🌈 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: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false
matrix: matrix:
node: [ 18, 20, 22 ] node: [
name: Node${{ matrix.node }} {"name": "LTS", "version": "lts/-2"},
{"name": "latest", "version": "latest"}
]
name: Node ${{ matrix.node.name }}
env:
CI: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup node ${{ matrix.node }} - name: Setup node ${{ matrix.node.name }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node.version }}
- run: npm install - run: npm install
- run: make public - run: make public
- run: npm run build - run: npm run build
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: dist-node${{ matrix.node }} name: dist-node-${{ matrix.node.name }}
path: dist path: dist
retention-days: 30 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) * [GNU MP](#gnu-mp)
* [OpenSCAD](#openscad) * [OpenSCAD](#openscad)
* [CGAL](#cgal) * [CGAL](#cgal)
* [JSZip](#jszip)
* [Manifold](#manifold) * [Manifold](#manifold)
* [Eigen](#eigen) * [Eigen](#eigen)
* [glib2](#glib2) * [glib2](#glib2)
@ -59,6 +60,12 @@ https://github.com/elalish/manifold
Licensed under the [Apache 2.0](#apache2) license. 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 ## PrimeReact
https://github.com/primefaces/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) # Pin WASM build to a version known to work
WASM_BUILD_URL=https://files.openscad.org/snapshots/OpenSCAD-2024.09.27.wasm20596-WebAssembly.zip 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_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/" ) # 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 dist/index.js: public
npm run build 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 npx rollup -c
src/wasm: libs/openscad-wasm src/wasm: libs/openscad-wasm
rm -f src/wasm rm -f src/wasm
ln -sf "$(shell pwd)/libs/openscad-wasm" 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: libs/openscad-wasm:
mkdir -p libs/openscad-wasm mkdir -p libs/openscad-wasm
wget ${WASM_BUILD_URL} -O libs/openscad-wasm.zip wget ${WASM_BUILD_URL} -O libs/openscad-wasm.zip
@ -116,7 +124,8 @@ NOTO_FONTS=\
# libs/noto/NotoSansCJKtc-Regular.otf # libs/noto/NotoSansCJKtc-Regular.otf
public/libraries/fonts.zip: $(NOTO_FONTS) libs/liberation 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: libs/noto/%.ttf:
mkdir -p libs/noto mkdir -p libs/noto
@ -130,7 +139,7 @@ libs/liberation:
git clone --recurse https://github.com/shantigilbert/liberation-fonts-ttf.git ${SHALLOW} ${SINGLE_BRANCH} $@ git clone --recurse https://github.com/shantigilbert/liberation-fonts-ttf.git ${SHALLOW} ${SINGLE_BRANCH} $@
libs/openscad: 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 public/libraries/openscad.zip: libs/openscad
mkdir -p public/libraries mkdir -p public/libraries
@ -176,7 +185,7 @@ libs/YAPP_Box:
public/libraries/YAPP_Box.zip: libs/YAPP_Box public/libraries/YAPP_Box.zip: libs/YAPP_Box
mkdir -p public/libraries 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: libs/MCAD:
git clone --recurse https://github.com/openscad/MCAD.git ${SHALLOW} ${SINGLE_BRANCH} $@ git clone --recurse https://github.com/openscad/MCAD.git ${SHALLOW} ${SINGLE_BRANCH} $@

View file

@ -47,11 +47,17 @@ Prerequisites:
* wget * wget
* GNU make * GNU make
* npm * 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: Local dev:
```bash ```bash
make public make public
npm install
npm start npm start
# http://localhost:4000/ # http://localhost:4000/
``` ```
@ -60,6 +66,7 @@ Local prod (test both the different inlining and serving under a prefix):
```bash ```bash
make public make public
npm install
npm run start:prod npm run start:prod
# http://localhost:3000/dist/ # http://localhost:3000/dist/
``` ```
@ -68,7 +75,8 @@ Deployment (edit "homepage" in `package.json` to match your deployment root!):
```bash ```bash
make public 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 rm -fR ../ochafik.github.io/openscad2 && cp -R dist ../ochafik.github.io/openscad2
# Now commit and push changes, wait for site update and enjoy! # 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, "private": true,
"homepage": "https://ochafik.com/openscad2/", "homepage": "https://ochafik.com/openscad2/",
"dependencies": { "dependencies": {
"@gltf-transform/core": "^4.1.1",
"@gltf-transform/extensions": "^4.1.1",
"@monaco-editor/loader": "^1.3.2", "@monaco-editor/loader": "^1.3.2",
"@monaco-editor/react": "^4.4.6", "@monaco-editor/react": "^4.4.6",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
@ -14,18 +16,20 @@
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"debug": "^4.3.4", "debug": "^4.3.4",
"jszip": "^3.10.1",
"monaco-editor": "^0.36.1", "monaco-editor": "^0.36.1",
"primeflex": "^3.3.1", "primeflex": "^3.3.1",
"primeicons": "^6.0.1", "primeicons": "^6.0.1",
"primereact": "9.3.x", "primereact": "9.3.x",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-stl-viewer": "^2.2.5", "react-stl-viewer": "^2.2.5"
"react-widgets": "^5.8.4"
}, },
"scripts": { "scripts": {
"start": "concurrently 'npx webpack serve --mode=development' 'NODE_ENV=development npx rollup --config openscad-worker.rollup.config.js --watch'", "test:e2e": "jest",
"start:prod": "PUBLIC_URL=http://localhost:3000/dist/ npm run build && npx serve", "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" "build": "NODE_ENV=production npx rollup --config openscad-worker.rollup.config.js && webpack --mode=production"
}, },
"eslintConfig": { "eslintConfig": {
@ -57,7 +61,10 @@
"concurrently": "^7.6.0", "concurrently": "^7.6.0",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1", "css-loader": "^6.8.1",
"jest": "^29.7.0",
"jest-puppeteer": "^11.0.0",
"livereload": "^0.9.3", "livereload": "^0.9.3",
"puppeteer": "^23.11.1",
"rollup": "^2.79.1", "rollup": "^2.79.1",
"rollup-plugin-babel": "^4.4.0", "rollup-plugin-babel": "^4.4.0",
"rollup-plugin-css": "^1.0.0", "rollup-plugin-css": "^1.0.0",
@ -68,7 +75,7 @@
"serve": "^14.2.0", "serve": "^14.2.0",
"style-loader": "^3.3.3", "style-loader": "^3.3.3",
"ts-loader": "^9.4.2", "ts-loader": "^9.4.2",
"tslib": "^2.5.0", "tslib": "^2.6.3",
"webpack": "^5.76.3", "webpack": "^5.76.3",
"webpack-cli": "^5.1.4", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.2", "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 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}) { export function App({initialState, statePersister, fs}: {initialState: State, statePersister: StatePersister, fs: FS}) {
const [state, setState] = useState(initialState); 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. // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { CSSProperties, useContext } from 'react'; import { CSSProperties, useContext } from 'react';
import { ModelContext, FSContext } from './contexts'; import { ModelContext } from './contexts';
import { Dropdown } from 'primereact/dropdown'; import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider'; import { Slider } from 'primereact/slider';
@ -48,6 +48,7 @@ export default function CustomizerPanel({className, style}: {className?: string,
style={{ style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
maxHeight: '80vh',
overflow: 'scroll', overflow: 'scroll',
...style, ...style,
bottom: 'unset', 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}) { function ParameterInput({param, value, className, style, handleChange}: {param: Parameter, value: any, className?: string, style?: CSSProperties, handleChange: (key: string, value: any) => void}) {
return ( return (
<div <div
// ref={ref}
// onClick={state.view.layout.mode === 'single' ? handleClick : undefined}
style={{ style={{
flex: 1, flex: 1,
...style, ...style,
@ -99,7 +98,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
justifyContent: 'space-between', justifyContent: 'space-between',
}}> }}>
<div <div
// onClick={swallowClick}
style={{ style={{
flex: 1, flex: 1,
display: 'flex', display: 'flex',
@ -109,10 +107,7 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
<div>{param.caption}</div> <div>{param.caption}</div>
</div> </div>
<div <div
// onClick={swallowClick}
style={{ style={{
// flex: 1,
// margin: '10px',
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -130,7 +125,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
)} )}
{param.type === 'string' && param.options && ( {param.type === 'string' && param.options && (
<Dropdown <Dropdown
// style={{flex: 1}}
value={value || param.initial} value={value || param.initial}
options={param.options} options={param.options}
onChange={(e) => handleChange(param.name, e.value)} onChange={(e) => handleChange(param.name, e.value)}
@ -140,18 +134,15 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
)} )}
{param.type === 'boolean' && ( {param.type === 'boolean' && (
<Checkbox <Checkbox
// style={{flex: 1}}
checked={value ?? param.initial} checked={value ?? param.initial}
onChange={(e) => handleChange(param.name, e.checked)} onChange={(e) => handleChange(param.name, e.checked)}
/> />
)} )}
{!Array.isArray(param.initial) && param.type === 'number' && !('options' in param) && ( {!Array.isArray(param.initial) && param.type === 'number' && !('options' in param) && (
<InputNumber <InputNumber
// style={{flex: 1}}
value={value || param.initial} value={value || param.initial}
showButtons showButtons
size={5} size={5}
// buttonLayout='horizontal'
onValueChange={(e) => handleChange(param.name, e.value)} onValueChange={(e) => handleChange(param.name, e.value)}
/> />
)} )}
@ -176,7 +167,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
min={param.min} min={param.min}
max={param.max} max={param.max}
showButtons showButtons
// buttonLayout='horizontal'
size={5} size={5}
step={param.step} step={param.step}
onValueChange={(e) => { onValueChange={(e) => {
@ -194,7 +184,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
marginRight: '0', marginRight: '0',
visibility: value === undefined || (JSON.stringify(value) === JSON.stringify(param.initial)) ? 'hidden' : 'visible', visibility: value === undefined || (JSON.stringify(value) === JSON.stringify(param.initial)) ? 'hidden' : 'visible',
}} }}
// tooltip={`Reset to default value (${JSON.stringify(param.initial)})`}
tooltipOptions={{position: 'left'}} tooltipOptions={{position: 'left'}}
icon='pi pi-refresh' icon='pi pi-refresh'
className='p-button-text'/> className='p-button-text'/>

View file

@ -57,6 +57,18 @@ export default function EditorPanel({className, style}: {className?: string, sty
label: "Preview OpenSCAD", label: "Preview OpenSCAD",
run: () => model.render({isPreview: true, now: true}) 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) setEditor(editor)
} }
@ -114,55 +126,22 @@ export default function EditorPanel({className, style}: {className?: string, sty
{ {
separator: true 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 separator: true
}, },
// { {
// label: 'Copy', label: 'Select All',
// icon: 'pi pi-copy', icon: 'pi pi-info-circle',
// // disabled: true, command: () => editor?.trigger(state.params.activePath, 'editor.action.selectAll', null),
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCopyAction', null), },
// }, {
// { separator: true
// label: 'Cut', },
// icon: 'pi pi-eraser', {
// // disabled: true, label: 'Find',
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCutAction', null), icon: 'pi pi-search',
// }, command: () => editor?.trigger(state.params.activePath, 'actions.find', 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),
},
{
separator: true
},
{
label: 'Find',
icon: 'pi pi-search',
// disabled: true,
command: () => editor?.trigger(state.params.sourcePath, 'actions.find', null),
},
] as MenuItem[]} popup ref={menu} /> ] as MenuItem[]} popup ref={menu} />
<Button title="Editor menu" rounded text icon="pi pi-ellipsis-h" onClick={(e) => menu.current && menu.current.toggle(e)} /> <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, flex: 1,
}}/> }}/>
{state.params.sourcePath !== defaultSourcePath && {state.params.activePath !== defaultSourcePath &&
<Button icon="pi pi-chevron-left" <Button icon="pi pi-chevron-left"
text text
onClick={() => model.openFile(defaultSourcePath)} onClick={() => model.openFile(defaultSourcePath)}
@ -188,24 +167,21 @@ export default function EditorPanel({className, style}: {className?: string, sty
<Editor <Editor
className="openscad-editor absolute-fill" className="openscad-editor absolute-fill"
defaultLanguage="openscad" defaultLanguage="openscad"
path={state.params.sourcePath} path={state.params.activePath}
value={state.params.source} value={model.source}
onChange={s => model.source = s ?? ''} onChange={s => model.source = s ?? ''}
onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render?? onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render??
options={{ options={{
...openscadEditorOptions, ...openscadEditorOptions,
fontSize: 16, fontSize: 16,
lineNumbers: state.view.lineNumbers ? 'on' : 'off', lineNumbers: state.view.lineNumbers ? 'on' : 'off',
// readOnly: !isFileWritable(state.params.sourcePath)
}} }}
/> />
)} )}
{!isMonacoSupported && ( {!isMonacoSupported && (
<InputTextarea <InputTextarea
style={{
}}
className="openscad-editor absolute-fill" className="openscad-editor absolute-fill"
value={state.params.source} value={model.source}
onChange={s => model.source = s.target.value ?? ''} onChange={s => model.source = s.target.value ?? ''}
/> />
)} )}
@ -216,8 +192,9 @@ export default function EditorPanel({className, style}: {className?: string, sty
overflowY: 'scroll', overflowY: 'scroll',
height: 'calc(min(200px, 30vh))', height: 'calc(min(200px, 30vh))',
}}> }}>
<pre><code id="logs" style={{ {(state.currentRunLogs ?? []).map(([type, text], i) => (
}}>{state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre> <pre key={i}>{text}</pre>
))}
</div> </div>
</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 { TreeSelect } from 'primereact/treeselect';
import TreeNode from 'primereact/treenode'; import TreeNode from 'primereact/treenode';
import { ModelContext, FSContext } from './contexts'; import { ModelContext, FSContext } from './contexts';
// import { isFileWritable } from '../state/model'; import { getParentDir, join } from '../fs/filesystem';
import { join } from '../fs/filesystem';
import { defaultSourcePath } from '../state/initial-state'; import { defaultSourcePath } from '../state/initial-state';
import { zipArchives } from '../fs/zip-archives'; import { zipArchives } from '../fs/zip-archives';
@ -69,7 +68,6 @@ function listFilesAsNodes(fs: FS, path: string, accept?: (path: string) => boole
} }
nodes.push({ 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', icon: isDirectory ? 'pi pi-folder' : path === defaultSourcePath ? 'pi pi-home' : 'pi pi-file',
label, label,
data: path, data: path,
@ -89,13 +87,28 @@ export default function FilePicker({className, style}: {className?: string, styl
const fs = useContext(FSContext); 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 ( return (
<TreeSelect <TreeSelect
className={className} className={className}
title='OpenSCAD Playground Files' title='OpenSCAD Playground Files'
value={state.params.sourcePath} value={state.params.activePath}
resetFilterOnHide={true} resetFilterOnHide={true}
filterBy="key" filterBy="key"
onChange={e => { onChange={e => {

View file

@ -1,7 +1,6 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { CSSProperties, useContext, useRef } from 'react'; import { CSSProperties, useContext, useRef } from 'react';
import { State } from '../state/app-state'
import { ModelContext } from './contexts'; import { ModelContext } from './contexts';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
@ -9,39 +8,8 @@ import { ProgressBar } from 'primereact/progressbar';
import { Badge } from 'primereact/badge'; import { Badge } from 'primereact/badge';
import { Menu } from 'primereact/menu'; import { Menu } from 'primereact/menu';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import SettingsMenu from './SettingsMenu';
import HelpMenu from './HelpMenu'; import HelpMenu from './HelpMenu';
import { confirmDialog } from 'primereact/confirmdialog'; import ExportButton from './ExportButton';
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();
}
}
export default function Footer({style}: {style?: CSSProperties}) { 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'); if (!model) throw new Error('No model');
const state = model.state; const state = model.state;
const helpMenu = useRef<Menu>(null);
const toast = useRef<Toast>(null); const toast = useRef<Toast>(null);
@ -76,35 +43,30 @@ export default function Footer({style}: {style?: CSSProperties}) {
style={{ style={{
marginLeft: '5px', marginLeft: '5px',
marginRight: '5px', marginRight: '5px',
visibility: state.rendering || state.previewing || state.checkingSyntax visibility: state.rendering || state.previewing || state.checkingSyntax || state.exporting
? 'visible' : 'hidden', ? 'visible' : 'hidden',
height: '6px' }}></ProgressBar> height: '6px' }}></ProgressBar>
<div className="flex flex-row gap-1" style={{ <div className="flex flex-row gap-1" style={{
// display: 'flex',
// flexDirection: 'row',
// gap: '10px',
// verticalAlign: 'center',
alignItems: 'center', alignItems: 'center',
margin: '5px', margin: '5px',
...(style ?? {}) ...(style ?? {})
}}> }}>
<Button onClick={() => model.render({isPreview: false, now: true})} <Button
loading={state.rendering} icon="pi pi-bolt"
icon="pi pi-refresh" onClick={() => model.render({isPreview: false, now: true})}
title="Render the model (F6 / Ctrl+Enter). Models can test $preview to enable more detail in renders only." className="p-button-sm"
label="Render" /> label="Render"
/>
<ExportButton />
{(state.lastCheckerRun || state.output) && {(state.lastCheckerRun || state.output) &&
<Button type="button" <Button type="button"
// label={state.view.logs ? "Hide logs" : "Show logs"} severity={maxMarkerSeverity && severityByMarkerSeverity.get(maxMarkerSeverity)}
label="Logs"
icon="pi pi-align-left" icon="pi pi-align-left"
text={!state.view.logs} text={!state.view.logs}
onClick={() => model.logsVisible = !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'}`} className={maxMarkerSeverity && `p-button-${severityByMarkerSeverity.get(maxMarkerSeverity) ?? 'success'}`}
> >
{getBadge(monaco.MarkerSeverity.Error)} {getBadge(monaco.MarkerSeverity.Error)}
@ -112,33 +74,15 @@ export default function Footer({style}: {style?: CSSProperties}) {
{getBadge(monaco.MarkerSeverity.Info)} {getBadge(monaco.MarkerSeverity.Info)}
</Button>} </Button>}
{state.output && ( <div style={{flex: 1}}></div>
<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)} />
)}
{/* {state.output && <HelpMenu style={{
<span style={{color: 'blue'}}> position: 'absolute',
<i className="pi pi-stopwatch" title={state.error}></i> right: 0,
<span style={{margin: '5px'}}>{state.output.formattedElapsedMillis}</span> top: '4px',
</span>} */} }} />
{/* {state.previewing && 'previewing... '}
{state.rendering && 'rendering... '}
{state.checkingSyntax && 'checking syntax... '} */}
{/* <span style={{flex: 1}}></span> */}
<span style={{flex: 1}}></span>
<Toast ref={toast} /> <Toast ref={toast} />
<HelpMenu />
</div> </div>
</> </>
} }

View file

@ -1,25 +1,25 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. // 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 { Button } from 'primereact/button';
import { MenuItem } from 'primereact/menuitem'; import { MenuItem } from 'primereact/menuitem';
import { Menu } from 'primereact/menu'; import { Menu } from 'primereact/menu';
export default function HelpMenu() { export default function HelpMenu({className, style}: {className?: string, style?: CSSProperties}) {
const menuRef = useRef<Menu>(null); const menuRef = useRef<Menu>(null);
return ( return (
<> <>
<Menu model={[ <Menu style={style} className={className} model={[
{ {
label: "openscad-playground", label: "openscad-playground",
icon: 'pi pi-github', icon: 'pi pi-github',
url: 'https://github.com/openscad/openscad-playground', url: 'https://github.com/ochafik/openscad-playground/tree/color-assimp2',
target: '_blank' target: '_blank'
}, },
{ {
label: 'LICENSES', label: 'LICENSES',
icon: 'pi pi-info-circle', 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' target: '_blank'
}, },
{ {
@ -48,7 +48,12 @@ export default function HelpMenu() {
target: '_blank' target: '_blank'
}, },
] as MenuItem[]} popup ref={menuRef} /> ] 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 { TabMenu } from 'primereact/tabmenu';
import { ToggleButton } from 'primereact/togglebutton'; import { ToggleButton } from 'primereact/togglebutton';
import { ModelContext, FSContext } from './contexts'; import { ModelContext, FSContext } from './contexts';
import SettingsMenu from './SettingsMenu';
export default function PanelSwitcher() { export default function PanelSwitcher() {
const model = useContext(ModelContext); const model = useContext(ModelContext);
@ -36,18 +35,13 @@ export default function PanelSwitcher() {
margin: '5px' margin: '5px'
}}> }}>
{multiTargets.map(({icon, label, id}) => {multiTargets.map(({icon, label, id}) =>
// <Button
<ToggleButton <ToggleButton
key={id} key={id}
// raised={(state.view.layout as any)[id]}
checked={(state.view.layout as any)[id]} checked={(state.view.layout as any)[id]}
// label={label}
onLabel={label} onLabel={label}
offLabel={label} offLabel={label}
onIcon={icon} onIcon={icon}
offIcon={icon} offIcon={icon}
// icon={icon}
// onClick={() => model.changeMultiVisibility(id, !(state.view.layout as any)[id])}
onChange={e => model.changeMultiVisibility(id, e.value)} 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)} activeIndex={singleTargets.map(t => t.id).indexOf(state.view.layout.focus)}
style={{ style={{
flex: 1, flex: 1,
// justifyContent: 'center'
}} }}
model={singleTargets.map(({icon, label, id}) => 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>
</div> </div>
); );

View file

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

View file

@ -1,7 +1,5 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
// import { FS } from "./filesystem";
declare interface FS { declare interface FS {
writeFile(path: string, content: string): void; writeFile(path: string, content: string): void;
readdir(path: string, cb: (err: any, files: string[]) => void): 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') { export async function symlinkLibraries(archiveNames: string[], fs: FS, prefix='/libraries', cwd='/tmp') {
const createSymlink = async (target: string, source: string) => { const createSymlink = async (target: string, source: string) => {
// console.log('symlink', target, source);
try { try {
await fs.symlink(target, source); await fs.symlink(target, source);
} catch (e) { } catch (e) {
console.error(`symlink(${target}, ${source}) failed: `, e); console.error(`symlink(${target}, ${source}) failed: `, e);
} }
// await symlink(target, source);
}; };
await Promise.all(archiveNames.map(n => (async () => { await Promise.all(archiveNames.map(n => (async () => {
@ -111,8 +109,6 @@ export async function createEditorFS({prefix, allowPersistence}: {prefix: string
}); });
var fs = BrowserFS.BFSRequire('fs'); 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; return fs;
} }

View file

@ -32,29 +32,30 @@ if (process.env.NODE_ENV !== 'production') {
declare var BrowserFS: BrowserFSInterface declare var BrowserFS: BrowserFSInterface
window.addEventListener('load', async () => { window.addEventListener('load', async () => {
//* //*
if ('serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production') {
try { if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('./sw.js'); try {
console.log('ServiceWorker registration successful with scope: ', registration.scope); const registration = await navigator.serviceWorker.register('./sw.js');
console.log('ServiceWorker registration successful with scope: ', registration.scope);
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing; const installingWorker = registration.installing;
if (installingWorker) { if (installingWorker) {
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) { if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) {
// Reload to activate the service worker and apply caching // Reload to activate the service worker and apply caching
window.location.reload(); window.location.reload();
return; return;
} }
}; };
} }
}; };
} catch (err) { } catch (err) {
console.log('ServiceWorker registration failed: ', err); console.log('ServiceWorker registration failed: ', err);
} }
}
} }
//*/ //*/
@ -88,7 +89,7 @@ window.addEventListener('load', async () => {
}; };
} }
const initialState = createInitialState(fs, persistedState); const initialState = createInitialState(persistedState);
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement

View file

@ -4,7 +4,6 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default { export default {
lineNumbers: 'on', lineNumbers: 'on',
// automaticLayout: true,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
fontSize: 12, fontSize: 12,
language: 'openscad', language: 'openscad',
@ -12,37 +11,10 @@ export default {
wordWrap: 'on', wordWrap: 'on',
wrappingStrategy: 'advanced', wrappingStrategy: 'advanced',
suggest: { suggest: {
// snippetsPreventQuickSuggestions: false,
localityBonus: true, localityBonus: true,
showStatusBar: true, showStatusBar: true,
preview: true, preview: true,
}, },
codeLens: true, codeLens: true,
// language: 'javascript',
wordBasedSuggestions: false, wordBasedSuggestions: false,
} as monaco.editor.IStandaloneEditorConstructionOptions; } 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', 'render', 'resize', 'rotate', 'round', 'scale',
'sphere', 'square', 'surface', 'translate', 'sphere', 'square', 'surface', 'translate',
'union', 'use', 'value', 'version', 'union', 'use', 'value', 'version',
// 'center', 'width', 'height',
]; ];
const builtInVarNames = [ const builtInVarNames = [
'false', 'true', 'PI', 'undef', '$children', 'false', 'true', 'PI', 'undef', '$children',

View file

@ -4,7 +4,6 @@ import loader from '@monaco-editor/loader';
import { ZipArchives } from '../fs/zip-archives'; import { ZipArchives } from '../fs/zip-archives';
import { buildOpenSCADCompletionItemProvider } from './openscad-completions'; import { buildOpenSCADCompletionItemProvider } from './openscad-completions';
import openscadLanguage from './openscad-language'; 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 // https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
export async function registerOpenSCADLanguage(fs: any, workingDir: string, zipArchives: ZipArchives) { 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"], mimetypes: ["text/openscad"],
}); });
//const [inheritLanguage] = monaco.languages.getLanguages().filter(l => l.id === 'javascript');
const { conf, language } = openscadLanguage; const { conf, language } = openscadLanguage;
monaco.languages.setLanguageConfiguration('openscad', conf); monaco.languages.setLanguageConfiguration('openscad', conf);
monaco.languages.setMonarchTokensProvider('openscad', language); 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. // 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 * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { getParentDir } from '../fs/filesystem'; import { ProcessStreams, spawnOpenSCAD } from "./openscad-runner";
import { spawnOpenSCAD } from "./openscad-runner";
import { processMergedOutputs } from "./output-parser"; import { processMergedOutputs } from "./output-parser";
import { AbortablePromise, turnIntoDelayableExecution } from '../utils'; 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'; import { ParameterSet } from '../state/customizer-types';
const syntaxDelay = 300; const syntaxDelay = 300;
type SyntaxCheckArgs = {
activePath: string,
sources: Source[],
}
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[], parameterSet?: ParameterSet}; type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[], parameterSet?: ParameterSet};
export const checkSyntax = export const checkSyntax =
turnIntoDelayableExecution(syntaxDelay, (source: string, sourcePath: string) => { turnIntoDelayableExecution(syntaxDelay, (sargs: SyntaxCheckArgs) => {
// const timestamp = Date.now(); const {
activePath,
sources,
} = sargs;
source = '$preview=true;\n' + source; const content = '$preview=true;\n' + sources[0].content;
const outFile = 'out.json'; const outFile = 'out.json';
const job = spawnOpenSCAD({ const job = spawnOpenSCAD({
inputs: [[sourcePath, source + '\n']], mountArchives: true,
args: [sourcePath, "-o", outFile, "--export-format=param"], inputs: sources,
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home' args: [activePath, "-o", outFile, "--export-format=param"],
outputPaths: [outFile], outputPaths: [outFile],
}, (streams) => {
console.log(JSON.stringify(streams));
}); });
return AbortablePromise<SyntaxCheckOutput>((res, rej) => { return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
@ -46,7 +56,7 @@ export const checkSyntax =
res({ res({
...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: { ...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
sourcePath, sourcePath: sources[0].path,
skipLines: 1, skipLines: 1,
}}), }}),
parameterSet, parameterSet,
@ -61,15 +71,22 @@ export const checkSyntax =
}); });
var renderDelay = 1000; 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 = { export type RenderArgs = {
source: string, scadPath: string,
sourcePath: string, sources: Source[],
vars?: {[name: string]: any}, vars?: {[name: string]: any},
features?: string[], features?: string[],
extraArgs?: 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 { function formatValue(any: any): string {
@ -82,40 +99,63 @@ function formatValue(any: any): string {
} }
} }
export const render = 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[] = []; const prefixLines: string[] = [];
let features: string[]
if (isPreview) { if (isPreview) {
prefixLines.push('$preview=true;'); 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 = [ const args = [
sourcePath, scadPath,
"-o", "out.stl", "-o", outFile,
"--export-format=binstl", "--backend=manifold",
"--export-format=" + (actualRenderFormat == 'stl' ? 'binstl' : actualRenderFormat),
...(Object.entries(vars ?? {}).flatMap(([k, v]) => [`-D${k}=${formatValue(v)}`])), ...(Object.entries(vars ?? {}).flatMap(([k, v]) => [`-D${k}=${formatValue(v)}`])),
...(features ?? []).map(f => `--enable=${f}`), ...(features ?? []).map(f => `--enable=${f}`),
...(extraArgs ?? []) ...(extraArgs ?? [])
] ]
const job = spawnOpenSCAD({ const job = spawnOpenSCAD({
// wasmMemory, mountArchives: mountArchives,
inputs: [[sourcePath, source]], inputs: sources.map(s => s.path === scadPath ? {path: s.path, content} : s),
args, args,
outputPaths: ['out.stl'], outputPaths: [outFile],
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home' }, streamsCallback);
});
return AbortablePromise<RenderOutput>((resolve, reject) => { return AbortablePromise<RenderOutput>((resolve, reject) => {
(async () => { (async () => {
try { try {
const result = await job; const result = await job;
console.log(result); // console.log(result);
const {logText, markers} = processMergedOutputs(result.mergedOutputs, { const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
shiftSourceLines: { shiftSourceLines: {
sourcePath: sourcePath, sourcePath: source.path,
skipLines: prefixLines.length skipLines: prefixLines.length
} }
}); });
@ -131,13 +171,13 @@ export const render =
} }
const [filePath, content] = output; const [filePath, content] = output;
const filePathFragments = filePath.split('/'); const filePathFragments = filePath.split('/');
const fileName = filePathFragments[filePathFragments.length - 1]; let fileName = filePathFragments[filePathFragments.length - 1];
// TODO: have the runner accept and return files. // TODO: have the runner accept and return files.
const blob = new Blob([content], { type: "application/octet-stream" }); const type = filePath.endsWith('.svg') ? 'image/svg+xml' : 'application/octet-stream';
// console.log(new TextDecoder().decode(content)); let blob = new Blob([content]);
const stlFile = new File([blob], fileName); let outFile = new File([blob], fileName, {type});
resolve({stlFile, logText, markers, elapsedMillis: result.elapsedMillis}); resolve({outFile, logText, markers, elapsedMillis: result.elapsedMillis});
} catch (e) { } catch (e) {
console.error(e); console.error(e);
reject(e); reject(e);
@ -147,3 +187,4 @@ export const render =
return () => job.kill() return () => job.kill()
}); });
}); });

View file

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

View file

@ -2,74 +2,117 @@
import OpenSCAD from "../wasm/openscad.js"; import OpenSCAD from "../wasm/openscad.js";
import { createEditorFS, symlinkLibraries } from "../fs/filesystem"; import { createEditorFS, getParentDir, symlinkLibraries } from "../fs/filesystem";
import { OpenSCADInvocation, OpenSCADInvocationResults } from "./openscad-runner"; import { OpenSCADInvocation, OpenSCADInvocationCallback, OpenSCADInvocationResults } from "./openscad-runner";
import { deployedArchiveNames, zipArchives } from "../fs/zip-archives"; import { deployedArchiveNames, zipArchives } from "../fs/zip-archives";
import { fetchSource } from "../utils.js";
import { stderr } from "process";
declare var BrowserFS: BrowserFSInterface declare var BrowserFS: BrowserFSInterface
importScripts("browserfs.min.js"); 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}[]; export type MergedOutputs = {stdout?: string, stderr?: string, error?: string}[];
function callback(payload: OpenSCADInvocationCallback) {
postMessage(payload);
}
addEventListener('message', async (e) => { 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 = []; const mergedOutputs: MergedOutputs = [];
let instance: any;
const start = performance.now();
try { try {
const instance = await OpenSCAD({ instance = await OpenSCAD({
wasmMemory, wasmMemory,
buffer: wasmMemory && wasmMemory.buffer, buffer: wasmMemory && wasmMemory.buffer,
noInitialRun: true, noInitialRun: true,
'print': (text: string) => { 'print': (text: string) => {
console.debug('stdout: ' + text); console.debug('stdout: ' + text);
callback({stdout: text})
mergedOutputs.push({ stdout: text }) mergedOutputs.push({ stdout: text })
}, },
'printErr': (text: string) => { 'printErr': (text: string) => {
console.debug('stderr: ' + text); console.debug('stderr: ' + text);
callback({stderr: text})
mergedOutputs.push({ stderr: text }) mergedOutputs.push({ stderr: text })
}, },
'ENV': {
'OPENSCADPATH': '/libraries',
},
}); });
// This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip if (mountArchives) {
await createEditorFS({prefix: '', allowPersistence: false}); // This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip
await createEditorFS({prefix: '', allowPersistence: false});
instance.FS.mkdir('/libraries'); instance.FS.mkdir('/libraries');
// https://github.com/emscripten-core/emscripten/issues/10061 // https://github.com/emscripten-core/emscripten/issues/10061
const BFS = new BrowserFS.EmscriptenFS( const BFS = new BrowserFS.EmscriptenFS(
instance.FS, instance.FS,
instance.PATH ?? { instance.PATH ?? {
join2: (a: string, b: string) => `${a}/${b}`, join2: (a: string, b: string) => `${a}/${b}`,
join: (...args: string[]) => args.join('/'), join: (...args: string[]) => args.join('/'),
}, },
instance.ERRNO_CODES ?? {} instance.ERRNO_CODES ?? {}
); );
instance.FS.mount(BFS, {root: '/'}, '/libraries'); instance.FS.mount(BFS, {root: '/'}, '/libraries');
await symlinkLibraries(deployedArchiveNames, instance.FS, '/libraries', "/"); await symlinkLibraries(deployedArchiveNames, instance.FS, '/libraries', "/");
}
// Fonts are seemingly resolved from $(cwd)/fonts // Fonts are seemingly resolved from $(cwd)/fonts
instance.FS.chdir("/"); 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) { if (inputs) {
for (const [path, content] of inputs) { for (const source of inputs) {
try { try {
// const parent = getParentDir(path); instance.FS.writeFile(source.path, await fetchSource(source));
instance.FS.writeFile(path, content);
// fs.writeFile(path, content);
} catch (e) { } 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) console.log('Invoking OpenSCAD with: ', args)
const start = performance.now(); let exitCode;
const exitCode = instance.callMain(args); 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 end = performance.now();
const elapsedMillis = end - start;
const outputs: [string, string][] = []; const outputs: [string, string][] = [];
for (const path of (outputPaths ?? [])) { for (const path of (outputPaths ?? [])) {
@ -77,26 +120,34 @@ addEventListener('message', async (e) => {
const content = instance.FS.readFile(path); const content = instance.FS.readFile(path);
outputs.push([path, content]); outputs.push([path, content]);
} catch (e) { } 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 = { const result: OpenSCADInvocationResults = {
outputs, outputs,
mergedOutputs, mergedOutputs,
exitCode, exitCode,
elapsedMillis: end - start elapsedMillis,
} }
console.debug(result); console.debug(result);
postMessage(result); callback({result});
} catch (e) { } catch (e) {
console.trace(e);//, e instanceof Error ? e.stack : ''); const end = performance.now();
const elapsedMillis = end - start;
console.trace(e);
const error = `${e}`; const error = `${e}`;
mergedOutputs.push({ error }); mergedOutputs.push({ error });
postMessage({ callback({
error, result: {
mergedOutputs, exitCode: undefined,
} as OpenSCADInvocationResults); error,
mergedOutputs,
elapsedMillis,
}
});
} }
}); });

View file

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

View file

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

View file

@ -2,20 +2,41 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { ParameterSet } from './customizer-types'; 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 MultiLayoutComponentId = 'editor' | 'viewer' | 'customizer';
export type SingleLayoutComponentId = MultiLayoutComponentId; 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 { export interface State {
params: { params: {
sourcePath: string, activePath: string,
source: string, sources: Source[],
vars?: {[name: string]: any}, vars?: {[name: string]: any},
features: string[], features: string[],
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D,
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D,
}, },
view: { view: {
logs?: boolean, logs?: boolean,
extruderPicker?: boolean,
layout: { layout: {
mode: 'single', mode: 'single',
focus: SingleLayoutComponentId, focus: SingleLayoutComponentId,
@ -31,28 +52,24 @@ export interface State {
lineNumbers?: boolean, lineNumbers?: boolean,
} }
currentRunLogs?: ['stderr'|'stdout', string][],
lastCheckerRun?: { lastCheckerRun?: {
logText: string, logText: string,
markers: monaco.editor.IMarkerData[] markers: monaco.editor.IMarkerData[],
} }
rendering?: boolean, rendering?: boolean,
previewing?: boolean, previewing?: boolean,
exporting?: boolean,
checkingSyntax?: boolean, checkingSyntax?: boolean,
parameterSet?: ParameterSet, parameterSet?: ParameterSet,
error?: string, error?: string,
output?: { is2D?: boolean,
output?: FileOutput & {
isPreview: boolean, 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 { export interface StatePersister {

View file

@ -17,29 +17,112 @@ export default `/*
https://github.com/openscad/openscad-playground/issues/new https://github.com/openscad/openscad-playground/issues/new
*/ */
// Click on Render or hit F6 to do a fine-grained rendering. title = "OpenSCAD";
$fn=$preview ? 20 : 100;
translate([-24,0,0]) { color("gray")
union() { translate([0, debug ? -60 : -20, 0])
cube(15, center=true); linear_extrude(1)
sphere(10); text(title,
} halign="center",
} valign="center");
intersection() { // You can find the original for the following example in the file explorer above,
cube(15, center=true); // under openscad / examples / Basic / CSG-modules.scad
sphere(10);
}
translate([24,0,0]) { // 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() { difference() {
cube(15, center=true); intersection() {
sphere(10); body();
intersector();
}
holes();
} }
// Helpers
if (debug) helpers();
} }
translate([0, -30, -12]) // Core geometric primitives.
linear_extrude(1) // These can be modified to create variations of the final object
text("OpenSCAD Playground", halign="center", valign="center");
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. // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { State } from "./app-state"; 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 { 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']}) { export function buildUrlForStateParams(state: State) {//partialState: {params: State['params'], view: State['view']}) {
return `${location.protocol}//${location.host}${location.pathname}#${encodeStateParamsAsFragment(state)}`; 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); window.location.hash = await encodeStateParamsAsFragment(state);
} }
async function compressString(input: string): Promise<string> { 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) { start(controller) {
controller.enqueue(new TextEncoder().encode(input)); controller.enqueue(new TextEncoder().encode(input));
controller.close(); controller.close();
} }
});
// @ts-ignore // @ts-ignore
const compressedStream = stream.pipeThrough(new CompressionStream('gzip')); }).pipeThrough(new CompressionStream('gzip'))).arrayBuffer())));
const compressedData = await new Response(compressedStream).arrayBuffer();
return btoa(String.fromCharCode(...new Uint8Array(compressedData)));
} }
async function decompressString(compressedInput: string): Promise<string> { async function decompressString(compressedInput: string): Promise<string> {
const compressedData = Uint8Array.from(atob(compressedInput), c => c.charCodeAt(0)); return new TextDecoder().decode(await new Response(new ReadableStream({
const stream = new ReadableStream<Uint8Array>({
start(controller) { start(controller) {
controller.enqueue(compressedData); controller.enqueue(Uint8Array.from(atob(compressedInput), c => c.charCodeAt(0)));
controller.close(); controller.close();
} }
});
// @ts-ignore // @ts-ignore
const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip')); }).pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
const decompressedData = await new Response(decompressedStream).arrayBuffer();
return new TextDecoder().decode(decompressedData);
} }
// 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) { export function encodeStateParamsAsFragment(state: State) {
const json = JSON.stringify({ const json = JSON.stringify({
params: state.params, params: state.params,
@ -60,12 +61,17 @@ export async function readStateFromFragment(): Promise<State | null> {
const {params, view} = obj; const {params, view} = obj;
return { return {
params: { params: {
sourcePath: validateString(params?.sourcePath), activePath: validateString(params?.activePath, () => defaultSourcePath),
source: validateString(params?.source),
features: validateArray(params?.features, validateString), features: validateArray(params?.features, validateString),
vars: params?.vars, // TODO: validate! 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: { view: {
logs: validateBoolean(view?.logs),
extruderPicker: validateBoolean(view?.extruderPicker),
layout: { layout: {
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']), mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false), 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 defaultSourcePath = '/playground.scad';
export const defaultModelColor = '#f9d72c'; export const defaultModelColor = '#f9d72c';
export const blankProjectState: State = { export function createInitialState(state: State | null, content: string = defaultScad): State {
params: {
sourcePath: defaultSourcePath,
source: '',
features: [],
},
view: {
color: defaultModelColor,
layout: {
mode: 'single',
focus: 'editor'
}
}
};
export function createInitialState(fs: any, state: State | null) {
type Mode = State['view']['layout']['mode']; type Mode = State['view']['layout']['mode'];
const mode: Mode = window.matchMedia("(min-width: 768px)").matches const mode: Mode = window.matchMedia("(min-width: 768px)").matches
@ -29,9 +14,11 @@ export function createInitialState(fs: any, state: State | null) {
const initialState: State = { const initialState: State = {
params: { params: {
sourcePath: defaultSourcePath, activePath: defaultSourcePath,
source: defaultScad, sources: [{path: defaultSourcePath, content}],
features: [], features: [],
exportFormat2D: 'svg',
exportFormat3D: 'glb',
}, },
view: { view: {
layout: { layout: {
@ -67,12 +54,12 @@ export function createInitialState(fs: any, state: State | null) {
initialState.view.showAxes ??= true initialState.view.showAxes ??= true
initialState.view.showShadows ??= true initialState.view.showShadows ??= true
fs.writeFile(initialState.params.sourcePath, initialState.params.source); // fs.writeFile(initialState.params.sourcePath, initialState.params.source);
if (initialState.params.sourcePath !== defaultSourcePath) { // if (initialState.params.sourcePath !== defaultSourcePath) {
fs.writeFile(defaultSourcePath, defaultScad); // fs.writeFile(defaultSourcePath, defaultScad);
} // }
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union']; const defaultFeatures = ['lazy-union'];
defaultFeatures.forEach(f => { defaultFeatures.forEach(f => {
if (initialState.params.features.indexOf(f) < 0) if (initialState.params.features.indexOf(f) < 0)
initialState.params.features.push(f); initialState.params.features.push(f);
@ -81,3 +68,5 @@ export function createInitialState(fs: any, state: State | null) {
return initialState; 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. // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { checkSyntax, render, RenderArgs, RenderOutput } from "../runner/actions"; 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 { 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 { export class Model {
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void, constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
@ -12,7 +18,7 @@ export class Model {
init() { init() {
if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering && 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(); this.processSource();
} }
} }
@ -35,6 +41,14 @@ export class Model {
return false; 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) { setVar(name: string, value: any) {
this.mutate(s => s.params.vars = {...s.params.vars ?? {}, [name]: value}); this.mutate(s => s.params.vars = {...s.params.vars ?? {}, [name]: value});
this.render({isPreview: true, now: false}); this.render({isPreview: true, now: false});
@ -103,46 +117,181 @@ export class Model {
openFile(path: string) { openFile(path: string) {
// alert(`TODO: open ${path}`); // alert(`TODO: open ${path}`);
if (this.mutate(s => { if (this.mutate(s => {
s.params.source = new TextDecoder("utf-8").decode(this.fs.readFileSync(path)); if (s.params.activePath != path) {
if (s.params.sourcePath != path) { s.params.activePath = path;
s.params.sourcePath = 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.lastCheckerRun = undefined;
s.output = undefined; s.output = undefined;
s.export = undefined;
} }
})) { })) {
this.processSource(); this.processSource();
} }
} }
get source(): string {
return this.state.params.sources.find(src => src.path === this.state.params.activePath)?.content ?? '';
}
set source(source: string) { 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(); this.processSource();
} }
} }
private processSource() { private processSource() {
const params = this.state.params; // const params = this.state.params;
// if (isFileWritable(params.sourcePath)) { // if (isFileWritable(params.sourcePath)) {
// const absolutePath = params.sourcePath.startsWith('/') ? params.sourcePath : `/${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);
// } // }
this.checkSyntax(); if (this.state.params.activePath.endsWith('.scad')) {
this.checkSyntax();
}
this.render({isPreview: true, now: false}); this.render({isPreview: true, now: false});
} }
checkSyntax() {
async checkSyntax() {
this.mutate(s => s.checkingSyntax = true); this.mutate(s => s.checkingSyntax = true);
checkSyntax(this.state.params.source, this.state.params.sourcePath)({now: false, callback: (checkerRun, err) => this.mutate(s => { try {
if (err != null) { const checkerRun = await checkSyntax({
console.error('Error while checking syntax:', err) activePath: this.state.params.activePath,
} else { sources: this.state.params.sources,
})({now: false});
this.mutate(s => {
s.lastCheckerRun = checkerRun; s.lastCheckerRun = checkerRun;
s.parameterSet = checkerRun?.parameterSet; s.parameterSet = checkerRun?.parameterSet;
s.checkingSyntax = false; 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) => { const setRendering = (s: State, value: boolean) => {
if (isPreview) { if (isPreview) {
s.previewing = value; s.previewing = value;
@ -150,41 +299,142 @@ export class Model {
s.rendering = value; 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 => { this.mutate(s => {
setRendering(s, false); setRendering(s, false);
if (err != null) { s.error = undefined;
console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err) s.is2D = is2D;
s.error = `${err}`; s.lastCheckerRun = {
} else if (output) { logText: output.logText,
s.error = undefined; markers: output.markers,
s.lastCheckerRun = { }
logText: output.logText, if (s.output?.outFileURL?.startsWith('blob:') ?? false) {
markers: output.markers, URL.revokeObjectURL(s.output!.outFileURL);
} }
if (s.output?.stlFileURL) { if (s.output?.displayFileURL?.startsWith('blob:') ?? false) {
URL.revokeObjectURL(s.output.stlFileURL); URL.revokeObjectURL(s.output!.displayFileURL!);
} }
s.output = { s.output = {
isPreview: isPreview, isPreview: isPreview,
stlFile: output.stlFile, outFile: output.outFile,
stlFileURL: URL.createObjectURL(output.stlFile), outFileURL,
elapsedMillis: output.elapsedMillis, displayFile,
formattedElapsedMillis: formatMillis(output.elapsedMillis), displayFileURL,
formattedStlFileSize: formatBytes(output.stlFile.size), elapsedMillis: output.elapsedMillis,
}; formattedElapsedMillis: formatMillis(output.elapsedMillis),
formattedOutFileSize: formatBytes(output.outFile.size),
};
if (!isPreview) { if (!isPreview) {
const audio = document.getElementById('complete-sound') as HTMLAudioElement; const audio = document.getElementById('complete-sound') as HTMLAudioElement;
audio?.play(); 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. // 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) { export function mapObject(o: any, f: (key: string, value: any) => any, ifPred: (key: string) => boolean) {
const ret = []; const ret = [];
for (const key of Object.keys(o)) { for (const key of Object.keys(o)) {
@ -28,33 +30,71 @@ export function turnIntoDelayableExecution<T extends any[], R>(
job: (...args: T) => AbortablePromise<R>) { job: (...args: T) => AbortablePromise<R>) {
let pendingId: number | null; let pendingId: number | null;
let runningJobKillSignal: (() => void) | 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;
const doExecute = async () => { // if (result.outputs && result.outputs.length == 1) {
if (runningJobKillSignal) { // let [[, content]] = result.outputs;
runningJobKillSignal(); // content = new TextDecoder().decode(content as any);
runningJobKillSignal = null; // 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;
}
abortablePromise = job(...args);
runningJobKillSignal = abortablePromise.kill;
try {
resolve(await abortablePromise);
} catch (e) {
reject(e);
} finally {
runningJobKillSignal = null;
}
} }
const abortablePromise = job(...args); if (pendingId) {
runningJobKillSignal = abortablePromise.kill; clearTimeout(pendingId);
try { pendingId = null;
callback(await abortablePromise);
} catch (e) {
callback(undefined, e);
} finally {
runningJobKillSignal = null;
} }
} if (now) {
if (pendingId) { doExecute();
clearTimeout(pendingId); } else {
pendingId = null; pendingId = window.setTimeout(doExecute, delay);
} }
if (now) { })();
doExecute(); return () => abortablePromise?.kill();
} else { });
pendingId = window.setTimeout(doExecute, delay);
}
};
} }
export function validateStringEnum<T extends string>( 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[] = () => []) => { export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
if (!(a instanceof Array)) return orElse(); if (!(a instanceof Array)) return orElse();
return a.map(validateElement); return a.map(validateElement);
} };
export function formatBytes(n: number) { export function formatBytes(n: number) {
if (n < 1024) { if (n < 1024) {
@ -102,4 +142,43 @@ export const isInStandaloneMode =
// true // true
Boolean(('standalone' in window.navigator) && (window.navigator.standalone)); 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": { "compilerOptions": {
"target": "ES2021", "target": "ES2022",
"lib": [ "lib": [
"dom", "dom",
"dom.iterable", "dom.iterable",
"ES2021", "ES2022",
"WebWorker", "WebWorker",
], ],
"rootDir": "src", "rootDir": "src",
@ -28,5 +28,9 @@
"src/**/*.tsx", "src/**/*.tsx",
"src/**/*.js", "src/**/*.js",
// "public" // "public"
],
"exclude": [
"node_modules",
"src/wasm/*.js"
] ]
} }

View file

@ -1,5 +1,6 @@
const CopyPlugin = require("copy-webpack-plugin"); const CopyPlugin = require("copy-webpack-plugin");
const WorkboxPlugin = require('workbox-webpack-plugin'); const WorkboxPlugin = require('workbox-webpack-plugin');
const webpack = require('webpack');
const path = require('path'); const path = require('path');
@ -42,25 +43,30 @@ module.exports = [{
port: 4000, port: 4000,
}, },
plugins: [ plugins: [
new WorkboxPlugin.GenerateSW({ new webpack.EnvironmentPlugin({
// these options encourage the ServiceWorkers to get in there fast 'process.env.NODE_ENV': 'development',
// and not allow any straggling "old" SWs to hang around
swDest: path.join(__dirname, "dist", 'sw.js'),
maximumFileSizeToCacheInBytes: 200 * 1024 * 1024,
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [{
urlPattern: ({request, url}) => true,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'all',
expiration: {
maxEntries: 1000,
purgeOnQuotaError: true,
},
},
}],
}), }),
...(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
swDest: path.join(__dirname, "dist", 'sw.js'),
maximumFileSizeToCacheInBytes: 200 * 1024 * 1024,
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [{
urlPattern: ({request, url}) => true,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'all',
expiration: {
maxEntries: 1000,
purgeOnQuotaError: true,
},
},
}],
}),
] : []),
new CopyPlugin({ new CopyPlugin({
patterns: [ patterns: [
{ {