🌈 Colorful Playground (#37)

* glb (color-assimp2 branch) + model-viewer

* color support + customizable output format

* cleanup

* disable monaco on iOS

* reinstate STL viewer when output is stl

* default to stl

* fix iOS editor

* script to build openscad-wasm from a branch / local repo

* update fork link + build instructions

* Update build-openscad-wasm.sh

* Update initial-state.ts

* update openscad3 refs

* update wasm build script

* Update Dockerfile.wasm

* add jszip dep

* multimaterial: color conversion + 3MF face painting

* ignore ts error about compression apis

* Update tsconfig.json

* multimaterial support w/ extruder colors input component

* fix wasm build script

* symlinks for openscad.{js,wasm} under src

* fix error line shift for bundled files

* remove obsolete build script

* set OPENSCADPATH env var

* render-colors feature renamed

* Customizer + prepare Multi-asset support

* hide customizer button if unselected, hide it if no params,

* Create build-openscad-wasm.sh

* update build script

* complete sound

* fetchSource util

* wider rotation allowance

* use CSG-modules.scad as default

* Update CustomizerPanel.tsx

* Update index.html

* capture save, allow Ctrl+Shift+Save to save project

* customizer: fieldsets are better than accordion

* expand customizer tabs by default

* tooltip for reset buttons

* pretty c++ exceptions

* Delete inline-openscad-worker.ts

* shuffle ux

* use uzip instead of jszip for 3mf materialization

* handle legacy links

* update links to fork

* use render-modifiers feature from color-assimp2 in preview mode

* include NopSCADlib's STL files in archive

* bind global f5 / f6

* update homepage back to openscad2

* styling fixes (editor alpha, customizer margins)

* Add help link for customizer

* fix customizer sliders (also need to show value input)

* fix vector customizer component

* style fallback editor

* shorter logs button

* customizer: rm reset button tooltip

* customizer: allow dnd on viewer below, tweak background

* add lights to modelviewer using an env image (gltf lights hard to plug across assimp for now)

* update customizer styles

* Update .gitignore

* add license for UZIP.js (MIT)

* Update skybox-lights.jpg

* Update actions.ts

* include NopSCADlib's STLs

* Update LICENSE.md

* include YAPP_Box examples

* limit customizer height

* Update skybox-lights.jpg

* Update skybox-lights.jpg

* Update openscad-worker.ts

* build-openscad-wasm.sh fix: docker won't mount files outside home directory

* Use Google CDN instead of unpkg for modelviewer

* tmp updates before merge

* Add ccache support to build-openscad-wasm.sh

* Fix makefile

* Update README.md

* Fix ts imports

* Delete build-openscad-wasm.sh

* Crude off -> glb converter

* Remove assimp related code

* gltf diffs

* Build using head openscad

* readme: Update build prerequisites

* make: attempt to build wasm binary in parallel first

* Remove 3mf extruder color mapping & fix render / export flows (render all 3d to off)

* off2glb: triangulate faces + add default color

* Add simple e2e puppeteer test

* ci: update pinned build to master output artefact

* Update Makefile

* Update test.yml

* Update test.yml

* Update test.yml

* Update test.yml

* ci: use relative node version names

* Update test.yml

* state: Remove renderFormat, introduce is2D

* disable service worker in devel mode

* remove dead code

* build: fix NODE_ENV define

* ci: simplify env vars

* cleanups

* Update README.md
This commit is contained in:
Olivier Chafik 2024-12-22 22:41:35 +00:00 committed by GitHub
commit f3cb9ae3f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1266 additions and 540 deletions

View file

@ -9,21 +9,29 @@ jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [ 18, 20, 22 ]
name: Node${{ matrix.node }}
node: [
{"name": "LTS", "version": "lts/-2"},
{"name": "latest", "version": "latest"}
]
name: Node ${{ matrix.node.name }}
env:
CI: true
steps:
- uses: actions/checkout@v4
- name: Setup node ${{ matrix.node }}
- name: Setup node ${{ matrix.node.name }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
node-version: ${{ matrix.node.version }}
- run: npm install
- run: make public
- run: npm run build
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: dist-node${{ matrix.node }}
name: dist-node-${{ matrix.node.name }}
path: dist
retention-days: 30
- run: NODE_ENV=development npm run test:e2e
- run: NODE_ENV=production npm run test:e2e

View file

@ -22,6 +22,7 @@ The WASM build was made possible by https://github.com/DSchroer/openscad-wasm.
* [GNU MP](#gnu-mp)
* [OpenSCAD](#openscad)
* [CGAL](#cgal)
* [JSZip](#jszip)
* [Manifold](#manifold)
* [Eigen](#eigen)
* [glib2](#glib2)
@ -59,6 +60,12 @@ https://github.com/elalish/manifold
Licensed under the [Apache 2.0](#apache2) license.
## JSZip
https://github.com/Stuk/jszip
Licensed under the [GPLv3](#gplv3) (actually dual-licensed under MIT, but we're picking this one here for brevity).
## PrimeReact
https://github.com/primefaces/primereact

View file

@ -1,5 +1,5 @@
# Pinning WASM build to last good revision (https://github.com/openscad/openscad-playground/issues/60)
WASM_BUILD_URL=https://files.openscad.org/snapshots/OpenSCAD-2024.09.27.wasm20596-WebAssembly.zip
# Pin WASM build to a version known to work
WASM_BUILD_URL=https://output.circle-artifacts.com/output/job/fdf49786-06c1-4775-aaa3-15dc089efc12/artifacts/0/wasm/OpenSCAD-2024.12.22.wasm21804-WebAssembly-web.zip
# WASM_SNAPSHOT_JS_URL=https://files.openscad.org/snapshots/.snapshot_wasm.js
# WASM_BUILD_URL=$(shell curl ${WASM_SNAPSHOT_JS_URL} 2>/dev/null | grep https | sed -E "s/.*(https:[^']+)'.*/\1/" )
@ -47,13 +47,21 @@ clean:
dist/index.js: public
npm run build
dist/openscad-worker.js: src/openscad-worker.ts
dist/openscad-worker.js: src/openscad-worker.ts src/wasm/openscad.js
npx rollup -c
src/wasm: libs/openscad-wasm
rm -f src/wasm
ln -sf "$(shell pwd)/libs/openscad-wasm" src/wasm
# libs/openscad/build/openscad.js: libs/openscad
# ( cd libs/openscad && ./scripts/wasm-base-docker-run.sh emcmake cmake -B build -DCMAKE_BUILD_TYPE=Release -DEXPERIMENTAL=1 )
# ( cd libs/openscad && ./scripts/wasm-base-docker-run.sh /bin/bash -c "cmake --build build -j || cmake --build build -j2 || cmake --build build" )
# libs/openscad-wasm: libs/openscad/build/openscad.js
# mkdir -p libs/openscad-wasm
# cp libs/openscad/build/openscad.* libs/openscad-wasm/
libs/openscad-wasm:
mkdir -p libs/openscad-wasm
wget ${WASM_BUILD_URL} -O libs/openscad-wasm.zip
@ -116,7 +124,8 @@ NOTO_FONTS=\
# libs/noto/NotoSansCJKtc-Regular.otf
public/libraries/fonts.zip: $(NOTO_FONTS) libs/liberation
zip -r $@ -j fonts.conf libs/noto/{*.ttf,*.otf} libs/liberation/{*.ttf,LICENSE,AUTHORS}
mkdir -p public/libraries
zip -r $@ -j fonts.conf libs/noto/*.ttf libs/liberation/{*.ttf,LICENSE,AUTHORS}
libs/noto/%.ttf:
mkdir -p libs/noto
@ -130,7 +139,7 @@ libs/liberation:
git clone --recurse https://github.com/shantigilbert/liberation-fonts-ttf.git ${SHALLOW} ${SINGLE_BRANCH} $@
libs/openscad:
git clone https://github.com/openscad/openscad.git ${SHALLOW} ${SINGLE_BRANCH} $@
git clone --recurse https://github.com/openscad/openscad.git ${SHALLOW} ${SINGLE_BRANCH} $@
public/libraries/openscad.zip: libs/openscad
mkdir -p public/libraries
@ -176,7 +185,7 @@ libs/YAPP_Box:
public/libraries/YAPP_Box.zip: libs/YAPP_Box
mkdir -p public/libraries
( cd libs/YAPP_Box ; zip -r ../../public/libraries/YAPP_Box.zip *.scad LICENSE )
( cd libs/YAPP_Box ; zip -r ../../public/libraries/YAPP_Box.zip `find . -name '*.scad'` LICENSE )
libs/MCAD:
git clone --recurse https://github.com/openscad/MCAD.git ${SHALLOW} ${SINGLE_BRANCH} $@

View file

@ -47,11 +47,17 @@ Prerequisites:
* wget
* GNU make
* npm
* Docker able to run amd64 containers. If running on a different platform (including Silicon Mac), you can add support for amd64 images through QEMU with:
```bash
docker run --privileged --rm tonistiigi/binfmt --install all
```
Local dev:
```bash
make public
npm install
npm start
# http://localhost:4000/
```
@ -60,6 +66,7 @@ Local prod (test both the different inlining and serving under a prefix):
```bash
make public
npm install
npm run start:prod
# http://localhost:3000/dist/
```
@ -68,7 +75,8 @@ Deployment (edit "homepage" in `package.json` to match your deployment root!):
```bash
make public
npm run build
npm install
NODE_ENV=production npm run build
rm -fR ../ochafik.github.io/openscad2 && cp -R dist ../ochafik.github.io/openscad2
# Now commit and push changes, wait for site update and enjoy!

View file

@ -1,25 +0,0 @@
#!/bin/bash
set -euo pipefail
if [ -z "${OPENSCAD_DIR:-}" ]; then
OPENSCAD_DIR=~/tmp/openscad-color
if [ ! -d "$OPENSCAD_DIR" ]; then
rm -fR "$OPENSCAD_DIR"
git clone --recurse https://github.com/ochafik/openscad.git \
--depth=1 --branch color-assimp2 --single-branch \
"$OPENSCAD_DIR"
fi
fi
docker run --rm -it -v "$OPENSCAD_DIR":/src:rw --platform=linux/amd64 openscad/wasm-base:latest \
emcmake cmake -B build -DEXPERIMENTAL=ON "$@" && \
docker run --rm -it -v "$OPENSCAD_DIR":/src:rw --platform=linux/amd64 openscad/wasm-base:latest \
cmake --build build -j10
rm -fR libs/openscad-wasm
mkdir -p libs/openscad-wasm
cp "$OPENSCAD_DIR/build/openscad.wasm" libs/openscad-wasm/
cp "$OPENSCAD_DIR/build/openscad.js" libs/openscad-wasm/
cp "$OPENSCAD_DIR/build/openscad.wasm.map" libs/openscad-wasm/ || true
( cd libs && zip -r ../dist/openscad-wasm.zip openscad-wasm )

10
jest-puppeteer.config.js Normal file
View file

@ -0,0 +1,10 @@
module.exports = {
launch: {
headless: process.env.CI === "true",
},
server: {
command: `npm run start:${process.env.NODE_ENV}`,
port: process.env.NODE_ENV === 'production' ? 3000 : 4000,
launchTimeout: 180000,
},
};

6
jest.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
preset: "jest-puppeteer",
testMatch: [
"**/tests/**/*.js",
],
};

View file

@ -4,6 +4,8 @@
"private": true,
"homepage": "https://ochafik.com/openscad2/",
"dependencies": {
"@gltf-transform/core": "^4.1.1",
"@gltf-transform/extensions": "^4.1.1",
"@monaco-editor/loader": "^1.3.2",
"@monaco-editor/react": "^4.4.6",
"@testing-library/jest-dom": "^5.16.5",
@ -14,18 +16,20 @@
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"debug": "^4.3.4",
"jszip": "^3.10.1",
"monaco-editor": "^0.36.1",
"primeflex": "^3.3.1",
"primeicons": "^6.0.1",
"primereact": "9.3.x",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-stl-viewer": "^2.2.5",
"react-widgets": "^5.8.4"
"react-stl-viewer": "^2.2.5"
},
"scripts": {
"start": "concurrently 'npx webpack serve --mode=development' 'NODE_ENV=development npx rollup --config openscad-worker.rollup.config.js --watch'",
"start:prod": "PUBLIC_URL=http://localhost:3000/dist/ npm run build && npx serve",
"test:e2e": "jest",
"start:development": "concurrently 'npx webpack serve --mode=development' 'NODE_ENV=development npx rollup --config openscad-worker.rollup.config.js --watch'",
"start:production": "NODE_ENV=production PUBLIC_URL=http://localhost:3000/dist/ npm run build && npx serve",
"start": "npm run start:development",
"build": "NODE_ENV=production npx rollup --config openscad-worker.rollup.config.js && webpack --mode=production"
},
"eslintConfig": {
@ -57,7 +61,10 @@
"concurrently": "^7.6.0",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"jest": "^29.7.0",
"jest-puppeteer": "^11.0.0",
"livereload": "^0.9.3",
"puppeteer": "^23.11.1",
"rollup": "^2.79.1",
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-css": "^1.0.0",
@ -68,7 +75,7 @@
"serve": "^14.2.0",
"style-loader": "^3.3.3",
"ts-loader": "^9.4.2",
"tslib": "^2.5.0",
"tslib": "^2.6.3",
"webpack": "^5.76.3",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.2",

BIN
public/skybox-lights.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -12,10 +12,6 @@ import { ConfirmDialog } from 'primereact/confirmdialog';
import CustomizerPanel from './CustomizerPanel';
// import "primereact/resources/themes/lara-light-indigo/theme.css";
// import "primereact/resources/primereact.min.css";
// import "primeicons/primeicons.css";
export function App({initialState, statePersister, fs}: {initialState: State, statePersister: StatePersister, fs: FS}) {
const [state, setState] = useState(initialState);

View file

@ -1,7 +1,7 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { CSSProperties, useContext } from 'react';
import { ModelContext, FSContext } from './contexts';
import { ModelContext } from './contexts';
import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider';
@ -48,6 +48,7 @@ export default function CustomizerPanel({className, style}: {className?: string,
style={{
display: 'flex',
flexDirection: 'column',
maxHeight: '80vh',
overflow: 'scroll',
...style,
bottom: 'unset',
@ -81,8 +82,6 @@ export default function CustomizerPanel({className, style}: {className?: string,
function ParameterInput({param, value, className, style, handleChange}: {param: Parameter, value: any, className?: string, style?: CSSProperties, handleChange: (key: string, value: any) => void}) {
return (
<div
// ref={ref}
// onClick={state.view.layout.mode === 'single' ? handleClick : undefined}
style={{
flex: 1,
...style,
@ -99,7 +98,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
justifyContent: 'space-between',
}}>
<div
// onClick={swallowClick}
style={{
flex: 1,
display: 'flex',
@ -109,10 +107,7 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
<div>{param.caption}</div>
</div>
<div
// onClick={swallowClick}
style={{
// flex: 1,
// margin: '10px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
@ -130,7 +125,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
)}
{param.type === 'string' && param.options && (
<Dropdown
// style={{flex: 1}}
value={value || param.initial}
options={param.options}
onChange={(e) => handleChange(param.name, e.value)}
@ -140,18 +134,15 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
)}
{param.type === 'boolean' && (
<Checkbox
// style={{flex: 1}}
checked={value ?? param.initial}
onChange={(e) => handleChange(param.name, e.checked)}
/>
)}
{!Array.isArray(param.initial) && param.type === 'number' && !('options' in param) && (
<InputNumber
// style={{flex: 1}}
value={value || param.initial}
showButtons
size={5}
// buttonLayout='horizontal'
onValueChange={(e) => handleChange(param.name, e.value)}
/>
)}
@ -176,7 +167,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
min={param.min}
max={param.max}
showButtons
// buttonLayout='horizontal'
size={5}
step={param.step}
onValueChange={(e) => {
@ -194,7 +184,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
marginRight: '0',
visibility: value === undefined || (JSON.stringify(value) === JSON.stringify(param.initial)) ? 'hidden' : 'visible',
}}
// tooltip={`Reset to default value (${JSON.stringify(param.initial)})`}
tooltipOptions={{position: 'left'}}
icon='pi pi-refresh'
className='p-button-text'/>

View file

@ -57,6 +57,18 @@ export default function EditorPanel({className, style}: {className?: string, sty
label: "Preview OpenSCAD",
run: () => model.render({isPreview: true, now: true})
});
editor.addAction({
id: "openscad-save-do-nothing",
label: "Save (disabled)",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {}
});
editor.addAction({
id: "openscad-save-project",
label: "Save OpenSCAD project",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyS],
run: () => model.saveProject()
});
setEditor(editor)
}
@ -114,45 +126,13 @@ export default function EditorPanel({className, style}: {className?: string, sty
{
separator: true
},
// https://vscode-docs.readthedocs.io/en/stable/customization/keybindings/
// {
// label: 'Undo',
// icon: 'pi pi-undo',
// // disabled: true,
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.undoAction', null),
// },
// {
// label: 'Redo',
// icon: 'pi pi-reply',
// // disabled: true,
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.redoAction', null),
// },
{
separator: true
},
// {
// label: 'Copy',
// icon: 'pi pi-copy',
// // disabled: true,
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCopyAction', null),
// },
// {
// label: 'Cut',
// icon: 'pi pi-eraser',
// // disabled: true,
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCutAction', null),
// },
// {
// label: 'Paste',
// icon: 'pi pi-images',
// // disabled: true,
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardPasteAction', null),
// },
{
label: 'Select All',
icon: 'pi pi-info-circle',
// disabled: true,
command: () => editor?.trigger(state.params.sourcePath, 'editor.action.selectAll', null),
command: () => editor?.trigger(state.params.activePath, 'editor.action.selectAll', null),
},
{
separator: true
@ -160,8 +140,7 @@ export default function EditorPanel({className, style}: {className?: string, sty
{
label: 'Find',
icon: 'pi pi-search',
// disabled: true,
command: () => editor?.trigger(state.params.sourcePath, 'actions.find', null),
command: () => editor?.trigger(state.params.activePath, 'actions.find', null),
},
] as MenuItem[]} popup ref={menu} />
<Button title="Editor menu" rounded text icon="pi pi-ellipsis-h" onClick={(e) => menu.current && menu.current.toggle(e)} />
@ -171,7 +150,7 @@ export default function EditorPanel({className, style}: {className?: string, sty
flex: 1,
}}/>
{state.params.sourcePath !== defaultSourcePath &&
{state.params.activePath !== defaultSourcePath &&
<Button icon="pi pi-chevron-left"
text
onClick={() => model.openFile(defaultSourcePath)}
@ -188,24 +167,21 @@ export default function EditorPanel({className, style}: {className?: string, sty
<Editor
className="openscad-editor absolute-fill"
defaultLanguage="openscad"
path={state.params.sourcePath}
value={state.params.source}
path={state.params.activePath}
value={model.source}
onChange={s => model.source = s ?? ''}
onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render??
options={{
...openscadEditorOptions,
fontSize: 16,
lineNumbers: state.view.lineNumbers ? 'on' : 'off',
// readOnly: !isFileWritable(state.params.sourcePath)
}}
/>
)}
{!isMonacoSupported && (
<InputTextarea
style={{
}}
className="openscad-editor absolute-fill"
value={state.params.source}
value={model.source}
onChange={s => model.source = s.target.value ?? ''}
/>
)}
@ -216,8 +192,9 @@ export default function EditorPanel({className, style}: {className?: string, sty
overflowY: 'scroll',
height: 'calc(min(200px, 30vh))',
}}>
<pre><code id="logs" style={{
}}>{state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
{(state.currentRunLogs ?? []).map(([type, text], i) => (
<pre key={i}>{text}</pre>
))}
</div>
</div>

View file

@ -0,0 +1,63 @@
import React, { useContext } from 'react';
import { ModelContext } from './contexts';
import { SplitButton } from 'primereact/splitbutton';
import { MenuItem } from 'primereact/menuitem';
export default function ExportButton({className, style}: {className?: string, style?: React.CSSProperties}) {
const model = useContext(ModelContext);
if (!model) throw new Error('No model');
const state = model.state;
const dropdownModel: MenuItem[] =
state.is2D ? [
{
data: 'svg',
label: 'SVG',
icon: 'pi pi-download',
command: () => model!.setFormats('svg', undefined),
},
{
data: 'dxf',
label: 'DXF',
icon: 'pi pi-download',
command: () => model!.setFormats('dxf', undefined),
},
] : [
{
data: 'glb',
label: 'GLB (glTF)',
icon: 'pi pi-download',
command: () => model!.setFormats(undefined, 'glb'),
},
{
data: 'stl',
label: 'STL',
icon: 'pi pi-download',
command: () => model!.setFormats(undefined, 'stl'),
},
{
data: 'off',
label: 'OFF',
icon: 'pi pi-download',
command: () => model!.setFormats(undefined, 'off'),
},
];
const exportFormat = state.is2D ? state.params.exportFormat2D : state.params.exportFormat3D;
const selectedItem = dropdownModel.filter(item => item.data === exportFormat)[0] || dropdownModel[0]!;
return (
<div className={className} style={style}>
<SplitButton
label={selectedItem.label}
disabled={!state.output || state.output.isPreview || state.rendering || state.exporting}
icon="pi pi-download"
model={dropdownModel}
severity="secondary"
onClick={e => model!.export()}
className="p-button-sm"
/>
</div>
);
}

View file

@ -4,8 +4,7 @@ import { CSSProperties, useContext } from 'react';
import { TreeSelect } from 'primereact/treeselect';
import TreeNode from 'primereact/treenode';
import { ModelContext, FSContext } from './contexts';
// import { isFileWritable } from '../state/model';
import { join } from '../fs/filesystem';
import { getParentDir, join } from '../fs/filesystem';
import { defaultSourcePath } from '../state/initial-state';
import { zipArchives } from '../fs/zip-archives';
@ -69,7 +68,6 @@ function listFilesAsNodes(fs: FS, path: string, accept?: (path: string) => boole
}
nodes.push({
// icon: isDirectory ? 'pi pi-folder' : isFileWritable(path) ? 'pi pi-file' : 'pi pi-lock',
icon: isDirectory ? 'pi pi-folder' : path === defaultSourcePath ? 'pi pi-home' : 'pi pi-file',
label,
data: path,
@ -89,13 +87,28 @@ export default function FilePicker({className, style}: {className?: string, styl
const fs = useContext(FSContext);
const fsItems = fs && listFilesAsNodes(fs, '/')
const fsItems: TreeNode[] = [];
for (const {path} of state.params.sources) {
const parent = getParentDir(path);
if (parent === '/') {
fsItems.push({
icon: 'pi pi-home',
label: path.split('/').pop(),
data: path,
key: path,
selectable: true,
});
}
}
if (fs) {
fsItems.push(...listFilesAsNodes(fs, '/'));
}
return (
<TreeSelect
className={className}
title='OpenSCAD Playground Files'
value={state.params.sourcePath}
value={state.params.activePath}
resetFilterOnHide={true}
filterBy="key"
onChange={e => {

View file

@ -1,7 +1,6 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { CSSProperties, useContext, useRef } from 'react';
import { State } from '../state/app-state'
import { ModelContext } from './contexts';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { Button } from 'primereact/button';
@ -9,39 +8,8 @@ import { ProgressBar } from 'primereact/progressbar';
import { Badge } from 'primereact/badge';
import { Menu } from 'primereact/menu';
import { Toast } from 'primereact/toast';
import SettingsMenu from './SettingsMenu';
import HelpMenu from './HelpMenu';
import { confirmDialog } from 'primereact/confirmdialog';
function downloadOutput(state: State) {
if (!state.output) return;
const sourcePathParts = state.params.sourcePath.split('/');
const sourceFileName = sourcePathParts.slice(-1)[0];
const fileName = [sourceFileName, state.output!.isPreview ? 'preview.stl' : 'render.stl'].join('.');
const doDownload = () => {
const a = document.createElement('a')
a.href = state.output!.stlFileURL
a.download = fileName;
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
};
if (state.output.isPreview && state.params.source.indexOf('$preview') >= 0) {
confirmDialog({
message: "This model references the $preview variable but hasn't been rendered (F6), or its rendering is stale. You're about to download the preview result itself, which may not have the intended refinement of the render. Sure you want to proceed?",
header: 'Preview vs. Render',
icon: 'pi pi-exclamation-triangle',
accept: doDownload,
acceptLabel: `Download ${fileName}`,
rejectLabel: 'Cancel'
// reject: () => {}
});
} else {
doDownload();
}
}
import ExportButton from './ExportButton';
export default function Footer({style}: {style?: CSSProperties}) {
@ -49,7 +17,6 @@ export default function Footer({style}: {style?: CSSProperties}) {
if (!model) throw new Error('No model');
const state = model.state;
const helpMenu = useRef<Menu>(null);
const toast = useRef<Toast>(null);
@ -76,35 +43,30 @@ export default function Footer({style}: {style?: CSSProperties}) {
style={{
marginLeft: '5px',
marginRight: '5px',
visibility: state.rendering || state.previewing || state.checkingSyntax
visibility: state.rendering || state.previewing || state.checkingSyntax || state.exporting
? 'visible' : 'hidden',
height: '6px' }}></ProgressBar>
<div className="flex flex-row gap-1" style={{
// display: 'flex',
// flexDirection: 'row',
// gap: '10px',
// verticalAlign: 'center',
alignItems: 'center',
margin: '5px',
...(style ?? {})
}}>
<Button onClick={() => model.render({isPreview: false, now: true})}
loading={state.rendering}
icon="pi pi-refresh"
title="Render the model (F6 / Ctrl+Enter). Models can test $preview to enable more detail in renders only."
label="Render" />
<Button
icon="pi pi-bolt"
onClick={() => model.render({isPreview: false, now: true})}
className="p-button-sm"
label="Render"
/>
<ExportButton />
{(state.lastCheckerRun || state.output) &&
<Button type="button"
// label={state.view.logs ? "Hide logs" : "Show logs"}
label="Logs"
severity={maxMarkerSeverity && severityByMarkerSeverity.get(maxMarkerSeverity)}
icon="pi pi-align-left"
text={!state.view.logs}
onClick={() => model.logsVisible = !state.view.logs}
// onLabel="Logs" offLabel="Logs"
// onIcon="pi pi-align-left"
// offIcon="pi pi-align-left"
className={maxMarkerSeverity && `p-button-${severityByMarkerSeverity.get(maxMarkerSeverity) ?? 'success'}`}
>
{getBadge(monaco.MarkerSeverity.Error)}
@ -112,33 +74,15 @@ export default function Footer({style}: {style?: CSSProperties}) {
{getBadge(monaco.MarkerSeverity.Info)}
</Button>}
{state.output && (
<Button icon='pi pi-download'
title={`Download ${state.output.isPreview ? "preview.stl" : "render.stl"} (${state.output.formattedStlFileSize})\nGenerated in ${state.output.formattedElapsedMillis}`}
severity="secondary"
text
// label={state.output.isPreview ? "preview.stl" : "render.stl"}
iconPos='right'
onClick={() => downloadOutput(state)} />
)}
<div style={{flex: 1}}></div>
{/* {state.output &&
<span style={{color: 'blue'}}>
<i className="pi pi-stopwatch" title={state.error}></i>
<span style={{margin: '5px'}}>{state.output.formattedElapsedMillis}</span>
</span>} */}
{/* {state.previewing && 'previewing... '}
{state.rendering && 'rendering... '}
{state.checkingSyntax && 'checking syntax... '} */}
{/* <span style={{flex: 1}}></span> */}
<span style={{flex: 1}}></span>
<HelpMenu style={{
position: 'absolute',
right: 0,
top: '4px',
}} />
<Toast ref={toast} />
<HelpMenu />
</div>
</>
}

View file

@ -1,25 +1,25 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { useRef } from 'react';
import { CSSProperties, useRef } from 'react';
import { Button } from 'primereact/button';
import { MenuItem } from 'primereact/menuitem';
import { Menu } from 'primereact/menu';
export default function HelpMenu() {
export default function HelpMenu({className, style}: {className?: string, style?: CSSProperties}) {
const menuRef = useRef<Menu>(null);
return (
<>
<Menu model={[
<Menu style={style} className={className} model={[
{
label: "openscad-playground",
icon: 'pi pi-github',
url: 'https://github.com/openscad/openscad-playground',
url: 'https://github.com/ochafik/openscad-playground/tree/color-assimp2',
target: '_blank'
},
{
label: 'LICENSES',
icon: 'pi pi-info-circle',
url: 'https://github.com/openscad/openscad-playground/blob/main/LICENSE.md',
url: 'https://github.com/ochafik/openscad-playground/blob/color-assimp2/LICENSE.md',
target: '_blank'
},
{
@ -48,7 +48,12 @@ export default function HelpMenu() {
target: '_blank'
},
] as MenuItem[]} popup ref={menuRef} />
<Button title="Help & Licenses" rounded icon="pi pi-question-circle" onClick={(e) => menuRef.current && menuRef.current.toggle(e)} />
<Button title="Help & Licenses"
rounded
text
icon="pi pi-question-circle"
onClick={(e) => menuRef.current && menuRef.current.toggle(e)} />
</>
);
}

View file

@ -5,7 +5,6 @@ import { SingleLayoutComponentId } from '../state/app-state'
import { TabMenu } from 'primereact/tabmenu';
import { ToggleButton } from 'primereact/togglebutton';
import { ModelContext, FSContext } from './contexts';
import SettingsMenu from './SettingsMenu';
export default function PanelSwitcher() {
const model = useContext(ModelContext);
@ -36,18 +35,13 @@ export default function PanelSwitcher() {
margin: '5px'
}}>
{multiTargets.map(({icon, label, id}) =>
// <Button
<ToggleButton
key={id}
// raised={(state.view.layout as any)[id]}
checked={(state.view.layout as any)[id]}
// label={label}
onLabel={label}
offLabel={label}
onIcon={icon}
offIcon={icon}
// icon={icon}
// onClick={() => model.changeMultiVisibility(id, !(state.view.layout as any)[id])}
onChange={e => model.changeMultiVisibility(id, e.value)}
/>
)}
@ -57,17 +51,11 @@ export default function PanelSwitcher() {
activeIndex={singleTargets.map(t => t.id).indexOf(state.view.layout.focus)}
style={{
flex: 1,
// justifyContent: 'center'
}}
model={singleTargets.map(({icon, label, id}) =>
({icon, label/*, disabled: id === 'customizer' && state?.parameterSet == null*/, command: () => model.changeSingleVisibility(id)}))} />
({icon, label, command: () => model.changeSingleVisibility(id)}))} />
</>
}
<SettingsMenu style={{
position: 'absolute',
right: 0,
top: '4px',
}} />
</div>
</div>
);

View file

@ -1,16 +1,25 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { CSSProperties, forwardRef, useContext, useEffect, useRef, useState } from 'react';
import { CSSProperties, useContext, useRef } from 'react';
import { ModelContext } from './contexts';
import { StlViewer} from "react-stl-viewer";
import { ColorPicker } from 'primereact/colorpicker';
import { defaultModelColor } from '../state/initial-state';
declare global {
namespace JSX {
interface IntrinsicElements {
"model-viewer": any;
}
}
}
export default function ViewerPanel({className, style}: {className?: string, style?: CSSProperties}) {
const model = useContext(ModelContext);
if (!model) throw new Error('No model');
const state = model.state;
const modelRef = useRef();
return (
<div className={className}
@ -18,28 +27,41 @@ export default function ViewerPanel({className, style}: {className?: string, sty
display: 'flex',
flexDirection: 'column',
flex: 1,
position: 'relative',
width: '100%',
...(style ?? {})
}}>
{state.output?.stlFileURL &&
<StlViewer
className='absolute-fill'
{(state.output?.displayFileURL || state.output?.outFile && state.output.outFile.name.endsWith('.glb') && state.output?.outFileURL) && (
<model-viewer
src={state.output?.displayFileURL ?? state.output?.outFileURL ?? ''}
style={{
zIndex: 0,
// flex: 1,
// width: '100%'
width: '100%',
height: '100%',
}}
environment-image="./skybox-lights.jpg"
max-camera-orbit="auto 180deg auto"
min-camera-orbit="auto 0deg auto"
camera-controls
ar
ref={(ref: any) => {
modelRef.current = ref;
}}
/>
)}
{state.output?.outFile && state.output.outFile.name.endsWith('.stl') && state.output?.outFileURL && (
<>
<StlViewer
style={{
width: '100%',
height: '100%',
}}
// ref={stlModelRef}
showAxes={state.view.showAxes}
orbitControls
shadows={state.view.showShadows}
modelProps={{
color: model.state.view.color,
}}
url={state.output?.stlFileURL ?? ''}
/>}
url={state.output?.outFileURL ?? ''}
/>
<ColorPicker
className={`opacity-animated ${!model.isComponentFullyVisible('viewer') ? 'opacity-0' : ''}`}
value={model.state.view.color}
@ -47,11 +69,11 @@ export default function ViewerPanel({className, style}: {className?: string, sty
position: 'absolute',
top: '12px',
left: '12px',
// pointerEvents: model.isComponentFullyVisible('viewer') ? undefined : 'none',
}}
onChange={(e) => model.mutate(s => s.view.color = `#${e.value ?? defaultModelColor}`)} />
onChange={(e) => model.mutate(s => s.view.color = `#${e.value ?? defaultModelColor}`)}
/>
</>
)}
</div>
)
}

View file

@ -1,7 +1,5 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
// import { FS } from "./filesystem";
declare interface FS {
writeFile(path: string, content: string): void;
readdir(path: string, cb: (err: any, files: string[]) => void): void;

View file

@ -45,13 +45,11 @@ export async function getBrowserFSLibrariesMounts(archiveNames: string[]) {
export async function symlinkLibraries(archiveNames: string[], fs: FS, prefix='/libraries', cwd='/tmp') {
const createSymlink = async (target: string, source: string) => {
// console.log('symlink', target, source);
try {
await fs.symlink(target, source);
} catch (e) {
console.error(`symlink(${target}, ${source}) failed: `, e);
}
// await symlink(target, source);
};
await Promise.all(archiveNames.map(n => (async () => {
@ -111,8 +109,6 @@ export async function createEditorFS({prefix, allowPersistence}: {prefix: string
});
var fs = BrowserFS.BFSRequire('fs');
// const symlink = (target, source) => new Promise((res, rej) => fs.symlink(target, source, (err) => err ? rej(err) : res()));
// await setupLibraries(archiveNames, symlink, '/libraries', workingDir);
return fs;
}

View file

@ -32,9 +32,9 @@ if (process.env.NODE_ENV !== 'production') {
declare var BrowserFS: BrowserFSInterface
window.addEventListener('load', async () => {
//*
if (process.env.NODE_ENV === 'production') {
if ('serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.register('./sw.js');
@ -56,6 +56,7 @@ window.addEventListener('load', async () => {
console.log('ServiceWorker registration failed: ', err);
}
}
}
//*/
registerCustomAppHeightCSSProperty();
@ -88,7 +89,7 @@ window.addEventListener('load', async () => {
};
}
const initialState = createInitialState(fs, persistedState);
const initialState = createInitialState(persistedState);
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement

View file

@ -4,7 +4,6 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default {
lineNumbers: 'on',
// automaticLayout: true,
scrollBeyondLastLine: false,
fontSize: 12,
language: 'openscad',
@ -12,37 +11,10 @@ export default {
wordWrap: 'on',
wrappingStrategy: 'advanced',
suggest: {
// snippetsPreventQuickSuggestions: false,
localityBonus: true,
showStatusBar: true,
preview: true,
},
codeLens: true,
// language: 'javascript',
wordBasedSuggestions: false,
} as monaco.editor.IStandaloneEditorConstructionOptions;
// monaco.editor.IModelContentChangedEvent
// function SCADEditor({input, onInputChanged, ...props}: {input: string, onInputChanged: (value?: string) => void, height: string}) {
// // let editor: monaco.editor.IStandaloneCodeEditor;
// // function editorDidMount(e) {
// // editor = e;
// // console.log('editorDidMount', monaco.languages.getLanguages(), editor);
// // // editor.
// // editor.setModel(monaco.editor.createModel('sphere(123);', 'openscad'));
// // // editor.trigger('anything', 'editor.action.triggerSuggest', {});
// // }
// return (
// <Editor {...props}
// className="openscad-editor"
// defaultLanguage="openscad"
// value={n}
// onChange={setValue}
// options={options} />
// // onMount={editorDidMount} />
// );
// }

View file

@ -21,7 +21,6 @@ const builtInModuleNames = [
'render', 'resize', 'rotate', 'round', 'scale',
'sphere', 'square', 'surface', 'translate',
'union', 'use', 'value', 'version',
// 'center', 'width', 'height',
];
const builtInVarNames = [
'false', 'true', 'PI', 'undef', '$children',

View file

@ -4,7 +4,6 @@ import loader from '@monaco-editor/loader';
import { ZipArchives } from '../fs/zip-archives';
import { buildOpenSCADCompletionItemProvider } from './openscad-completions';
import openscadLanguage from './openscad-language';
// import jsLanguage from 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
// https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
export async function registerOpenSCADLanguage(fs: any, workingDir: string, zipArchives: ZipArchives) {
@ -16,7 +15,6 @@ export async function registerOpenSCADLanguage(fs: any, workingDir: string, zipA
mimetypes: ["text/openscad"],
});
//const [inheritLanguage] = monaco.languages.getLanguages().filter(l => l.id === 'javascript');
const { conf, language } = openscadLanguage;
monaco.languages.setLanguageConfiguration('openscad', conf);
monaco.languages.setMonarchTokensProvider('openscad', language);

View file

@ -0,0 +1,153 @@
import { Document, NodeIO, Accessor, Primitive } from '@gltf-transform/core';
import { Light as LightDef, KHRLightsPunctual } from '@gltf-transform/extensions';
type Vertex = {
x: number;
y: number;
z: number;
}
type SolidColor = [number, number, number];
type Face = {
vertices: number[];
color?: SolidColor;
}
type IndexedPolyhedron = {
vertices: Vertex[];
faces: Face[];
}
const DEFAULT_FACE_COLOR: SolidColor = [0xf9 / 255, 0xd7 / 255, 0x2c / 255];
export async function convertOffToGlb(data: IndexedPolyhedron, defaultColor: SolidColor = DEFAULT_FACE_COLOR): Promise<Blob> {
// Note: GLTF doesn't seem to support per-face colors, so we duplicate vertices
// and provide per-vertex colors (all the same for each face).
const numVertices = data.faces.reduce((acc, face) => acc + face.vertices.length, 0);
const positions = new Float32Array(numVertices * 3);
const colors = new Float32Array(numVertices * 3);
const indices = new Uint32Array(numVertices);
let verticesAdded = 0;
const addVertex = (vertex: Vertex, color: [number, number, number]) => {
const offset = verticesAdded * 3;
positions[offset] = vertex.x;
positions[offset + 1] = vertex.y;
positions[offset + 2] = vertex.z;
colors[offset] = color[0];
colors[offset + 1] = color[1];
colors[offset + 2] = color[2];
return verticesAdded++;
};
data.faces.forEach((face, i) => {
const { vertices, color } = face;
if (vertices.length < 3) throw new Error('Face must have at least 3 vertices');
const faceColor = color ?? defaultColor;
const offset = i * 3;
indices[offset] = addVertex(data.vertices[vertices[0]], faceColor);
indices[offset + 1] = addVertex(data.vertices[vertices[1]], faceColor);
indices[offset + 2] = addVertex(data.vertices[vertices[2]], faceColor);
});
const doc = new Document();
const lightExt = doc.createExtension(KHRLightsPunctual);
const buffer = doc.createBuffer();
doc.createScene()
.addChild(doc.createNode().setMesh(
doc.createMesh().addPrimitive(
doc.createPrimitive()
.setMode(Primitive.Mode.TRIANGLES)
.setMaterial(
doc.createMaterial()
.setDoubleSided(true)
.setBaseColorFactor([1,1,1,1]))
.setAttribute('POSITION',
doc.createAccessor()
.setType(Accessor.Type.VEC3)
.setArray(positions)
.setBuffer(buffer))
.setIndices(
doc.createAccessor()
.setType(Accessor.Type.SCALAR)
.setArray(indices)
.setBuffer(buffer))
.setAttribute('COLOR_0',
doc.createAccessor()
.setType(Accessor.Type.VEC3)
.setArray(colors)
.setBuffer(buffer)))))
.addChild(doc.createNode()
.setExtension('KHR_lights_punctual', lightExt
.createLight()
.setType(LightDef.Type.DIRECTIONAL)
.setIntensity(8.0)
.setColor([1.0, 1.0, 1.0]))
.setRotation([-0.3250576, -0.3250576, 0, 0.8880739]))
.addChild(doc.createNode()
.setExtension('KHR_lights_punctual', lightExt
.createLight()
.setType(LightDef.Type.DIRECTIONAL)
.setIntensity(8.0)
.setColor([1.0, 1.0, 1.0]))
.setRotation([0.6279631, 0.6279631, 0, 0.4597009]));
const glb = await new NodeIO().registerExtensions([KHRLightsPunctual]).writeBinary(doc);
return new Blob([glb], { type: 'model/gltf-binary' });
}
export function parseOff(content: string): IndexedPolyhedron {
const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0 && !line.startsWith('#'));
if (lines.length === 0) throw new Error('Empty OFF file');
let counts: string;
let currentLine = 0;
if (lines[0].match(/^OFF(\s|$)/)) {
counts = lines[0].substring(3).trim();
currentLine = 1;
} else if (lines[currentLine] === 'OFF' && lines.length > 1) {
counts = lines[1];
currentLine = 2;
} else {
throw new Error('Invalid OFF file: missing OFF header');
}
const [numVertices, numFaces] = counts.split(/\s+/).map(Number);
if (isNaN(numVertices) || isNaN(numFaces)) throw new Error('Invalid OFF file: invalid vertex or face counts');
if (currentLine + numVertices + numFaces > lines.length) throw new Error('Invalid OFF file: not enough lines');
const vertices: Vertex[] = [];
for (let i = 0; i < numVertices; i++) {
const parts = lines[currentLine + i].split(/\s+/).map(Number);
if (parts.length < 3 || parts.some(isNaN)) throw new Error(`Invalid OFF file: invalid vertex at line ${currentLine + i + 1}`);
vertices.push({ x: parts[0], y: parts[1], z: parts[2] });
}
currentLine += numVertices;
const faces: Face[] = [];
for (let i = 0; i < numFaces; i++) {
const parts = lines[currentLine + i].split(/\s+/).map(Number);
const numVerts = parts[0];
const vertices = parts.slice(1, numVerts + 1);
const color = parts.length >= numVerts + 4
? parts.slice(numVerts + 1, numVerts + 4).map(c => c / 255) as [number, number, number]
: undefined;
if (vertices.length < 3) throw new Error(`Invalid OFF file: face at line ${currentLine + i + 1} must have at least 3 vertices`);
else if (vertices.length == 3) {
faces.push({ vertices, color });
} else {
// Triangulate the face
for (let j = 1; j < vertices.length - 1; j++) {
faces.push({ vertices: [vertices[0], vertices[j], vertices[j + 1]], color });
}
}
}
return { vertices, faces };
}

View file

@ -1,27 +1,37 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { getParentDir } from '../fs/filesystem';
import { spawnOpenSCAD } from "./openscad-runner";
import { ProcessStreams, spawnOpenSCAD } from "./openscad-runner";
import { processMergedOutputs } from "./output-parser";
import { AbortablePromise, turnIntoDelayableExecution } from '../utils';
import { Source } from '../state/app-state';
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from '../state/formats';
import { ParameterSet } from '../state/customizer-types';
const syntaxDelay = 300;
type SyntaxCheckArgs = {
activePath: string,
sources: Source[],
}
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[], parameterSet?: ParameterSet};
export const checkSyntax =
turnIntoDelayableExecution(syntaxDelay, (source: string, sourcePath: string) => {
// const timestamp = Date.now();
turnIntoDelayableExecution(syntaxDelay, (sargs: SyntaxCheckArgs) => {
const {
activePath,
sources,
} = sargs;
source = '$preview=true;\n' + source;
const content = '$preview=true;\n' + sources[0].content;
const outFile = 'out.json';
const job = spawnOpenSCAD({
inputs: [[sourcePath, source + '\n']],
args: [sourcePath, "-o", outFile, "--export-format=param"],
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home'
mountArchives: true,
inputs: sources,
args: [activePath, "-o", outFile, "--export-format=param"],
outputPaths: [outFile],
}, (streams) => {
console.log(JSON.stringify(streams));
});
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
@ -46,7 +56,7 @@ export const checkSyntax =
res({
...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
sourcePath,
sourcePath: sources[0].path,
skipLines: 1,
}}),
parameterSet,
@ -61,15 +71,22 @@ export const checkSyntax =
});
var renderDelay = 1000;
export type RenderOutput = {stlFile: File, logText: string, markers: monaco.editor.IMarkerData[], elapsedMillis: number}
export type RenderOutput = {
outFile: File,
logText: string,
markers: monaco.editor.IMarkerData[],
elapsedMillis: number}
export type RenderArgs = {
source: string,
sourcePath: string,
scadPath: string,
sources: Source[],
vars?: {[name: string]: any},
features?: string[],
extraArgs?: string[],
isPreview: boolean
isPreview: boolean,
mountArchives: boolean,
renderFormat: keyof typeof VALID_EXPORT_FORMATS_2D | keyof typeof VALID_EXPORT_FORMATS_3D | keyof typeof VALID_RENDER_FORMATS,
streamsCallback: (ps: ProcessStreams) => void,
}
function formatValue(any: any): string {
@ -82,40 +99,63 @@ function formatValue(any: any): string {
}
}
export const render =
turnIntoDelayableExecution(renderDelay, ({sourcePath, source, isPreview, vars, features, extraArgs}: RenderArgs) => {
turnIntoDelayableExecution(renderDelay, (renderArgs: RenderArgs) => {
const {
scadPath,
sources,
isPreview,
mountArchives,
vars,
features: additionalFeatures,
extraArgs,
renderFormat,
streamsCallback,
} = renderArgs;
const prefixLines: string[] = [];
let features: string[]
if (isPreview) {
prefixLines.push('$preview=true;');
features = ['render-modifiers', ...(additionalFeatures ?? [])];
} else {
features = (additionalFeatures ?? []).filter(f => f != 'render-modifiers');
}
source = [...prefixLines, source].join('\n');
if (!scadPath.endsWith('.scad')) throw new Error('First source must be a .scad file, got ' + sources[0].path + ' instead');
const source = sources.filter(s => s.path === scadPath)[0];
if (!source) throw new Error('Active path not found in sources!');
if (source.content == null) throw new Error('Source content is null!');
const content = [...prefixLines, source.content].join('\n');
const actualRenderFormat = renderFormat == 'glb' ? 'off' : renderFormat;
const outFile = 'out.' + actualRenderFormat;
const args = [
sourcePath,
"-o", "out.stl",
"--export-format=binstl",
scadPath,
"-o", outFile,
"--backend=manifold",
"--export-format=" + (actualRenderFormat == 'stl' ? 'binstl' : actualRenderFormat),
...(Object.entries(vars ?? {}).flatMap(([k, v]) => [`-D${k}=${formatValue(v)}`])),
...(features ?? []).map(f => `--enable=${f}`),
...(extraArgs ?? [])
]
const job = spawnOpenSCAD({
// wasmMemory,
inputs: [[sourcePath, source]],
mountArchives: mountArchives,
inputs: sources.map(s => s.path === scadPath ? {path: s.path, content} : s),
args,
outputPaths: ['out.stl'],
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home'
});
outputPaths: [outFile],
}, streamsCallback);
return AbortablePromise<RenderOutput>((resolve, reject) => {
(async () => {
try {
const result = await job;
console.log(result);
// console.log(result);
const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
shiftSourceLines: {
sourcePath: sourcePath,
sourcePath: source.path,
skipLines: prefixLines.length
}
});
@ -131,13 +171,13 @@ export const render =
}
const [filePath, content] = output;
const filePathFragments = filePath.split('/');
const fileName = filePathFragments[filePathFragments.length - 1];
let fileName = filePathFragments[filePathFragments.length - 1];
// TODO: have the runner accept and return files.
const blob = new Blob([content], { type: "application/octet-stream" });
// console.log(new TextDecoder().decode(content));
const stlFile = new File([blob], fileName);
resolve({stlFile, logText, markers, elapsedMillis: result.elapsedMillis});
const type = filePath.endsWith('.svg') ? 'image/svg+xml' : 'application/octet-stream';
let blob = new Blob([content]);
let outFile = new File([blob], fileName, {type});
resolve({outFile, logText, markers, elapsedMillis: result.elapsedMillis});
} catch (e) {
console.error(e);
reject(e);
@ -147,3 +187,4 @@ export const render =
return () => job.kill()
});
});

View file

@ -2,6 +2,7 @@
import { MergedOutputs } from "./openscad-worker";
import { AbortablePromise } from "../utils";
import { Source } from "../state/app-state";
export function createWasmMemory({maximumMegabytes, maximumBytes}: {maximumMegabytes: number, maximumBytes: number}) {
const pageSize = 64 * 1024; // 64KB
@ -18,20 +19,22 @@ export function createWasmMemory({maximumMegabytes, maximumBytes}: {maximumMegab
// Output is {outputs: [name, content][], mergedOutputs: [{(stderr|stdout|error)?: string}], exitCode: number}
export type OpenSCADInvocation = {
wasmMemory?: WebAssembly.Memory,
// workingDir: string,
inputs?: [string, string][],
mountArchives: boolean,
inputs?: Source[],
args: string[],
outputPaths?: string[],
}
export type OpenSCADInvocationResults = {
exitCode: number,
exitCode?: number,
error?: string,
outputs?: [string, string][],
mergedOutputs: MergedOutputs,
elapsedMillis: number,
}
};
export type ProcessStreams = {stderr: string} | {stdout: string}
export type OpenSCADInvocationCallback = {result: OpenSCADInvocationResults} | ProcessStreams;
export function spawnOpenSCAD(invocation: OpenSCADInvocation): AbortablePromise<OpenSCADInvocationResults> {
export function spawnOpenSCAD(invocation: OpenSCADInvocation, streamsCallback: (ps: ProcessStreams) => void): AbortablePromise<OpenSCADInvocationResults> {
var worker: Worker | null;
var rejection: (err: any) => void;
@ -44,21 +47,19 @@ export function spawnOpenSCAD(invocation: OpenSCADInvocation): AbortablePromise<
}
return AbortablePromise<OpenSCADInvocationResults>((resolve, reject) => {
worker = new Worker('./openscad-worker.js');//, {type: "module"})
// if (navigator.userAgent.indexOf(' Chrome/') < 0) {
// worker = new Worker('./openscad-worker-firefox.js'); // {'type': 'module'}
// } else {
// worker = new Worker('./openscad-worker.js', {'type': 'module'});
// }
worker = new Worker('./openscad-worker.js');
rejection = reject;
worker.onmessage = (e: {data: OpenSCADInvocationResults}) => {
resolve(e.data);
worker.onmessage = (e: {data: OpenSCADInvocationCallback}) => {
if ('result' in e.data) {
resolve(e.data.result);
terminate();
} else {
streamsCallback(e.data);
}
}
worker.postMessage(invocation)
return () => {
// rejection({error: 'Terminated'});
terminate();
};
});

View file

@ -2,36 +2,55 @@
import OpenSCAD from "../wasm/openscad.js";
import { createEditorFS, symlinkLibraries } from "../fs/filesystem";
import { OpenSCADInvocation, OpenSCADInvocationResults } from "./openscad-runner";
import { createEditorFS, getParentDir, symlinkLibraries } from "../fs/filesystem";
import { OpenSCADInvocation, OpenSCADInvocationCallback, OpenSCADInvocationResults } from "./openscad-runner";
import { deployedArchiveNames, zipArchives } from "../fs/zip-archives";
import { fetchSource } from "../utils.js";
import { stderr } from "process";
declare var BrowserFS: BrowserFSInterface
importScripts("browserfs.min.js");
// importScripts("https://cdnjs.cloudflare.com/ajax/libs/BrowserFS/2.0.0/browserfs.min.js");
export type MergedOutputs = {stdout?: string, stderr?: string, error?: string}[];
function callback(payload: OpenSCADInvocationCallback) {
postMessage(payload);
}
addEventListener('message', async (e) => {
const { inputs, args, outputPaths, wasmMemory } = e.data as OpenSCADInvocation;
const {
mountArchives,
inputs,
args,
outputPaths,
wasmMemory,
} = e.data as OpenSCADInvocation;
const mergedOutputs: MergedOutputs = [];
let instance: any;
const start = performance.now();
try {
const instance = await OpenSCAD({
instance = await OpenSCAD({
wasmMemory,
buffer: wasmMemory && wasmMemory.buffer,
noInitialRun: true,
'print': (text: string) => {
console.debug('stdout: ' + text);
callback({stdout: text})
mergedOutputs.push({ stdout: text })
},
'printErr': (text: string) => {
console.debug('stderr: ' + text);
callback({stderr: text})
mergedOutputs.push({ stderr: text })
},
'ENV': {
'OPENSCADPATH': '/libraries',
},
});
if (mountArchives) {
// This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip
await createEditorFS({prefix: '', allowPersistence: false});
@ -50,26 +69,50 @@ addEventListener('message', async (e) => {
instance.FS.mount(BFS, {root: '/'}, '/libraries');
await symlinkLibraries(deployedArchiveNames, instance.FS, '/libraries', "/");
}
// Fonts are seemingly resolved from $(cwd)/fonts
instance.FS.chdir("/");
// const walkFolder = (path: string, indent = '') => {
// console.log("Walking " + path);
// instance.FS.readdir(path)?.forEach((f: string) => {
// if (f.startsWith('.')) {
// return;
// }
// const ii = indent + ' ';
// const p = `${path != '/' ? path + '/' : '/'}${f}`;
// console.log(`${ii}${p}`);
// walkFolder(p, ii);
// });
// };
// walkFolder('/libraries');
if (inputs) {
for (const [path, content] of inputs) {
for (const source of inputs) {
try {
// const parent = getParentDir(path);
instance.FS.writeFile(path, content);
// fs.writeFile(path, content);
instance.FS.writeFile(source.path, await fetchSource(source));
} catch (e) {
console.error(`Error while trying to write ${path}`, e);
console.trace(e);
throw new Error(`Error while trying to write ${source.path}: ${e}`);
}
}
}
console.log('Invoking OpenSCAD with: ', args)
const start = performance.now();
const exitCode = instance.callMain(args);
let exitCode;
try {
exitCode = instance.callMain(args);
} catch(e){
if(typeof e === "number" && instance.formatException){
// The number was a raw C++ exception
// See https://github.com/emscripten-core/emscripten/pull/16343
e = instance.formatException(e);
}
throw new Error(`OpenSCAD invocation failed: ${e}`);
}
const end = performance.now();
const elapsedMillis = end - start;
const outputs: [string, string][] = [];
for (const path of (outputPaths ?? [])) {
@ -77,26 +120,34 @@ addEventListener('message', async (e) => {
const content = instance.FS.readFile(path);
outputs.push([path, content]);
} catch (e) {
console.trace(`Failed to read output file ${path}`, e);
console.trace(e);
throw new Error(`Failed to read output file ${path}: ${e}`);
}
}
const result: OpenSCADInvocationResults = {
outputs,
mergedOutputs,
exitCode,
elapsedMillis: end - start
elapsedMillis,
}
console.debug(result);
postMessage(result);
callback({result});
} catch (e) {
console.trace(e);//, e instanceof Error ? e.stack : '');
const end = performance.now();
const elapsedMillis = end - start;
console.trace(e);
const error = `${e}`;
mergedOutputs.push({ error });
postMessage({
callback({
result: {
exitCode: undefined,
error,
mergedOutputs,
} as OpenSCADInvocationResults);
elapsedMillis,
}
});
}
});

View file

@ -11,6 +11,7 @@ type MergedOutputsOptions = {
skipLines: number,
}
}
export const processMergedOutputs = (outputs: MergedOutputs, opts: MergedOutputsOptions) => ({
logText: joinMergedOutputs(outputs, opts),
markers: parseMergedOutputs(outputs, opts)

View file

@ -45,7 +45,6 @@ export interface PlaygroundState {
fileExplorerPath?: string,
scadInputPath?: string,
outputLogs?: {
// sourceLastModified: number,
stdout: string,
stderr: string,
errors: {

View file

@ -2,20 +2,41 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { ParameterSet } from './customizer-types';
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from './formats';
export type MultiLayoutComponentId = 'editor' | 'viewer' | 'customizer';
export type SingleLayoutComponentId = MultiLayoutComponentId;
export type Source = {
// If path ends w/ /, it's a directory, and URL should contain a ZIP file that can be mounted
path: string,
url?: string,
content?: string,
};
export interface FileOutput {
outFile: File,
outFileURL: string,
displayFile?: File,
displayFileURL?: string,
elapsedMillis: number,
formattedElapsedMillis: string,
formattedOutFileSize: string,
}
export interface State {
params: {
sourcePath: string,
source: string,
activePath: string,
sources: Source[],
vars?: {[name: string]: any},
features: string[],
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D,
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D,
},
view: {
logs?: boolean,
extruderPicker?: boolean,
layout: {
mode: 'single',
focus: SingleLayoutComponentId,
@ -31,28 +52,24 @@ export interface State {
lineNumbers?: boolean,
}
currentRunLogs?: ['stderr'|'stdout', string][],
lastCheckerRun?: {
logText: string,
markers: monaco.editor.IMarkerData[]
markers: monaco.editor.IMarkerData[],
}
rendering?: boolean,
previewing?: boolean,
exporting?: boolean,
checkingSyntax?: boolean,
parameterSet?: ParameterSet,
error?: string,
output?: {
is2D?: boolean,
output?: FileOutput & {
isPreview: boolean,
stlFile: File,
stlFileURL: string,
elapsedMillis: number,
formattedElapsedMillis: string,
formattedStlFileSize: string,
// path: string,
// timestamp: number,
// sizeBytes: number,
// formattedSize: string,
},
export?: FileOutput,
};
export interface StatePersister {

View file

@ -17,29 +17,112 @@ export default `/*
https://github.com/openscad/openscad-playground/issues/new
*/
// Click on Render or hit F6 to do a fine-grained rendering.
$fn=$preview ? 20 : 100;
title = "OpenSCAD";
translate([-24,0,0]) {
union() {
cube(15, center=true);
sphere(10);
}
}
intersection() {
cube(15, center=true);
sphere(10);
}
translate([24,0,0]) {
difference() {
cube(15, center=true);
sphere(10);
}
}
translate([0, -30, -12])
color("gray")
translate([0, debug ? -60 : -20, 0])
linear_extrude(1)
text("OpenSCAD Playground", halign="center", valign="center");
text(title,
halign="center",
valign="center");
// You can find the original for the following example in the file explorer above,
// under openscad / examples / Basic / CSG-modules.scad
// CSG-modules.scad - Basic usage of modules, if, color, $fs/$fa
// Change this to false to remove the helper geometry
debug = true;
// Global resolution
$fs=$preview ? 1 : 0.1; // Don't generate smaller facets than 0.1 mm
$fa=$preview ? 15 : 5; // Don't generate larger angles than 5 degrees
rotate([-90, 0, 0]) {
// Main geometry
difference() {
intersection() {
body();
intersector();
}
holes();
}
// Helpers
if (debug) helpers();
}
// Core geometric primitives.
// These can be modified to create variations of the final object
module body() {
color("Blue") sphere(10);
}
module intersector() {
color("Red") cube(15, center=true);
}
module holeObject() {
color("Lime") cylinder(h=20, r=5, center=true);
}
// Various modules for visualizing intermediate components
module intersected() {
intersection() {
body();
intersector();
}
}
module holeA() rotate([0,90,0]) holeObject();
module holeB() rotate([90,0,0]) holeObject();
module holeC() holeObject();
module holes() {
union() {
holeA();
holeB();
holeC();
}
}
module helpers() {
// Inner module since it's only needed inside helpers
module line() color("Black") cylinder(r=1, h=10, center=true);
scale(0.5) {
translate([-30,0,-40]) {
intersected();
translate([-15,0,-35]) body();
translate([15,0,-35]) intersector();
translate([-7.5,0,-17.5]) rotate([0,30,0]) line();
translate([7.5,0,-17.5]) rotate([0,-30,0]) line();
}
translate([30,0,-40]) {
holes();
translate([-10,0,-35]) holeA();
translate([10,0,-35]) holeB();
translate([30,0,-35]) holeC();
translate([5,0,-17.5]) rotate([0,-20,0]) line();
translate([-5,0,-17.5]) rotate([0,30,0]) line();
translate([15,0,-17.5]) rotate([0,-45,0]) line();
}
translate([-20,0,-22.5]) rotate([0,45,0]) line();
translate([20,0,-22.5]) rotate([0,-45,0]) line();
}
}
echo(version=version());
// Written by Marius Kintel <marius@kintel.net>
//
// To the extent possible under law, the author(s) have dedicated all
// copyright and related and neighboring rights to this software to the
// public domain worldwide. This software is distributed without any
// warranty.
//
// You should have received a copy of the CC0 Public Domain
// Dedication along with this software.
// If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
`

34
src/state/formats.ts Normal file
View file

@ -0,0 +1,34 @@
export const VALID_RENDER_FORMATS = {
'off': true,
'svg': true,
};
export const VALID_EXPORT_FORMATS_2D = {
'svg': true,
'dxf': true,
};
export const VALID_EXPORT_FORMATS_3D = {
'stl': true,
'off': true,
'glb': true,
'3mf': true,
};
export function is2DFormatExtension(ext: string) {
return ext === 'svg' || ext === 'dxf';
}
const supportedImportExtensions = new Set([
'stl',
'off',
'dxf',
'nef3',
'3mf',
'amf',
'svg',
'obj',
]);
export function isSupportedImportExtension(ext: string) {
return supportedImportExtensions.has(ext);
}

View file

@ -1,8 +1,9 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { State } from "./app-state";
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from './formats';
import { validateArray, validateBoolean, validateString, validateStringEnum } from "../utils";
import { defaultModelColor } from "./initial-state";
import { defaultModelColor, defaultSourcePath } from "./initial-state";
export function buildUrlForStateParams(state: State) {//partialState: {params: State['params'], view: State['view']}) {
return `${location.protocol}//${location.host}${location.pathname}#${encodeStateParamsAsFragment(state)}`;
@ -11,33 +12,33 @@ export async function writeStateInFragment(state: State) {
window.location.hash = await encodeStateParamsAsFragment(state);
}
async function compressString(input: string): Promise<string> {
const stream = new ReadableStream<Uint8Array>({
return btoa(String.fromCharCode(...new Uint8Array(await new Response(new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(input));
controller.close();
}
});
// @ts-ignore
const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
const compressedData = await new Response(compressedStream).arrayBuffer();
return btoa(String.fromCharCode(...new Uint8Array(compressedData)));
}).pipeThrough(new CompressionStream('gzip'))).arrayBuffer())));
}
async function decompressString(compressedInput: string): Promise<string> {
const compressedData = Uint8Array.from(atob(compressedInput), c => c.charCodeAt(0));
const stream = new ReadableStream<Uint8Array>({
return new TextDecoder().decode(await new Response(new ReadableStream({
start(controller) {
controller.enqueue(compressedData);
controller.enqueue(Uint8Array.from(atob(compressedInput), c => c.charCodeAt(0)));
controller.close();
}
});
// @ts-ignore
const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip'));
const decompressedData = await new Response(decompressedStream).arrayBuffer();
return new TextDecoder().decode(decompressedData);
}).pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
}
// async function addFile(path: string, content: string) {
// const state = JSON.parse(await decompressString(window.location.hash.substring(1)));
// // console.log(JSON.stringify(state, null, 2)); // Put a breakpoint here if you wanna peek into the state
// state.params.sources.push({ path, content });
// window.history.pushState(state, '', '#' + await compressString(JSON.stringify(state)));
// window.location.reload();
// }
export function encodeStateParamsAsFragment(state: State) {
const json = JSON.stringify({
params: state.params,
@ -60,12 +61,17 @@ export async function readStateFromFragment(): Promise<State | null> {
const {params, view} = obj;
return {
params: {
sourcePath: validateString(params?.sourcePath),
source: validateString(params?.source),
activePath: validateString(params?.activePath, () => defaultSourcePath),
features: validateArray(params?.features, validateString),
vars: params?.vars, // TODO: validate!
// Source deserialization also handles legacy links (source + sourcePath)
sources: params?.sources ?? (params?.source ? [{path: params?.sourcePath, content: params?.source}] : undefined), // TODO: validate!
exportFormat2D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_2D), s => 'svg'),
exportFormat3D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'off'),
},
view: {
logs: validateBoolean(view?.logs),
extruderPicker: validateBoolean(view?.extruderPicker),
layout: {
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false),

View file

@ -6,22 +6,7 @@ import { State } from './app-state';
export const defaultSourcePath = '/playground.scad';
export const defaultModelColor = '#f9d72c';
export const blankProjectState: State = {
params: {
sourcePath: defaultSourcePath,
source: '',
features: [],
},
view: {
color: defaultModelColor,
layout: {
mode: 'single',
focus: 'editor'
}
}
};
export function createInitialState(fs: any, state: State | null) {
export function createInitialState(state: State | null, content: string = defaultScad): State {
type Mode = State['view']['layout']['mode'];
const mode: Mode = window.matchMedia("(min-width: 768px)").matches
@ -29,9 +14,11 @@ export function createInitialState(fs: any, state: State | null) {
const initialState: State = {
params: {
sourcePath: defaultSourcePath,
source: defaultScad,
activePath: defaultSourcePath,
sources: [{path: defaultSourcePath, content}],
features: [],
exportFormat2D: 'svg',
exportFormat3D: 'glb',
},
view: {
layout: {
@ -67,12 +54,12 @@ export function createInitialState(fs: any, state: State | null) {
initialState.view.showAxes ??= true
initialState.view.showShadows ??= true
fs.writeFile(initialState.params.sourcePath, initialState.params.source);
if (initialState.params.sourcePath !== defaultSourcePath) {
fs.writeFile(defaultSourcePath, defaultScad);
}
// fs.writeFile(initialState.params.sourcePath, initialState.params.source);
// if (initialState.params.sourcePath !== defaultSourcePath) {
// fs.writeFile(defaultSourcePath, defaultScad);
// }
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union'];
const defaultFeatures = ['lazy-union'];
defaultFeatures.forEach(f => {
if (initialState.params.features.indexOf(f) < 0)
initialState.params.features.push(f);
@ -81,3 +68,5 @@ export function createInitialState(fs: any, state: State | null) {
return initialState;
}
export const blankProjectState: State = createInitialState(null, '');

View file

@ -1,9 +1,15 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { checkSyntax, render, RenderArgs, RenderOutput } from "../runner/actions";
import { MultiLayoutComponentId, SingleLayoutComponentId, State, StatePersister } from "./app-state";
import { MultiLayoutComponentId, SingleLayoutComponentId, Source, State, StatePersister } from "./app-state";
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from './formats';
import { bubbleUpDeepMutations } from "./deep-mutate";
import { formatBytes, formatMillis } from '../utils'
import { downloadUrl, fetchSource, formatBytes, formatMillis, readFileAsDataURL } from '../utils'
import JSZip from 'jszip';
import { ProcessStreams } from "../runner/openscad-runner";
import { is2DFormatExtension } from "./formats";
import { convertOffToGlb, parseOff } from "../multimaterial/off2glb";
export class Model {
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
@ -12,7 +18,7 @@ export class Model {
init() {
if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering &&
this.state.params.source.trim() != '') {
this.source.trim() != '') {
this.processSource();
}
}
@ -35,6 +41,14 @@ export class Model {
return false;
}
setFormats(
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D | undefined,
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D | undefined) {
this.mutate(s => {
if (exportFormat2D != null) s.params.exportFormat2D = exportFormat2D;
if (exportFormat3D != null) s.params.exportFormat3D = exportFormat3D;
});
}
setVar(name: string, value: any) {
this.mutate(s => s.params.vars = {...s.params.vars ?? {}, [name]: value});
this.render({isPreview: true, now: false});
@ -103,46 +117,181 @@ export class Model {
openFile(path: string) {
// alert(`TODO: open ${path}`);
if (this.mutate(s => {
s.params.source = new TextDecoder("utf-8").decode(this.fs.readFileSync(path));
if (s.params.sourcePath != path) {
s.params.sourcePath = path;
if (s.params.activePath != path) {
s.params.activePath = path;
if (!s.params.sources.find(src => src.path === path)) {
let content = '';
try {
content = new TextDecoder("utf-8").decode(this.fs.readFileSync(path));
} catch (e) {
console.error('Error while reading file:', e);
}
s.params.sources = [...s.params.sources, {path, content}];
}
s.lastCheckerRun = undefined;
s.output = undefined;
s.export = undefined;
}
})) {
this.processSource();
}
}
get source(): string {
return this.state.params.sources.find(src => src.path === this.state.params.activePath)?.content ?? '';
}
set source(source: string) {
if (this.mutate(s => { s.params.source = source; })) {
if (this.mutate(s => s.params.sources = s.params.sources.map(src => src.path === s.params.activePath ? {path: src.path, content: source} : src))) {
this.processSource();
}
}
private processSource() {
const params = this.state.params;
// const params = this.state.params;
// if (isFileWritable(params.sourcePath)) {
// const absolutePath = params.sourcePath.startsWith('/') ? params.sourcePath : `/${params.sourcePath}`;
this.fs.writeFile(params.sourcePath, params.source);
// this.fs.writeFile(params.sourcePath, params.source);
// }
if (this.state.params.activePath.endsWith('.scad')) {
this.checkSyntax();
}
this.render({isPreview: true, now: false});
}
checkSyntax() {
async checkSyntax() {
this.mutate(s => s.checkingSyntax = true);
checkSyntax(this.state.params.source, this.state.params.sourcePath)({now: false, callback: (checkerRun, err) => this.mutate(s => {
if (err != null) {
console.error('Error while checking syntax:', err)
} else {
try {
const checkerRun = await checkSyntax({
activePath: this.state.params.activePath,
sources: this.state.params.sources,
})({now: false});
this.mutate(s => {
s.lastCheckerRun = checkerRun;
s.parameterSet = checkerRun?.parameterSet;
s.checkingSyntax = false;
});
} catch (err) {
console.error('Error while checking syntax:', err)
}
})});
}
render({isPreview, now}: {isPreview: boolean, now: boolean}) {
rawStreamsCallback(ps: ProcessStreams) {
this.mutate(s => {
if ('stdout' in ps) {
s.currentRunLogs?.push(['stdout', ps.stdout]);
} else {
s.currentRunLogs?.push(['stderr', ps.stderr]);
}
});
}
async export() {
if (this.state.output) {
const normalPassThrough =
(this.state.is2D && this.state.params.exportFormat2D === 'svg')
|| (!this.state.is2D && this.state.params.exportFormat3D === 'off');
const glbPassThrough =
(!this.state.is2D && this.state.params.exportFormat3D === 'glb')
&& (this.state.output.displayFile?.name.endsWith('.glb') ?? false)
&& (this.state.output.displayFileURL != null);
if (normalPassThrough || glbPassThrough) {
this.mutate(s => s.export = s.output);
if (glbPassThrough) {
downloadUrl(this.state.output.displayFileURL!, this.state.output.displayFile!.name);
} else {
downloadUrl(this.state.output.outFileURL, this.state.output.outFile.name);
}
return;
}
}
this.mutate(s => {
s.currentRunLogs ??= [];
s.exporting = true;
});
const outFile = this.state.output?.outFile;
const outFileURL = this.state.output?.outFileURL;
if (!outFile || !outFileURL) {
throw new Error('No output file to export');
}
const scadPath = '/export.scad'
const sources: Source[] = [
{
path: scadPath,
content: `import("${outFile.name}");`
},
{
path: outFile.name,
url: outFileURL,
}
];
let {features, exportFormat2D, exportFormat3D} = this.state.params;
const renderArgs: RenderArgs = {
mountArchives: false,
scadPath,
sources,
extraArgs: [], isPreview: false,
features,
renderFormat: this.state.is2D ? exportFormat2D : exportFormat3D,
streamsCallback: ps => console.log('Export', JSON.stringify(ps)),
};
try {
const output = await render(renderArgs)({now: true});
const outFileURL = URL.createObjectURL(output.outFile);
this.mutate(s => {
s.exporting = false;
if (s.export?.outFileURL?.startsWith('blob:') ?? false) {
URL.revokeObjectURL(s.export!.outFileURL);
}
s.export = {
outFile: output.outFile,
outFileURL,
elapsedMillis: output.elapsedMillis,
formattedElapsedMillis: formatMillis(output.elapsedMillis),
formattedOutFileSize: formatBytes(output.outFile.size),
};
downloadUrl(s.export.outFileURL, output.outFile.name);
});
} catch (err) {
this.mutate(s => {
s.exporting = false;
console.error('Error while exporting:', err)
s.error = `${err}`;
});
}
}
async saveProject() {
if (this.state.params.sources.length == 1) {
const content = this.state.params.sources[0].content;
const contentBytes = new TextEncoder().encode(content);
const blob = new Blob([contentBytes], {type: 'text/plain'});
const file = new File([blob], this.state.params.activePath.split('/').pop()!);
downloadUrl(URL.createObjectURL(file), file.name);
} else {
const zip = new JSZip();
for (const source of this.state.params.sources) {
let path = source.path
if (path.startsWith('/')) {
path = path.substring(1);
}
zip.file(path, await fetchSource(source));
}
zip.generateAsync({type: 'blob'}).then(blob => {
const file = new File([blob], 'project.zip');
downloadUrl(URL.createObjectURL(file), file.name);
});
}
}
async render({isPreview, mountArchives, now, retryInOtherDim}: {isPreview: boolean, mountArchives?: boolean, now: boolean, retryInOtherDim?: boolean}) {
mountArchives ??= true;
retryInOtherDim ??= true;
const setRendering = (s: State, value: boolean) => {
if (isPreview) {
s.previewing = value;
@ -150,41 +299,142 @@ export class Model {
s.rendering = value;
}
}
this.mutate(s => setRendering(s, true));
this.mutate(s => {
s.currentRunLogs = [];
setRendering(s, true);
});
const {source, sourcePath, vars, features} = this.state.params;
let {
activePath,
sources,
vars,
features,
} = this.state.params;
render({source, sourcePath, vars, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
let is2D = this.state.is2D;
const extension = activePath.split('.').pop() ?? '';
if (!activePath.endsWith('.scad')) {
const resourcePath = activePath;
const loaderPath = '/load-resource.scad';
is2D = is2DFormatExtension(extension);
mountArchives = false;
activePath = loaderPath;
sources = [
{
path: activePath,
content: `${is2D ? 'linear_extrude(1) ' : ''} import("${resourcePath}");`,
},
...sources.filter(s => s.path === resourcePath),
];
}
const renderArgs: RenderArgs = {
mountArchives,
scadPath: activePath,
sources,
vars,
features,
isPreview,
renderFormat: this.state.is2D ? 'svg' : 'off',
streamsCallback: this.rawStreamsCallback.bind(this)
};
try {
let output = await render(renderArgs)({now});
let displayFile = output.outFile;
if (output.outFile.name.endsWith('.svg') || output.outFile.name.endsWith('.dxf')) {
is2D = true;
const fn = output.outFile.name;
const extrudedOutput = await render({
mountArchives: false,
scadPath: '/extruded.scad',
sources: [
{
path: '/extruded.scad',
content: `linear_extrude(0.1) import("${fn}");`,
},
{
path: `/${fn}`,
url: await readFileAsDataURL(output.outFile),
},
],
vars: {},
features,
isPreview: false,
renderFormat: 'off',
streamsCallback: this.rawStreamsCallback.bind(this)
})({now});
displayFile = extrudedOutput.outFile;
} else {
is2D = false;
}
if (displayFile.name.endsWith('.off')) {
const offData = parseOff(await displayFile.text());
displayFile = new File([await convertOffToGlb(offData)], displayFile.name.replace('.off', '.glb'));
}
const outFileURL = URL.createObjectURL(output.outFile);
const displayFileURL = displayFile && await readFileAsDataURL(displayFile);
this.mutate(s => {
setRendering(s, false);
if (err != null) {
console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err)
s.error = `${err}`;
} else if (output) {
s.error = undefined;
s.is2D = is2D;
s.lastCheckerRun = {
logText: output.logText,
markers: output.markers,
}
if (s.output?.stlFileURL) {
URL.revokeObjectURL(s.output.stlFileURL);
if (s.output?.outFileURL?.startsWith('blob:') ?? false) {
URL.revokeObjectURL(s.output!.outFileURL);
}
if (s.output?.displayFileURL?.startsWith('blob:') ?? false) {
URL.revokeObjectURL(s.output!.displayFileURL!);
}
s.output = {
isPreview: isPreview,
stlFile: output.stlFile,
stlFileURL: URL.createObjectURL(output.stlFile),
outFile: output.outFile,
outFileURL,
displayFile,
displayFileURL,
elapsedMillis: output.elapsedMillis,
formattedElapsedMillis: formatMillis(output.elapsedMillis),
formattedStlFileSize: formatBytes(output.stlFile.size),
formattedOutFileSize: formatBytes(output.outFile.size),
};
if (!isPreview) {
const audio = document.getElementById('complete-sound') as HTMLAudioElement;
audio?.play();
}
}
});
}})
} catch (err) {
this.mutate(s => {
setRendering(s, false);
console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err)
s.error = `${err}`;
});
}
if (retryInOtherDim) {
let is2D: boolean | undefined;
let is3D: boolean | undefined;
let isMixed: boolean | undefined;
for (const [pipe, line] of this.state.currentRunLogs ?? []) {
if (line == 'Current top level object is not a 3D object.') {
is3D = false;
} else if (line == 'Top level object is a 3D object:') {
is3D = true;
} else if (line == 'Current top level object is not a 2D object.') {
is2D = false;
} else if (line == 'Top level object is a 2D object:') {
is2D = true;
} else if (line.includes('WARNING: Mixing 2D and 3D objects is not supported')) {
isMixed = true;
}
}
if (is2D === false || is3D === false) {//} || isMixed !== undefined) {
this.mutate(s => s.is2D = !(is2D === false));
this.render({isPreview, now: true, retryInOtherDim: false});
return;
}
}
}
}

View file

@ -1,5 +1,7 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { Source } from "./state/app-state";
export function mapObject(o: any, f: (key: string, value: any) => any, ifPred: (key: string) => boolean) {
const ret = [];
for (const key of Object.keys(o)) {
@ -28,19 +30,55 @@ export function turnIntoDelayableExecution<T extends any[], R>(
job: (...args: T) => AbortablePromise<R>) {
let pendingId: number | null;
let runningJobKillSignal: (() => void) | null;
// return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
// (async () => {
// try {
// const result = await job;
// // console.log(result);
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result?: R, error?: any) => void}) => {
// let parameterSet: ParameterSet | undefined = undefined;
// if (result.outputs && result.outputs.length == 1) {
// let [[, content]] = result.outputs;
// content = new TextDecoder().decode(content as any);
// try {
// parameterSet = JSON.parse(content)
// // console.log('PARAMETER SET', JSON.stringify(parameterSet, null, 2))
// } catch (e) {
// console.error(`Error while parsing parameter set: ${e}\n${content}`);
// }
// } else {
// console.error('No output from runner!');
// }
// res({
// ...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
// sourcePath: sources[0].path,
// skipLines: 1,
// }}),
// parameterSet,
// });
// } catch (e) {
// console.error(e);
// rej(e);
// }
// })()
// return () => job.kill();
// });
//return (...args: T) => async ({now, callback}: {now: boolean, callback: (result?: R, error?: any) => void}) => {
return (...args: T) => ({now}: {now: boolean}) => AbortablePromise<R>((resolve, reject) => {
let abortablePromise: AbortablePromise<R> | undefined = undefined;
(async () => {
const doExecute = async () => {
if (runningJobKillSignal) {
runningJobKillSignal();
runningJobKillSignal = null;
}
const abortablePromise = job(...args);
abortablePromise = job(...args);
runningJobKillSignal = abortablePromise.kill;
try {
callback(await abortablePromise);
resolve(await abortablePromise);
} catch (e) {
callback(undefined, e);
reject(e);
} finally {
runningJobKillSignal = null;
}
@ -54,7 +92,9 @@ export function turnIntoDelayableExecution<T extends any[], R>(
} else {
pendingId = window.setTimeout(doExecute, delay);
}
};
})();
return () => abortablePromise?.kill();
});
}
export function validateStringEnum<T extends string>(
@ -67,7 +107,7 @@ export const validateString = (s: string, orElse: () => string = () => '') => s
export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
if (!(a instanceof Array)) return orElse();
return a.map(validateElement);
}
};
export function formatBytes(n: number) {
if (n < 1024) {
@ -102,4 +142,43 @@ export const isInStandaloneMode =
// true
Boolean(('standalone' in window.navigator) && (window.navigator.standalone));
export function downloadUrl(url: string, filename: string) {
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename)
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
}
export async function fetchSource({content, path, url}: Source) {
if (content) {
return content;
} else if (url) {
if (path.endsWith('.scad') || path.endsWith('.json')) {
content = await (await fetch(url)).text();
return content;
} else {
// Fetch bytes
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const data = new Uint8Array(buffer);
return data;
}
} else {
throw new Error('Invalid source: ' + JSON.stringify({path, content, url}));
}
}
export function readFileAsDataURL(file: File) {
// TO data URI:
return new Promise<string>((res, rej) => {
const reader = new FileReader();
reader.onloadend = () => {
res(reader.result as string);
}
reader.onerror = rej;
reader.readAsDataURL(file);
});
// return URL.createObjectURL(file);
}

21
tests/e2e.test.js Normal file
View file

@ -0,0 +1,21 @@
const longTimeout = 60000;
const isProd = process.env.NODE_ENV === 'production';
const url = isProd ? 'http://localhost:3000/dist/' : 'http://localhost:4000';
describe('e2e', () => {
test('should load the page', async () => {
const messages = [];
page.on('console', (msg) => messages.push({type: msg.type(), text: msg.text()}));
page.goto(url);
await page.waitForSelector('model-viewer');
console.log('Messages:', JSON.stringify(messages, null, 2));
const errors = messages.filter(msg => msg.type === 'error');
expect(errors).toHaveLength(0);
const successMessage = messages.filter(msg => msg.type === 'debug' && msg.text === 'stderr: Top level object is a list of objects:');
expect(successMessage).toHaveLength(1);
}, longTimeout);
});

View file

@ -1,10 +1,10 @@
{
"compilerOptions": {
"target": "ES2021",
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"ES2021",
"ES2022",
"WebWorker",
],
"rootDir": "src",
@ -28,5 +28,9 @@
"src/**/*.tsx",
"src/**/*.js",
// "public"
],
"exclude": [
"node_modules",
"src/wasm/*.js"
]
}

View file

@ -1,5 +1,6 @@
const CopyPlugin = require("copy-webpack-plugin");
const WorkboxPlugin = require('workbox-webpack-plugin');
const webpack = require('webpack');
const path = require('path');
@ -42,6 +43,10 @@ module.exports = [{
port: 4000,
},
plugins: [
new webpack.EnvironmentPlugin({
'process.env.NODE_ENV': 'development',
}),
...(process.env.NODE_ENV === 'production' ? [
new WorkboxPlugin.GenerateSW({
// these options encourage the ServiceWorkers to get in there fast
// and not allow any straggling "old" SWs to hang around
@ -61,6 +66,7 @@ module.exports = [{
},
}],
}),
] : []),
new CopyPlugin({
patterns: [
{