🌈 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:
parent
92103a80b9
commit
f3cb9ae3f9
40 changed files with 1266 additions and 540 deletions
18
.github/workflows/test.yml
vendored
18
.github/workflows/test.yml
vendored
|
|
@ -9,21 +9,29 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [ 18, 20, 22 ]
|
||||
name: Node${{ matrix.node }}
|
||||
node: [
|
||||
{"name": "LTS", "version": "lts/-2"},
|
||||
{"name": "latest", "version": "latest"}
|
||||
]
|
||||
name: Node ${{ matrix.node.name }}
|
||||
env:
|
||||
CI: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup node ${{ matrix.node }}
|
||||
- name: Setup node ${{ matrix.node.name }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
node-version: ${{ matrix.node.version }}
|
||||
- run: npm install
|
||||
- run: make public
|
||||
- run: npm run build
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-node${{ matrix.node }}
|
||||
name: dist-node-${{ matrix.node.name }}
|
||||
path: dist
|
||||
retention-days: 30
|
||||
- run: NODE_ENV=development npm run test:e2e
|
||||
- run: NODE_ENV=production npm run test:e2e
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ The WASM build was made possible by https://github.com/DSchroer/openscad-wasm.
|
|||
* [GNU MP](#gnu-mp)
|
||||
* [OpenSCAD](#openscad)
|
||||
* [CGAL](#cgal)
|
||||
* [JSZip](#jszip)
|
||||
* [Manifold](#manifold)
|
||||
* [Eigen](#eigen)
|
||||
* [glib2](#glib2)
|
||||
|
|
@ -59,6 +60,12 @@ https://github.com/elalish/manifold
|
|||
|
||||
Licensed under the [Apache 2.0](#apache2) license.
|
||||
|
||||
## JSZip
|
||||
|
||||
https://github.com/Stuk/jszip
|
||||
|
||||
Licensed under the [GPLv3](#gplv3) (actually dual-licensed under MIT, but we're picking this one here for brevity).
|
||||
|
||||
## PrimeReact
|
||||
|
||||
https://github.com/primefaces/primereact
|
||||
|
|
|
|||
21
Makefile
21
Makefile
|
|
@ -1,5 +1,5 @@
|
|||
# Pinning WASM build to last good revision (https://github.com/openscad/openscad-playground/issues/60)
|
||||
WASM_BUILD_URL=https://files.openscad.org/snapshots/OpenSCAD-2024.09.27.wasm20596-WebAssembly.zip
|
||||
# Pin WASM build to a version known to work
|
||||
WASM_BUILD_URL=https://output.circle-artifacts.com/output/job/fdf49786-06c1-4775-aaa3-15dc089efc12/artifacts/0/wasm/OpenSCAD-2024.12.22.wasm21804-WebAssembly-web.zip
|
||||
# WASM_SNAPSHOT_JS_URL=https://files.openscad.org/snapshots/.snapshot_wasm.js
|
||||
# WASM_BUILD_URL=$(shell curl ${WASM_SNAPSHOT_JS_URL} 2>/dev/null | grep https | sed -E "s/.*(https:[^']+)'.*/\1/" )
|
||||
|
||||
|
|
@ -47,13 +47,21 @@ clean:
|
|||
dist/index.js: public
|
||||
npm run build
|
||||
|
||||
dist/openscad-worker.js: src/openscad-worker.ts
|
||||
dist/openscad-worker.js: src/openscad-worker.ts src/wasm/openscad.js
|
||||
npx rollup -c
|
||||
|
||||
src/wasm: libs/openscad-wasm
|
||||
rm -f src/wasm
|
||||
ln -sf "$(shell pwd)/libs/openscad-wasm" src/wasm
|
||||
|
||||
# libs/openscad/build/openscad.js: libs/openscad
|
||||
# ( cd libs/openscad && ./scripts/wasm-base-docker-run.sh emcmake cmake -B build -DCMAKE_BUILD_TYPE=Release -DEXPERIMENTAL=1 )
|
||||
# ( cd libs/openscad && ./scripts/wasm-base-docker-run.sh /bin/bash -c "cmake --build build -j || cmake --build build -j2 || cmake --build build" )
|
||||
|
||||
# libs/openscad-wasm: libs/openscad/build/openscad.js
|
||||
# mkdir -p libs/openscad-wasm
|
||||
# cp libs/openscad/build/openscad.* libs/openscad-wasm/
|
||||
|
||||
libs/openscad-wasm:
|
||||
mkdir -p libs/openscad-wasm
|
||||
wget ${WASM_BUILD_URL} -O libs/openscad-wasm.zip
|
||||
|
|
@ -116,7 +124,8 @@ NOTO_FONTS=\
|
|||
# libs/noto/NotoSansCJKtc-Regular.otf
|
||||
|
||||
public/libraries/fonts.zip: $(NOTO_FONTS) libs/liberation
|
||||
zip -r $@ -j fonts.conf libs/noto/{*.ttf,*.otf} libs/liberation/{*.ttf,LICENSE,AUTHORS}
|
||||
mkdir -p public/libraries
|
||||
zip -r $@ -j fonts.conf libs/noto/*.ttf libs/liberation/{*.ttf,LICENSE,AUTHORS}
|
||||
|
||||
libs/noto/%.ttf:
|
||||
mkdir -p libs/noto
|
||||
|
|
@ -130,7 +139,7 @@ libs/liberation:
|
|||
git clone --recurse https://github.com/shantigilbert/liberation-fonts-ttf.git ${SHALLOW} ${SINGLE_BRANCH} $@
|
||||
|
||||
libs/openscad:
|
||||
git clone https://github.com/openscad/openscad.git ${SHALLOW} ${SINGLE_BRANCH} $@
|
||||
git clone --recurse https://github.com/openscad/openscad.git ${SHALLOW} ${SINGLE_BRANCH} $@
|
||||
|
||||
public/libraries/openscad.zip: libs/openscad
|
||||
mkdir -p public/libraries
|
||||
|
|
@ -176,7 +185,7 @@ libs/YAPP_Box:
|
|||
|
||||
public/libraries/YAPP_Box.zip: libs/YAPP_Box
|
||||
mkdir -p public/libraries
|
||||
( cd libs/YAPP_Box ; zip -r ../../public/libraries/YAPP_Box.zip *.scad LICENSE )
|
||||
( cd libs/YAPP_Box ; zip -r ../../public/libraries/YAPP_Box.zip `find . -name '*.scad'` LICENSE )
|
||||
|
||||
libs/MCAD:
|
||||
git clone --recurse https://github.com/openscad/MCAD.git ${SHALLOW} ${SINGLE_BRANCH} $@
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -47,11 +47,17 @@ Prerequisites:
|
|||
* wget
|
||||
* GNU make
|
||||
* npm
|
||||
* Docker able to run amd64 containers. If running on a different platform (including Silicon Mac), you can add support for amd64 images through QEMU with:
|
||||
|
||||
```bash
|
||||
docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
```
|
||||
|
||||
Local dev:
|
||||
|
||||
```bash
|
||||
make public
|
||||
npm install
|
||||
npm start
|
||||
# http://localhost:4000/
|
||||
```
|
||||
|
|
@ -60,6 +66,7 @@ Local prod (test both the different inlining and serving under a prefix):
|
|||
|
||||
```bash
|
||||
make public
|
||||
npm install
|
||||
npm run start:prod
|
||||
# http://localhost:3000/dist/
|
||||
```
|
||||
|
|
@ -68,7 +75,8 @@ Deployment (edit "homepage" in `package.json` to match your deployment root!):
|
|||
|
||||
```bash
|
||||
make public
|
||||
npm run build
|
||||
npm install
|
||||
NODE_ENV=production npm run build
|
||||
|
||||
rm -fR ../ochafik.github.io/openscad2 && cp -R dist ../ochafik.github.io/openscad2
|
||||
# Now commit and push changes, wait for site update and enjoy!
|
||||
|
|
|
|||
|
|
@ -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
10
jest-puppeteer.config.js
Normal 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
6
jest.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
preset: "jest-puppeteer",
|
||||
testMatch: [
|
||||
"**/tests/**/*.js",
|
||||
],
|
||||
};
|
||||
17
package.json
17
package.json
|
|
@ -4,6 +4,8 @@
|
|||
"private": true,
|
||||
"homepage": "https://ochafik.com/openscad2/",
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^4.1.1",
|
||||
"@gltf-transform/extensions": "^4.1.1",
|
||||
"@monaco-editor/loader": "^1.3.2",
|
||||
"@monaco-editor/react": "^4.4.6",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
|
|
@ -14,18 +16,20 @@
|
|||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"debug": "^4.3.4",
|
||||
"jszip": "^3.10.1",
|
||||
"monaco-editor": "^0.36.1",
|
||||
"primeflex": "^3.3.1",
|
||||
"primeicons": "^6.0.1",
|
||||
"primereact": "9.3.x",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-stl-viewer": "^2.2.5",
|
||||
"react-widgets": "^5.8.4"
|
||||
"react-stl-viewer": "^2.2.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "concurrently 'npx webpack serve --mode=development' 'NODE_ENV=development npx rollup --config openscad-worker.rollup.config.js --watch'",
|
||||
"start:prod": "PUBLIC_URL=http://localhost:3000/dist/ npm run build && npx serve",
|
||||
"test:e2e": "jest",
|
||||
"start:development": "concurrently 'npx webpack serve --mode=development' 'NODE_ENV=development npx rollup --config openscad-worker.rollup.config.js --watch'",
|
||||
"start:production": "NODE_ENV=production PUBLIC_URL=http://localhost:3000/dist/ npm run build && npx serve",
|
||||
"start": "npm run start:development",
|
||||
"build": "NODE_ENV=production npx rollup --config openscad-worker.rollup.config.js && webpack --mode=production"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
|
@ -57,7 +61,10 @@
|
|||
"concurrently": "^7.6.0",
|
||||
"copy-webpack-plugin": "^11.0.0",
|
||||
"css-loader": "^6.8.1",
|
||||
"jest": "^29.7.0",
|
||||
"jest-puppeteer": "^11.0.0",
|
||||
"livereload": "^0.9.3",
|
||||
"puppeteer": "^23.11.1",
|
||||
"rollup": "^2.79.1",
|
||||
"rollup-plugin-babel": "^4.4.0",
|
||||
"rollup-plugin-css": "^1.0.0",
|
||||
|
|
@ -68,7 +75,7 @@
|
|||
"serve": "^14.2.0",
|
||||
"style-loader": "^3.3.3",
|
||||
"ts-loader": "^9.4.2",
|
||||
"tslib": "^2.5.0",
|
||||
"tslib": "^2.6.3",
|
||||
"webpack": "^5.76.3",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "^4.15.2",
|
||||
|
|
|
|||
BIN
public/skybox-lights.jpg
Normal file
BIN
public/skybox-lights.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -12,10 +12,6 @@ import { ConfirmDialog } from 'primereact/confirmdialog';
|
|||
import CustomizerPanel from './CustomizerPanel';
|
||||
|
||||
|
||||
// import "primereact/resources/themes/lara-light-indigo/theme.css";
|
||||
// import "primereact/resources/primereact.min.css";
|
||||
// import "primeicons/primeicons.css";
|
||||
|
||||
export function App({initialState, statePersister, fs}: {initialState: State, statePersister: StatePersister, fs: FS}) {
|
||||
const [state, setState] = useState(initialState);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { CSSProperties, useContext } from 'react';
|
||||
import { ModelContext, FSContext } from './contexts';
|
||||
import { ModelContext } from './contexts';
|
||||
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Slider } from 'primereact/slider';
|
||||
|
|
@ -48,6 +48,7 @@ export default function CustomizerPanel({className, style}: {className?: string,
|
|||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'scroll',
|
||||
...style,
|
||||
bottom: 'unset',
|
||||
|
|
@ -81,8 +82,6 @@ export default function CustomizerPanel({className, style}: {className?: string,
|
|||
function ParameterInput({param, value, className, style, handleChange}: {param: Parameter, value: any, className?: string, style?: CSSProperties, handleChange: (key: string, value: any) => void}) {
|
||||
return (
|
||||
<div
|
||||
// ref={ref}
|
||||
// onClick={state.view.layout.mode === 'single' ? handleClick : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
...style,
|
||||
|
|
@ -99,7 +98,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
|
|||
justifyContent: 'space-between',
|
||||
}}>
|
||||
<div
|
||||
// onClick={swallowClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
|
|
@ -109,10 +107,7 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
|
|||
<div>{param.caption}</div>
|
||||
</div>
|
||||
<div
|
||||
// onClick={swallowClick}
|
||||
style={{
|
||||
// flex: 1,
|
||||
// margin: '10px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
@ -130,7 +125,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
|
|||
)}
|
||||
{param.type === 'string' && param.options && (
|
||||
<Dropdown
|
||||
// style={{flex: 1}}
|
||||
value={value || param.initial}
|
||||
options={param.options}
|
||||
onChange={(e) => handleChange(param.name, e.value)}
|
||||
|
|
@ -140,18 +134,15 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
|
|||
)}
|
||||
{param.type === 'boolean' && (
|
||||
<Checkbox
|
||||
// style={{flex: 1}}
|
||||
checked={value ?? param.initial}
|
||||
onChange={(e) => handleChange(param.name, e.checked)}
|
||||
/>
|
||||
)}
|
||||
{!Array.isArray(param.initial) && param.type === 'number' && !('options' in param) && (
|
||||
<InputNumber
|
||||
// style={{flex: 1}}
|
||||
value={value || param.initial}
|
||||
showButtons
|
||||
size={5}
|
||||
// buttonLayout='horizontal'
|
||||
onValueChange={(e) => handleChange(param.name, e.value)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -176,7 +167,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
|
|||
min={param.min}
|
||||
max={param.max}
|
||||
showButtons
|
||||
// buttonLayout='horizontal'
|
||||
size={5}
|
||||
step={param.step}
|
||||
onValueChange={(e) => {
|
||||
|
|
@ -194,7 +184,6 @@ function ParameterInput({param, value, className, style, handleChange}: {param:
|
|||
marginRight: '0',
|
||||
visibility: value === undefined || (JSON.stringify(value) === JSON.stringify(param.initial)) ? 'hidden' : 'visible',
|
||||
}}
|
||||
// tooltip={`Reset to default value (${JSON.stringify(param.initial)})`}
|
||||
tooltipOptions={{position: 'left'}}
|
||||
icon='pi pi-refresh'
|
||||
className='p-button-text'/>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ export default function EditorPanel({className, style}: {className?: string, sty
|
|||
label: "Preview OpenSCAD",
|
||||
run: () => model.render({isPreview: true, now: true})
|
||||
});
|
||||
editor.addAction({
|
||||
id: "openscad-save-do-nothing",
|
||||
label: "Save (disabled)",
|
||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
|
||||
run: () => {}
|
||||
});
|
||||
editor.addAction({
|
||||
id: "openscad-save-project",
|
||||
label: "Save OpenSCAD project",
|
||||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyS],
|
||||
run: () => model.saveProject()
|
||||
});
|
||||
setEditor(editor)
|
||||
}
|
||||
|
||||
|
|
@ -114,45 +126,13 @@ export default function EditorPanel({className, style}: {className?: string, sty
|
|||
{
|
||||
separator: true
|
||||
},
|
||||
// https://vscode-docs.readthedocs.io/en/stable/customization/keybindings/
|
||||
// {
|
||||
// label: 'Undo',
|
||||
// icon: 'pi pi-undo',
|
||||
// // disabled: true,
|
||||
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.undoAction', null),
|
||||
// },
|
||||
// {
|
||||
// label: 'Redo',
|
||||
// icon: 'pi pi-reply',
|
||||
// // disabled: true,
|
||||
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.redoAction', null),
|
||||
// },
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
// {
|
||||
// label: 'Copy',
|
||||
// icon: 'pi pi-copy',
|
||||
// // disabled: true,
|
||||
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCopyAction', null),
|
||||
// },
|
||||
// {
|
||||
// label: 'Cut',
|
||||
// icon: 'pi pi-eraser',
|
||||
// // disabled: true,
|
||||
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardCutAction', null),
|
||||
// },
|
||||
// {
|
||||
// label: 'Paste',
|
||||
// icon: 'pi pi-images',
|
||||
// // disabled: true,
|
||||
// command: () => editor?.trigger(state.params.sourcePath, 'editor.action.clipboardPasteAction', null),
|
||||
// },
|
||||
{
|
||||
label: 'Select All',
|
||||
icon: 'pi pi-info-circle',
|
||||
// disabled: true,
|
||||
command: () => editor?.trigger(state.params.sourcePath, 'editor.action.selectAll', null),
|
||||
command: () => editor?.trigger(state.params.activePath, 'editor.action.selectAll', null),
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
|
|
@ -160,8 +140,7 @@ export default function EditorPanel({className, style}: {className?: string, sty
|
|||
{
|
||||
label: 'Find',
|
||||
icon: 'pi pi-search',
|
||||
// disabled: true,
|
||||
command: () => editor?.trigger(state.params.sourcePath, 'actions.find', null),
|
||||
command: () => editor?.trigger(state.params.activePath, 'actions.find', null),
|
||||
},
|
||||
] as MenuItem[]} popup ref={menu} />
|
||||
<Button title="Editor menu" rounded text icon="pi pi-ellipsis-h" onClick={(e) => menu.current && menu.current.toggle(e)} />
|
||||
|
|
@ -171,7 +150,7 @@ export default function EditorPanel({className, style}: {className?: string, sty
|
|||
flex: 1,
|
||||
}}/>
|
||||
|
||||
{state.params.sourcePath !== defaultSourcePath &&
|
||||
{state.params.activePath !== defaultSourcePath &&
|
||||
<Button icon="pi pi-chevron-left"
|
||||
text
|
||||
onClick={() => model.openFile(defaultSourcePath)}
|
||||
|
|
@ -188,24 +167,21 @@ export default function EditorPanel({className, style}: {className?: string, sty
|
|||
<Editor
|
||||
className="openscad-editor absolute-fill"
|
||||
defaultLanguage="openscad"
|
||||
path={state.params.sourcePath}
|
||||
value={state.params.source}
|
||||
path={state.params.activePath}
|
||||
value={model.source}
|
||||
onChange={s => model.source = s ?? ''}
|
||||
onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render??
|
||||
options={{
|
||||
...openscadEditorOptions,
|
||||
fontSize: 16,
|
||||
lineNumbers: state.view.lineNumbers ? 'on' : 'off',
|
||||
// readOnly: !isFileWritable(state.params.sourcePath)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isMonacoSupported && (
|
||||
<InputTextarea
|
||||
style={{
|
||||
}}
|
||||
className="openscad-editor absolute-fill"
|
||||
value={state.params.source}
|
||||
value={model.source}
|
||||
onChange={s => model.source = s.target.value ?? ''}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -216,8 +192,9 @@ export default function EditorPanel({className, style}: {className?: string, sty
|
|||
overflowY: 'scroll',
|
||||
height: 'calc(min(200px, 30vh))',
|
||||
}}>
|
||||
<pre><code id="logs" style={{
|
||||
}}>{state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
|
||||
{(state.currentRunLogs ?? []).map(([type, text], i) => (
|
||||
<pre key={i}>{text}</pre>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
63
src/components/ExportButton.tsx
Normal file
63
src/components/ExportButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,8 +4,7 @@ import { CSSProperties, useContext } from 'react';
|
|||
import { TreeSelect } from 'primereact/treeselect';
|
||||
import TreeNode from 'primereact/treenode';
|
||||
import { ModelContext, FSContext } from './contexts';
|
||||
// import { isFileWritable } from '../state/model';
|
||||
import { join } from '../fs/filesystem';
|
||||
import { getParentDir, join } from '../fs/filesystem';
|
||||
import { defaultSourcePath } from '../state/initial-state';
|
||||
import { zipArchives } from '../fs/zip-archives';
|
||||
|
||||
|
|
@ -69,7 +68,6 @@ function listFilesAsNodes(fs: FS, path: string, accept?: (path: string) => boole
|
|||
}
|
||||
|
||||
nodes.push({
|
||||
// icon: isDirectory ? 'pi pi-folder' : isFileWritable(path) ? 'pi pi-file' : 'pi pi-lock',
|
||||
icon: isDirectory ? 'pi pi-folder' : path === defaultSourcePath ? 'pi pi-home' : 'pi pi-file',
|
||||
label,
|
||||
data: path,
|
||||
|
|
@ -89,13 +87,28 @@ export default function FilePicker({className, style}: {className?: string, styl
|
|||
|
||||
const fs = useContext(FSContext);
|
||||
|
||||
const fsItems = fs && listFilesAsNodes(fs, '/')
|
||||
const fsItems: TreeNode[] = [];
|
||||
for (const {path} of state.params.sources) {
|
||||
const parent = getParentDir(path);
|
||||
if (parent === '/') {
|
||||
fsItems.push({
|
||||
icon: 'pi pi-home',
|
||||
label: path.split('/').pop(),
|
||||
data: path,
|
||||
key: path,
|
||||
selectable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fs) {
|
||||
fsItems.push(...listFilesAsNodes(fs, '/'));
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
className={className}
|
||||
title='OpenSCAD Playground Files'
|
||||
value={state.params.sourcePath}
|
||||
value={state.params.activePath}
|
||||
resetFilterOnHide={true}
|
||||
filterBy="key"
|
||||
onChange={e => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { CSSProperties, useContext, useRef } from 'react';
|
||||
import { State } from '../state/app-state'
|
||||
import { ModelContext } from './contexts';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import { Button } from 'primereact/button';
|
||||
|
|
@ -9,39 +8,8 @@ import { ProgressBar } from 'primereact/progressbar';
|
|||
import { Badge } from 'primereact/badge';
|
||||
import { Menu } from 'primereact/menu';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
import HelpMenu from './HelpMenu';
|
||||
import { confirmDialog } from 'primereact/confirmdialog';
|
||||
|
||||
function downloadOutput(state: State) {
|
||||
if (!state.output) return;
|
||||
const sourcePathParts = state.params.sourcePath.split('/');
|
||||
const sourceFileName = sourcePathParts.slice(-1)[0];
|
||||
const fileName = [sourceFileName, state.output!.isPreview ? 'preview.stl' : 'render.stl'].join('.');
|
||||
const doDownload = () => {
|
||||
const a = document.createElement('a')
|
||||
a.href = state.output!.stlFileURL
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
};
|
||||
|
||||
if (state.output.isPreview && state.params.source.indexOf('$preview') >= 0) {
|
||||
confirmDialog({
|
||||
message: "This model references the $preview variable but hasn't been rendered (F6), or its rendering is stale. You're about to download the preview result itself, which may not have the intended refinement of the render. Sure you want to proceed?",
|
||||
header: 'Preview vs. Render',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
accept: doDownload,
|
||||
acceptLabel: `Download ${fileName}`,
|
||||
rejectLabel: 'Cancel'
|
||||
// reject: () => {}
|
||||
});
|
||||
} else {
|
||||
doDownload();
|
||||
}
|
||||
|
||||
}
|
||||
import ExportButton from './ExportButton';
|
||||
|
||||
|
||||
export default function Footer({style}: {style?: CSSProperties}) {
|
||||
|
|
@ -49,7 +17,6 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
|||
if (!model) throw new Error('No model');
|
||||
const state = model.state;
|
||||
|
||||
const helpMenu = useRef<Menu>(null);
|
||||
const toast = useRef<Toast>(null);
|
||||
|
||||
|
||||
|
|
@ -76,35 +43,30 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
|||
style={{
|
||||
marginLeft: '5px',
|
||||
marginRight: '5px',
|
||||
visibility: state.rendering || state.previewing || state.checkingSyntax
|
||||
visibility: state.rendering || state.previewing || state.checkingSyntax || state.exporting
|
||||
? 'visible' : 'hidden',
|
||||
height: '6px' }}></ProgressBar>
|
||||
|
||||
<div className="flex flex-row gap-1" style={{
|
||||
// display: 'flex',
|
||||
// flexDirection: 'row',
|
||||
// gap: '10px',
|
||||
// verticalAlign: 'center',
|
||||
alignItems: 'center',
|
||||
margin: '5px',
|
||||
...(style ?? {})
|
||||
}}>
|
||||
<Button onClick={() => model.render({isPreview: false, now: true})}
|
||||
loading={state.rendering}
|
||||
icon="pi pi-refresh"
|
||||
title="Render the model (F6 / Ctrl+Enter). Models can test $preview to enable more detail in renders only."
|
||||
label="Render" />
|
||||
<Button
|
||||
icon="pi pi-bolt"
|
||||
onClick={() => model.render({isPreview: false, now: true})}
|
||||
className="p-button-sm"
|
||||
label="Render"
|
||||
/>
|
||||
|
||||
<ExportButton />
|
||||
|
||||
{(state.lastCheckerRun || state.output) &&
|
||||
<Button type="button"
|
||||
// label={state.view.logs ? "Hide logs" : "Show logs"}
|
||||
label="Logs"
|
||||
severity={maxMarkerSeverity && severityByMarkerSeverity.get(maxMarkerSeverity)}
|
||||
icon="pi pi-align-left"
|
||||
text={!state.view.logs}
|
||||
onClick={() => model.logsVisible = !state.view.logs}
|
||||
// onLabel="Logs" offLabel="Logs"
|
||||
// onIcon="pi pi-align-left"
|
||||
// offIcon="pi pi-align-left"
|
||||
className={maxMarkerSeverity && `p-button-${severityByMarkerSeverity.get(maxMarkerSeverity) ?? 'success'}`}
|
||||
>
|
||||
{getBadge(monaco.MarkerSeverity.Error)}
|
||||
|
|
@ -112,33 +74,15 @@ export default function Footer({style}: {style?: CSSProperties}) {
|
|||
{getBadge(monaco.MarkerSeverity.Info)}
|
||||
</Button>}
|
||||
|
||||
{state.output && (
|
||||
<Button icon='pi pi-download'
|
||||
title={`Download ${state.output.isPreview ? "preview.stl" : "render.stl"} (${state.output.formattedStlFileSize})\nGenerated in ${state.output.formattedElapsedMillis}`}
|
||||
severity="secondary"
|
||||
text
|
||||
// label={state.output.isPreview ? "preview.stl" : "render.stl"}
|
||||
iconPos='right'
|
||||
onClick={() => downloadOutput(state)} />
|
||||
)}
|
||||
<div style={{flex: 1}}></div>
|
||||
|
||||
{/* {state.output &&
|
||||
<span style={{color: 'blue'}}>
|
||||
<i className="pi pi-stopwatch" title={state.error}></i>
|
||||
<span style={{margin: '5px'}}>{state.output.formattedElapsedMillis}</span>
|
||||
</span>} */}
|
||||
|
||||
{/* {state.previewing && 'previewing... '}
|
||||
{state.rendering && 'rendering... '}
|
||||
{state.checkingSyntax && 'checking syntax... '} */}
|
||||
|
||||
{/* <span style={{flex: 1}}></span> */}
|
||||
|
||||
<span style={{flex: 1}}></span>
|
||||
<HelpMenu style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '4px',
|
||||
}} />
|
||||
|
||||
<Toast ref={toast} />
|
||||
|
||||
<HelpMenu />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { CSSProperties, useRef } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { MenuItem } from 'primereact/menuitem';
|
||||
import { Menu } from 'primereact/menu';
|
||||
|
||||
export default function HelpMenu() {
|
||||
export default function HelpMenu({className, style}: {className?: string, style?: CSSProperties}) {
|
||||
const menuRef = useRef<Menu>(null);
|
||||
return (
|
||||
<>
|
||||
<Menu model={[
|
||||
<Menu style={style} className={className} model={[
|
||||
{
|
||||
label: "openscad-playground",
|
||||
icon: 'pi pi-github',
|
||||
url: 'https://github.com/openscad/openscad-playground',
|
||||
url: 'https://github.com/ochafik/openscad-playground/tree/color-assimp2',
|
||||
target: '_blank'
|
||||
},
|
||||
{
|
||||
label: 'LICENSES',
|
||||
icon: 'pi pi-info-circle',
|
||||
url: 'https://github.com/openscad/openscad-playground/blob/main/LICENSE.md',
|
||||
url: 'https://github.com/ochafik/openscad-playground/blob/color-assimp2/LICENSE.md',
|
||||
target: '_blank'
|
||||
},
|
||||
{
|
||||
|
|
@ -48,7 +48,12 @@ export default function HelpMenu() {
|
|||
target: '_blank'
|
||||
},
|
||||
] as MenuItem[]} popup ref={menuRef} />
|
||||
<Button title="Help & Licenses" rounded icon="pi pi-question-circle" onClick={(e) => menuRef.current && menuRef.current.toggle(e)} />
|
||||
|
||||
<Button title="Help & Licenses"
|
||||
rounded
|
||||
text
|
||||
icon="pi pi-question-circle"
|
||||
onClick={(e) => menuRef.current && menuRef.current.toggle(e)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import { SingleLayoutComponentId } from '../state/app-state'
|
|||
import { TabMenu } from 'primereact/tabmenu';
|
||||
import { ToggleButton } from 'primereact/togglebutton';
|
||||
import { ModelContext, FSContext } from './contexts';
|
||||
import SettingsMenu from './SettingsMenu';
|
||||
|
||||
export default function PanelSwitcher() {
|
||||
const model = useContext(ModelContext);
|
||||
|
|
@ -36,18 +35,13 @@ export default function PanelSwitcher() {
|
|||
margin: '5px'
|
||||
}}>
|
||||
{multiTargets.map(({icon, label, id}) =>
|
||||
// <Button
|
||||
<ToggleButton
|
||||
key={id}
|
||||
// raised={(state.view.layout as any)[id]}
|
||||
checked={(state.view.layout as any)[id]}
|
||||
// label={label}
|
||||
onLabel={label}
|
||||
offLabel={label}
|
||||
onIcon={icon}
|
||||
offIcon={icon}
|
||||
// icon={icon}
|
||||
// onClick={() => model.changeMultiVisibility(id, !(state.view.layout as any)[id])}
|
||||
onChange={e => model.changeMultiVisibility(id, e.value)}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -57,17 +51,11 @@ export default function PanelSwitcher() {
|
|||
activeIndex={singleTargets.map(t => t.id).indexOf(state.view.layout.focus)}
|
||||
style={{
|
||||
flex: 1,
|
||||
// justifyContent: 'center'
|
||||
}}
|
||||
model={singleTargets.map(({icon, label, id}) =>
|
||||
({icon, label/*, disabled: id === 'customizer' && state?.parameterSet == null*/, command: () => model.changeSingleVisibility(id)}))} />
|
||||
({icon, label, command: () => model.changeSingleVisibility(id)}))} />
|
||||
</>
|
||||
}
|
||||
<SettingsMenu style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: '4px',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,25 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { CSSProperties, forwardRef, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { CSSProperties, useContext, useRef } from 'react';
|
||||
import { ModelContext } from './contexts';
|
||||
import { StlViewer} from "react-stl-viewer";
|
||||
import { ColorPicker } from 'primereact/colorpicker';
|
||||
import { defaultModelColor } from '../state/initial-state';
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"model-viewer": any;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function ViewerPanel({className, style}: {className?: string, style?: CSSProperties}) {
|
||||
const model = useContext(ModelContext);
|
||||
if (!model) throw new Error('No model');
|
||||
|
||||
const state = model.state;
|
||||
const modelRef = useRef();
|
||||
|
||||
return (
|
||||
<div className={className}
|
||||
|
|
@ -18,28 +27,41 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
|||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
...(style ?? {})
|
||||
}}>
|
||||
{state.output?.stlFileURL &&
|
||||
<StlViewer
|
||||
className='absolute-fill'
|
||||
{(state.output?.displayFileURL || state.output?.outFile && state.output.outFile.name.endsWith('.glb') && state.output?.outFileURL) && (
|
||||
<model-viewer
|
||||
src={state.output?.displayFileURL ?? state.output?.outFileURL ?? ''}
|
||||
style={{
|
||||
zIndex: 0,
|
||||
// flex: 1,
|
||||
// width: '100%'
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
environment-image="./skybox-lights.jpg"
|
||||
max-camera-orbit="auto 180deg auto"
|
||||
min-camera-orbit="auto 0deg auto"
|
||||
camera-controls
|
||||
ar
|
||||
ref={(ref: any) => {
|
||||
modelRef.current = ref;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{state.output?.outFile && state.output.outFile.name.endsWith('.stl') && state.output?.outFileURL && (
|
||||
<>
|
||||
<StlViewer
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
// ref={stlModelRef}
|
||||
showAxes={state.view.showAxes}
|
||||
orbitControls
|
||||
shadows={state.view.showShadows}
|
||||
modelProps={{
|
||||
color: model.state.view.color,
|
||||
}}
|
||||
url={state.output?.stlFileURL ?? ''}
|
||||
/>}
|
||||
|
||||
url={state.output?.outFileURL ?? ''}
|
||||
/>
|
||||
<ColorPicker
|
||||
className={`opacity-animated ${!model.isComponentFullyVisible('viewer') ? 'opacity-0' : ''}`}
|
||||
value={model.state.view.color}
|
||||
|
|
@ -47,11 +69,11 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
|||
position: 'absolute',
|
||||
top: '12px',
|
||||
left: '12px',
|
||||
// pointerEvents: model.isComponentFullyVisible('viewer') ? undefined : 'none',
|
||||
}}
|
||||
onChange={(e) => model.mutate(s => s.view.color = `#${e.value ?? defaultModelColor}`)} />
|
||||
|
||||
|
||||
onChange={(e) => model.mutate(s => s.view.color = `#${e.value ?? defaultModelColor}`)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
2
src/fs/BrowserFS.d.ts
vendored
2
src/fs/BrowserFS.d.ts
vendored
|
|
@ -1,7 +1,5 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
// import { FS } from "./filesystem";
|
||||
|
||||
declare interface FS {
|
||||
writeFile(path: string, content: string): void;
|
||||
readdir(path: string, cb: (err: any, files: string[]) => void): void;
|
||||
|
|
|
|||
|
|
@ -45,13 +45,11 @@ export async function getBrowserFSLibrariesMounts(archiveNames: string[]) {
|
|||
|
||||
export async function symlinkLibraries(archiveNames: string[], fs: FS, prefix='/libraries', cwd='/tmp') {
|
||||
const createSymlink = async (target: string, source: string) => {
|
||||
// console.log('symlink', target, source);
|
||||
try {
|
||||
await fs.symlink(target, source);
|
||||
} catch (e) {
|
||||
console.error(`symlink(${target}, ${source}) failed: `, e);
|
||||
}
|
||||
// await symlink(target, source);
|
||||
};
|
||||
|
||||
await Promise.all(archiveNames.map(n => (async () => {
|
||||
|
|
@ -111,8 +109,6 @@ export async function createEditorFS({prefix, allowPersistence}: {prefix: string
|
|||
});
|
||||
|
||||
var fs = BrowserFS.BFSRequire('fs');
|
||||
// const symlink = (target, source) => new Promise((res, rej) => fs.symlink(target, source, (err) => err ? rej(err) : res()));
|
||||
|
||||
// await setupLibraries(archiveNames, symlink, '/libraries', workingDir);
|
||||
return fs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ if (process.env.NODE_ENV !== 'production') {
|
|||
declare var BrowserFS: BrowserFSInterface
|
||||
|
||||
|
||||
|
||||
window.addEventListener('load', async () => {
|
||||
//*
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('./sw.js');
|
||||
|
|
@ -56,6 +56,7 @@ window.addEventListener('load', async () => {
|
|||
console.log('ServiceWorker registration failed: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
//*/
|
||||
|
||||
registerCustomAppHeightCSSProperty();
|
||||
|
|
@ -88,7 +89,7 @@ window.addEventListener('load', async () => {
|
|||
};
|
||||
}
|
||||
|
||||
const initialState = createInitialState(fs, persistedState);
|
||||
const initialState = createInitialState(persistedState);
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
|||
|
||||
export default {
|
||||
lineNumbers: 'on',
|
||||
// automaticLayout: true,
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 12,
|
||||
language: 'openscad',
|
||||
|
|
@ -12,37 +11,10 @@ export default {
|
|||
wordWrap: 'on',
|
||||
wrappingStrategy: 'advanced',
|
||||
suggest: {
|
||||
// snippetsPreventQuickSuggestions: false,
|
||||
localityBonus: true,
|
||||
showStatusBar: true,
|
||||
preview: true,
|
||||
},
|
||||
codeLens: true,
|
||||
// language: 'javascript',
|
||||
wordBasedSuggestions: false,
|
||||
} as monaco.editor.IStandaloneEditorConstructionOptions;
|
||||
|
||||
// monaco.editor.IModelContentChangedEvent
|
||||
|
||||
// function SCADEditor({input, onInputChanged, ...props}: {input: string, onInputChanged: (value?: string) => void, height: string}) {
|
||||
|
||||
// // let editor: monaco.editor.IStandaloneCodeEditor;
|
||||
|
||||
// // function editorDidMount(e) {
|
||||
// // editor = e;
|
||||
// // console.log('editorDidMount', monaco.languages.getLanguages(), editor);
|
||||
// // // editor.
|
||||
// // editor.setModel(monaco.editor.createModel('sphere(123);', 'openscad'));
|
||||
// // // editor.trigger('anything', 'editor.action.triggerSuggest', {});
|
||||
// // }
|
||||
|
||||
// return (
|
||||
// <Editor {...props}
|
||||
// className="openscad-editor"
|
||||
// defaultLanguage="openscad"
|
||||
// value={n}
|
||||
// onChange={setValue}
|
||||
// options={options} />
|
||||
// // onMount={editorDidMount} />
|
||||
// );
|
||||
// }
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const builtInModuleNames = [
|
|||
'render', 'resize', 'rotate', 'round', 'scale',
|
||||
'sphere', 'square', 'surface', 'translate',
|
||||
'union', 'use', 'value', 'version',
|
||||
// 'center', 'width', 'height',
|
||||
];
|
||||
const builtInVarNames = [
|
||||
'false', 'true', 'PI', 'undef', '$children',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import loader from '@monaco-editor/loader';
|
|||
import { ZipArchives } from '../fs/zip-archives';
|
||||
import { buildOpenSCADCompletionItemProvider } from './openscad-completions';
|
||||
import openscadLanguage from './openscad-language';
|
||||
// import jsLanguage from 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution'
|
||||
|
||||
// https://microsoft.github.io/monaco-editor/playground.html#extending-language-services-custom-languages
|
||||
export async function registerOpenSCADLanguage(fs: any, workingDir: string, zipArchives: ZipArchives) {
|
||||
|
|
@ -16,7 +15,6 @@ export async function registerOpenSCADLanguage(fs: any, workingDir: string, zipA
|
|||
mimetypes: ["text/openscad"],
|
||||
});
|
||||
|
||||
//const [inheritLanguage] = monaco.languages.getLanguages().filter(l => l.id === 'javascript');
|
||||
const { conf, language } = openscadLanguage;
|
||||
monaco.languages.setLanguageConfiguration('openscad', conf);
|
||||
monaco.languages.setMonarchTokensProvider('openscad', language);
|
||||
|
|
|
|||
153
src/multimaterial/off2glb.ts
Normal file
153
src/multimaterial/off2glb.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -1,27 +1,37 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import { getParentDir } from '../fs/filesystem';
|
||||
import { spawnOpenSCAD } from "./openscad-runner";
|
||||
import { ProcessStreams, spawnOpenSCAD } from "./openscad-runner";
|
||||
import { processMergedOutputs } from "./output-parser";
|
||||
import { AbortablePromise, turnIntoDelayableExecution } from '../utils';
|
||||
import { Source } from '../state/app-state';
|
||||
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from '../state/formats';
|
||||
import { ParameterSet } from '../state/customizer-types';
|
||||
|
||||
const syntaxDelay = 300;
|
||||
|
||||
type SyntaxCheckArgs = {
|
||||
activePath: string,
|
||||
sources: Source[],
|
||||
}
|
||||
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[], parameterSet?: ParameterSet};
|
||||
export const checkSyntax =
|
||||
turnIntoDelayableExecution(syntaxDelay, (source: string, sourcePath: string) => {
|
||||
// const timestamp = Date.now();
|
||||
turnIntoDelayableExecution(syntaxDelay, (sargs: SyntaxCheckArgs) => {
|
||||
const {
|
||||
activePath,
|
||||
sources,
|
||||
} = sargs;
|
||||
|
||||
source = '$preview=true;\n' + source;
|
||||
const content = '$preview=true;\n' + sources[0].content;
|
||||
|
||||
const outFile = 'out.json';
|
||||
const job = spawnOpenSCAD({
|
||||
inputs: [[sourcePath, source + '\n']],
|
||||
args: [sourcePath, "-o", outFile, "--export-format=param"],
|
||||
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home'
|
||||
mountArchives: true,
|
||||
inputs: sources,
|
||||
args: [activePath, "-o", outFile, "--export-format=param"],
|
||||
outputPaths: [outFile],
|
||||
}, (streams) => {
|
||||
console.log(JSON.stringify(streams));
|
||||
});
|
||||
|
||||
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
|
||||
|
|
@ -46,7 +56,7 @@ export const checkSyntax =
|
|||
|
||||
res({
|
||||
...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
|
||||
sourcePath,
|
||||
sourcePath: sources[0].path,
|
||||
skipLines: 1,
|
||||
}}),
|
||||
parameterSet,
|
||||
|
|
@ -61,15 +71,22 @@ export const checkSyntax =
|
|||
});
|
||||
|
||||
var renderDelay = 1000;
|
||||
export type RenderOutput = {stlFile: File, logText: string, markers: monaco.editor.IMarkerData[], elapsedMillis: number}
|
||||
export type RenderOutput = {
|
||||
outFile: File,
|
||||
logText: string,
|
||||
markers: monaco.editor.IMarkerData[],
|
||||
elapsedMillis: number}
|
||||
|
||||
export type RenderArgs = {
|
||||
source: string,
|
||||
sourcePath: string,
|
||||
scadPath: string,
|
||||
sources: Source[],
|
||||
vars?: {[name: string]: any},
|
||||
features?: string[],
|
||||
extraArgs?: string[],
|
||||
isPreview: boolean
|
||||
isPreview: boolean,
|
||||
mountArchives: boolean,
|
||||
renderFormat: keyof typeof VALID_EXPORT_FORMATS_2D | keyof typeof VALID_EXPORT_FORMATS_3D | keyof typeof VALID_RENDER_FORMATS,
|
||||
streamsCallback: (ps: ProcessStreams) => void,
|
||||
}
|
||||
|
||||
function formatValue(any: any): string {
|
||||
|
|
@ -82,40 +99,63 @@ function formatValue(any: any): string {
|
|||
}
|
||||
}
|
||||
export const render =
|
||||
turnIntoDelayableExecution(renderDelay, ({sourcePath, source, isPreview, vars, features, extraArgs}: RenderArgs) => {
|
||||
turnIntoDelayableExecution(renderDelay, (renderArgs: RenderArgs) => {
|
||||
const {
|
||||
scadPath,
|
||||
sources,
|
||||
isPreview,
|
||||
mountArchives,
|
||||
vars,
|
||||
features: additionalFeatures,
|
||||
extraArgs,
|
||||
renderFormat,
|
||||
streamsCallback,
|
||||
} = renderArgs;
|
||||
|
||||
const prefixLines: string[] = [];
|
||||
let features: string[]
|
||||
if (isPreview) {
|
||||
prefixLines.push('$preview=true;');
|
||||
features = ['render-modifiers', ...(additionalFeatures ?? [])];
|
||||
} else {
|
||||
features = (additionalFeatures ?? []).filter(f => f != 'render-modifiers');
|
||||
}
|
||||
source = [...prefixLines, source].join('\n');
|
||||
if (!scadPath.endsWith('.scad')) throw new Error('First source must be a .scad file, got ' + sources[0].path + ' instead');
|
||||
|
||||
const source = sources.filter(s => s.path === scadPath)[0];
|
||||
if (!source) throw new Error('Active path not found in sources!');
|
||||
|
||||
if (source.content == null) throw new Error('Source content is null!');
|
||||
const content = [...prefixLines, source.content].join('\n');
|
||||
|
||||
const actualRenderFormat = renderFormat == 'glb' ? 'off' : renderFormat;
|
||||
const outFile = 'out.' + actualRenderFormat;
|
||||
const args = [
|
||||
sourcePath,
|
||||
"-o", "out.stl",
|
||||
"--export-format=binstl",
|
||||
scadPath,
|
||||
"-o", outFile,
|
||||
"--backend=manifold",
|
||||
"--export-format=" + (actualRenderFormat == 'stl' ? 'binstl' : actualRenderFormat),
|
||||
...(Object.entries(vars ?? {}).flatMap(([k, v]) => [`-D${k}=${formatValue(v)}`])),
|
||||
...(features ?? []).map(f => `--enable=${f}`),
|
||||
...(extraArgs ?? [])
|
||||
]
|
||||
|
||||
const job = spawnOpenSCAD({
|
||||
// wasmMemory,
|
||||
inputs: [[sourcePath, source]],
|
||||
mountArchives: mountArchives,
|
||||
inputs: sources.map(s => s.path === scadPath ? {path: s.path, content} : s),
|
||||
args,
|
||||
outputPaths: ['out.stl'],
|
||||
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home'
|
||||
});
|
||||
outputPaths: [outFile],
|
||||
}, streamsCallback);
|
||||
|
||||
return AbortablePromise<RenderOutput>((resolve, reject) => {
|
||||
(async () => {
|
||||
try {
|
||||
const result = await job;
|
||||
console.log(result);
|
||||
// console.log(result);
|
||||
|
||||
const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
|
||||
shiftSourceLines: {
|
||||
sourcePath: sourcePath,
|
||||
sourcePath: source.path,
|
||||
skipLines: prefixLines.length
|
||||
}
|
||||
});
|
||||
|
|
@ -131,13 +171,13 @@ export const render =
|
|||
}
|
||||
const [filePath, content] = output;
|
||||
const filePathFragments = filePath.split('/');
|
||||
const fileName = filePathFragments[filePathFragments.length - 1];
|
||||
let fileName = filePathFragments[filePathFragments.length - 1];
|
||||
|
||||
// TODO: have the runner accept and return files.
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
// console.log(new TextDecoder().decode(content));
|
||||
const stlFile = new File([blob], fileName);
|
||||
resolve({stlFile, logText, markers, elapsedMillis: result.elapsedMillis});
|
||||
const type = filePath.endsWith('.svg') ? 'image/svg+xml' : 'application/octet-stream';
|
||||
let blob = new Blob([content]);
|
||||
let outFile = new File([blob], fileName, {type});
|
||||
resolve({outFile, logText, markers, elapsedMillis: result.elapsedMillis});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
reject(e);
|
||||
|
|
@ -147,3 +187,4 @@ export const render =
|
|||
return () => job.kill()
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { MergedOutputs } from "./openscad-worker";
|
||||
import { AbortablePromise } from "../utils";
|
||||
import { Source } from "../state/app-state";
|
||||
|
||||
export function createWasmMemory({maximumMegabytes, maximumBytes}: {maximumMegabytes: number, maximumBytes: number}) {
|
||||
const pageSize = 64 * 1024; // 64KB
|
||||
|
|
@ -18,20 +19,22 @@ export function createWasmMemory({maximumMegabytes, maximumBytes}: {maximumMegab
|
|||
// Output is {outputs: [name, content][], mergedOutputs: [{(stderr|stdout|error)?: string}], exitCode: number}
|
||||
export type OpenSCADInvocation = {
|
||||
wasmMemory?: WebAssembly.Memory,
|
||||
// workingDir: string,
|
||||
inputs?: [string, string][],
|
||||
mountArchives: boolean,
|
||||
inputs?: Source[],
|
||||
args: string[],
|
||||
outputPaths?: string[],
|
||||
}
|
||||
export type OpenSCADInvocationResults = {
|
||||
exitCode: number,
|
||||
exitCode?: number,
|
||||
error?: string,
|
||||
outputs?: [string, string][],
|
||||
mergedOutputs: MergedOutputs,
|
||||
elapsedMillis: number,
|
||||
}
|
||||
};
|
||||
export type ProcessStreams = {stderr: string} | {stdout: string}
|
||||
export type OpenSCADInvocationCallback = {result: OpenSCADInvocationResults} | ProcessStreams;
|
||||
|
||||
export function spawnOpenSCAD(invocation: OpenSCADInvocation): AbortablePromise<OpenSCADInvocationResults> {
|
||||
export function spawnOpenSCAD(invocation: OpenSCADInvocation, streamsCallback: (ps: ProcessStreams) => void): AbortablePromise<OpenSCADInvocationResults> {
|
||||
var worker: Worker | null;
|
||||
var rejection: (err: any) => void;
|
||||
|
||||
|
|
@ -44,21 +47,19 @@ export function spawnOpenSCAD(invocation: OpenSCADInvocation): AbortablePromise<
|
|||
}
|
||||
|
||||
return AbortablePromise<OpenSCADInvocationResults>((resolve, reject) => {
|
||||
worker = new Worker('./openscad-worker.js');//, {type: "module"})
|
||||
// if (navigator.userAgent.indexOf(' Chrome/') < 0) {
|
||||
// worker = new Worker('./openscad-worker-firefox.js'); // {'type': 'module'}
|
||||
// } else {
|
||||
// worker = new Worker('./openscad-worker.js', {'type': 'module'});
|
||||
// }
|
||||
worker = new Worker('./openscad-worker.js');
|
||||
rejection = reject;
|
||||
worker.onmessage = (e: {data: OpenSCADInvocationResults}) => {
|
||||
resolve(e.data);
|
||||
worker.onmessage = (e: {data: OpenSCADInvocationCallback}) => {
|
||||
if ('result' in e.data) {
|
||||
resolve(e.data.result);
|
||||
terminate();
|
||||
} else {
|
||||
streamsCallback(e.data);
|
||||
}
|
||||
}
|
||||
worker.postMessage(invocation)
|
||||
|
||||
return () => {
|
||||
// rejection({error: 'Terminated'});
|
||||
terminate();
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,36 +2,55 @@
|
|||
|
||||
import OpenSCAD from "../wasm/openscad.js";
|
||||
|
||||
import { createEditorFS, symlinkLibraries } from "../fs/filesystem";
|
||||
import { OpenSCADInvocation, OpenSCADInvocationResults } from "./openscad-runner";
|
||||
import { createEditorFS, getParentDir, symlinkLibraries } from "../fs/filesystem";
|
||||
import { OpenSCADInvocation, OpenSCADInvocationCallback, OpenSCADInvocationResults } from "./openscad-runner";
|
||||
import { deployedArchiveNames, zipArchives } from "../fs/zip-archives";
|
||||
import { fetchSource } from "../utils.js";
|
||||
import { stderr } from "process";
|
||||
declare var BrowserFS: BrowserFSInterface
|
||||
|
||||
importScripts("browserfs.min.js");
|
||||
// importScripts("https://cdnjs.cloudflare.com/ajax/libs/BrowserFS/2.0.0/browserfs.min.js");
|
||||
|
||||
export type MergedOutputs = {stdout?: string, stderr?: string, error?: string}[];
|
||||
|
||||
function callback(payload: OpenSCADInvocationCallback) {
|
||||
postMessage(payload);
|
||||
}
|
||||
|
||||
addEventListener('message', async (e) => {
|
||||
|
||||
const { inputs, args, outputPaths, wasmMemory } = e.data as OpenSCADInvocation;
|
||||
const {
|
||||
mountArchives,
|
||||
inputs,
|
||||
args,
|
||||
outputPaths,
|
||||
wasmMemory,
|
||||
} = e.data as OpenSCADInvocation;
|
||||
|
||||
const mergedOutputs: MergedOutputs = [];
|
||||
let instance: any;
|
||||
const start = performance.now();
|
||||
try {
|
||||
const instance = await OpenSCAD({
|
||||
instance = await OpenSCAD({
|
||||
wasmMemory,
|
||||
buffer: wasmMemory && wasmMemory.buffer,
|
||||
noInitialRun: true,
|
||||
'print': (text: string) => {
|
||||
console.debug('stdout: ' + text);
|
||||
callback({stdout: text})
|
||||
mergedOutputs.push({ stdout: text })
|
||||
},
|
||||
'printErr': (text: string) => {
|
||||
console.debug('stderr: ' + text);
|
||||
callback({stderr: text})
|
||||
mergedOutputs.push({ stderr: text })
|
||||
},
|
||||
'ENV': {
|
||||
'OPENSCADPATH': '/libraries',
|
||||
},
|
||||
});
|
||||
|
||||
if (mountArchives) {
|
||||
// This will mount lots of libraries' ZIP archives under /libraries/<name> -> <name>.zip
|
||||
await createEditorFS({prefix: '', allowPersistence: false});
|
||||
|
||||
|
|
@ -50,26 +69,50 @@ addEventListener('message', async (e) => {
|
|||
instance.FS.mount(BFS, {root: '/'}, '/libraries');
|
||||
|
||||
await symlinkLibraries(deployedArchiveNames, instance.FS, '/libraries', "/");
|
||||
}
|
||||
|
||||
// Fonts are seemingly resolved from $(cwd)/fonts
|
||||
instance.FS.chdir("/");
|
||||
|
||||
// const walkFolder = (path: string, indent = '') => {
|
||||
// console.log("Walking " + path);
|
||||
// instance.FS.readdir(path)?.forEach((f: string) => {
|
||||
// if (f.startsWith('.')) {
|
||||
// return;
|
||||
// }
|
||||
// const ii = indent + ' ';
|
||||
// const p = `${path != '/' ? path + '/' : '/'}${f}`;
|
||||
// console.log(`${ii}${p}`);
|
||||
// walkFolder(p, ii);
|
||||
// });
|
||||
// };
|
||||
// walkFolder('/libraries');
|
||||
|
||||
if (inputs) {
|
||||
for (const [path, content] of inputs) {
|
||||
for (const source of inputs) {
|
||||
try {
|
||||
// const parent = getParentDir(path);
|
||||
instance.FS.writeFile(path, content);
|
||||
// fs.writeFile(path, content);
|
||||
instance.FS.writeFile(source.path, await fetchSource(source));
|
||||
} catch (e) {
|
||||
console.error(`Error while trying to write ${path}`, e);
|
||||
console.trace(e);
|
||||
throw new Error(`Error while trying to write ${source.path}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Invoking OpenSCAD with: ', args)
|
||||
const start = performance.now();
|
||||
const exitCode = instance.callMain(args);
|
||||
let exitCode;
|
||||
try {
|
||||
exitCode = instance.callMain(args);
|
||||
} catch(e){
|
||||
if(typeof e === "number" && instance.formatException){
|
||||
// The number was a raw C++ exception
|
||||
// See https://github.com/emscripten-core/emscripten/pull/16343
|
||||
e = instance.formatException(e);
|
||||
}
|
||||
throw new Error(`OpenSCAD invocation failed: ${e}`);
|
||||
}
|
||||
const end = performance.now();
|
||||
const elapsedMillis = end - start;
|
||||
|
||||
const outputs: [string, string][] = [];
|
||||
for (const path of (outputPaths ?? [])) {
|
||||
|
|
@ -77,26 +120,34 @@ addEventListener('message', async (e) => {
|
|||
const content = instance.FS.readFile(path);
|
||||
outputs.push([path, content]);
|
||||
} catch (e) {
|
||||
console.trace(`Failed to read output file ${path}`, e);
|
||||
console.trace(e);
|
||||
throw new Error(`Failed to read output file ${path}: ${e}`);
|
||||
}
|
||||
}
|
||||
const result: OpenSCADInvocationResults = {
|
||||
outputs,
|
||||
mergedOutputs,
|
||||
exitCode,
|
||||
elapsedMillis: end - start
|
||||
elapsedMillis,
|
||||
}
|
||||
|
||||
console.debug(result);
|
||||
|
||||
postMessage(result);
|
||||
callback({result});
|
||||
} catch (e) {
|
||||
console.trace(e);//, e instanceof Error ? e.stack : '');
|
||||
const end = performance.now();
|
||||
const elapsedMillis = end - start;
|
||||
|
||||
console.trace(e);
|
||||
const error = `${e}`;
|
||||
mergedOutputs.push({ error });
|
||||
postMessage({
|
||||
callback({
|
||||
result: {
|
||||
exitCode: undefined,
|
||||
error,
|
||||
mergedOutputs,
|
||||
} as OpenSCADInvocationResults);
|
||||
elapsedMillis,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type MergedOutputsOptions = {
|
|||
skipLines: number,
|
||||
}
|
||||
}
|
||||
|
||||
export const processMergedOutputs = (outputs: MergedOutputs, opts: MergedOutputsOptions) => ({
|
||||
logText: joinMergedOutputs(outputs, opts),
|
||||
markers: parseMergedOutputs(outputs, opts)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export interface PlaygroundState {
|
|||
fileExplorerPath?: string,
|
||||
scadInputPath?: string,
|
||||
outputLogs?: {
|
||||
// sourceLastModified: number,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -2,20 +2,41 @@
|
|||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||||
import { ParameterSet } from './customizer-types';
|
||||
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from './formats';
|
||||
|
||||
export type MultiLayoutComponentId = 'editor' | 'viewer' | 'customizer';
|
||||
export type SingleLayoutComponentId = MultiLayoutComponentId;
|
||||
|
||||
export type Source = {
|
||||
// If path ends w/ /, it's a directory, and URL should contain a ZIP file that can be mounted
|
||||
path: string,
|
||||
url?: string,
|
||||
content?: string,
|
||||
};
|
||||
|
||||
export interface FileOutput {
|
||||
outFile: File,
|
||||
outFileURL: string,
|
||||
displayFile?: File,
|
||||
displayFileURL?: string,
|
||||
elapsedMillis: number,
|
||||
formattedElapsedMillis: string,
|
||||
formattedOutFileSize: string,
|
||||
}
|
||||
|
||||
export interface State {
|
||||
params: {
|
||||
sourcePath: string,
|
||||
source: string,
|
||||
activePath: string,
|
||||
sources: Source[],
|
||||
vars?: {[name: string]: any},
|
||||
features: string[],
|
||||
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D,
|
||||
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D,
|
||||
},
|
||||
|
||||
view: {
|
||||
logs?: boolean,
|
||||
extruderPicker?: boolean,
|
||||
layout: {
|
||||
mode: 'single',
|
||||
focus: SingleLayoutComponentId,
|
||||
|
|
@ -31,28 +52,24 @@ export interface State {
|
|||
lineNumbers?: boolean,
|
||||
}
|
||||
|
||||
currentRunLogs?: ['stderr'|'stdout', string][],
|
||||
|
||||
lastCheckerRun?: {
|
||||
logText: string,
|
||||
markers: monaco.editor.IMarkerData[]
|
||||
markers: monaco.editor.IMarkerData[],
|
||||
}
|
||||
rendering?: boolean,
|
||||
previewing?: boolean,
|
||||
exporting?: boolean,
|
||||
checkingSyntax?: boolean,
|
||||
|
||||
parameterSet?: ParameterSet,
|
||||
error?: string,
|
||||
output?: {
|
||||
is2D?: boolean,
|
||||
output?: FileOutput & {
|
||||
isPreview: boolean,
|
||||
stlFile: File,
|
||||
stlFileURL: string,
|
||||
elapsedMillis: number,
|
||||
formattedElapsedMillis: string,
|
||||
formattedStlFileSize: string,
|
||||
// path: string,
|
||||
// timestamp: number,
|
||||
// sizeBytes: number,
|
||||
// formattedSize: string,
|
||||
},
|
||||
export?: FileOutput,
|
||||
};
|
||||
|
||||
export interface StatePersister {
|
||||
|
|
|
|||
|
|
@ -17,29 +17,112 @@ export default `/*
|
|||
https://github.com/openscad/openscad-playground/issues/new
|
||||
*/
|
||||
|
||||
// Click on Render or hit F6 to do a fine-grained rendering.
|
||||
$fn=$preview ? 20 : 100;
|
||||
title = "OpenSCAD";
|
||||
|
||||
translate([-24,0,0]) {
|
||||
union() {
|
||||
cube(15, center=true);
|
||||
sphere(10);
|
||||
}
|
||||
}
|
||||
|
||||
intersection() {
|
||||
cube(15, center=true);
|
||||
sphere(10);
|
||||
}
|
||||
|
||||
translate([24,0,0]) {
|
||||
difference() {
|
||||
cube(15, center=true);
|
||||
sphere(10);
|
||||
}
|
||||
}
|
||||
|
||||
translate([0, -30, -12])
|
||||
color("gray")
|
||||
translate([0, debug ? -60 : -20, 0])
|
||||
linear_extrude(1)
|
||||
text("OpenSCAD Playground", halign="center", valign="center");
|
||||
text(title,
|
||||
halign="center",
|
||||
valign="center");
|
||||
|
||||
// You can find the original for the following example in the file explorer above,
|
||||
// under openscad / examples / Basic / CSG-modules.scad
|
||||
|
||||
// CSG-modules.scad - Basic usage of modules, if, color, $fs/$fa
|
||||
|
||||
// Change this to false to remove the helper geometry
|
||||
debug = true;
|
||||
|
||||
// Global resolution
|
||||
$fs=$preview ? 1 : 0.1; // Don't generate smaller facets than 0.1 mm
|
||||
$fa=$preview ? 15 : 5; // Don't generate larger angles than 5 degrees
|
||||
|
||||
rotate([-90, 0, 0]) {
|
||||
// Main geometry
|
||||
difference() {
|
||||
intersection() {
|
||||
body();
|
||||
intersector();
|
||||
}
|
||||
holes();
|
||||
}
|
||||
|
||||
// Helpers
|
||||
if (debug) helpers();
|
||||
}
|
||||
|
||||
// Core geometric primitives.
|
||||
// These can be modified to create variations of the final object
|
||||
|
||||
module body() {
|
||||
color("Blue") sphere(10);
|
||||
}
|
||||
|
||||
module intersector() {
|
||||
color("Red") cube(15, center=true);
|
||||
}
|
||||
|
||||
module holeObject() {
|
||||
color("Lime") cylinder(h=20, r=5, center=true);
|
||||
}
|
||||
|
||||
// Various modules for visualizing intermediate components
|
||||
|
||||
module intersected() {
|
||||
intersection() {
|
||||
body();
|
||||
intersector();
|
||||
}
|
||||
}
|
||||
|
||||
module holeA() rotate([0,90,0]) holeObject();
|
||||
module holeB() rotate([90,0,0]) holeObject();
|
||||
module holeC() holeObject();
|
||||
|
||||
module holes() {
|
||||
union() {
|
||||
holeA();
|
||||
holeB();
|
||||
holeC();
|
||||
}
|
||||
}
|
||||
|
||||
module helpers() {
|
||||
// Inner module since it's only needed inside helpers
|
||||
module line() color("Black") cylinder(r=1, h=10, center=true);
|
||||
|
||||
scale(0.5) {
|
||||
translate([-30,0,-40]) {
|
||||
intersected();
|
||||
translate([-15,0,-35]) body();
|
||||
translate([15,0,-35]) intersector();
|
||||
translate([-7.5,0,-17.5]) rotate([0,30,0]) line();
|
||||
translate([7.5,0,-17.5]) rotate([0,-30,0]) line();
|
||||
}
|
||||
translate([30,0,-40]) {
|
||||
holes();
|
||||
translate([-10,0,-35]) holeA();
|
||||
translate([10,0,-35]) holeB();
|
||||
translate([30,0,-35]) holeC();
|
||||
translate([5,0,-17.5]) rotate([0,-20,0]) line();
|
||||
translate([-5,0,-17.5]) rotate([0,30,0]) line();
|
||||
translate([15,0,-17.5]) rotate([0,-45,0]) line();
|
||||
}
|
||||
translate([-20,0,-22.5]) rotate([0,45,0]) line();
|
||||
translate([20,0,-22.5]) rotate([0,-45,0]) line();
|
||||
}
|
||||
}
|
||||
|
||||
echo(version=version());
|
||||
// Written by Marius Kintel <marius@kintel.net>
|
||||
//
|
||||
// To the extent possible under law, the author(s) have dedicated all
|
||||
// copyright and related and neighboring rights to this software to the
|
||||
// public domain worldwide. This software is distributed without any
|
||||
// warranty.
|
||||
//
|
||||
// You should have received a copy of the CC0 Public Domain
|
||||
// Dedication along with this software.
|
||||
// If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
|
||||
`
|
||||
34
src/state/formats.ts
Normal file
34
src/state/formats.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { State } from "./app-state";
|
||||
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from './formats';
|
||||
import { validateArray, validateBoolean, validateString, validateStringEnum } from "../utils";
|
||||
import { defaultModelColor } from "./initial-state";
|
||||
import { defaultModelColor, defaultSourcePath } from "./initial-state";
|
||||
|
||||
export function buildUrlForStateParams(state: State) {//partialState: {params: State['params'], view: State['view']}) {
|
||||
return `${location.protocol}//${location.host}${location.pathname}#${encodeStateParamsAsFragment(state)}`;
|
||||
|
|
@ -11,33 +12,33 @@ export async function writeStateInFragment(state: State) {
|
|||
window.location.hash = await encodeStateParamsAsFragment(state);
|
||||
}
|
||||
async function compressString(input: string): Promise<string> {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
return btoa(String.fromCharCode(...new Uint8Array(await new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(input));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
// @ts-ignore
|
||||
const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
|
||||
const compressedData = await new Response(compressedStream).arrayBuffer();
|
||||
return btoa(String.fromCharCode(...new Uint8Array(compressedData)));
|
||||
}).pipeThrough(new CompressionStream('gzip'))).arrayBuffer())));
|
||||
}
|
||||
|
||||
async function decompressString(compressedInput: string): Promise<string> {
|
||||
const compressedData = Uint8Array.from(atob(compressedInput), c => c.charCodeAt(0));
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
return new TextDecoder().decode(await new Response(new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(compressedData);
|
||||
controller.enqueue(Uint8Array.from(atob(compressedInput), c => c.charCodeAt(0)));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip'));
|
||||
const decompressedData = await new Response(decompressedStream).arrayBuffer();
|
||||
return new TextDecoder().decode(decompressedData);
|
||||
}).pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
|
||||
}
|
||||
|
||||
// async function addFile(path: string, content: string) {
|
||||
// const state = JSON.parse(await decompressString(window.location.hash.substring(1)));
|
||||
// // console.log(JSON.stringify(state, null, 2)); // Put a breakpoint here if you wanna peek into the state
|
||||
// state.params.sources.push({ path, content });
|
||||
// window.history.pushState(state, '', '#' + await compressString(JSON.stringify(state)));
|
||||
// window.location.reload();
|
||||
// }
|
||||
|
||||
export function encodeStateParamsAsFragment(state: State) {
|
||||
const json = JSON.stringify({
|
||||
params: state.params,
|
||||
|
|
@ -60,12 +61,17 @@ export async function readStateFromFragment(): Promise<State | null> {
|
|||
const {params, view} = obj;
|
||||
return {
|
||||
params: {
|
||||
sourcePath: validateString(params?.sourcePath),
|
||||
source: validateString(params?.source),
|
||||
activePath: validateString(params?.activePath, () => defaultSourcePath),
|
||||
features: validateArray(params?.features, validateString),
|
||||
vars: params?.vars, // TODO: validate!
|
||||
// Source deserialization also handles legacy links (source + sourcePath)
|
||||
sources: params?.sources ?? (params?.source ? [{path: params?.sourcePath, content: params?.source}] : undefined), // TODO: validate!
|
||||
exportFormat2D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_2D), s => 'svg'),
|
||||
exportFormat3D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'off'),
|
||||
},
|
||||
view: {
|
||||
logs: validateBoolean(view?.logs),
|
||||
extruderPicker: validateBoolean(view?.extruderPicker),
|
||||
layout: {
|
||||
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
|
||||
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false),
|
||||
|
|
|
|||
|
|
@ -6,22 +6,7 @@ import { State } from './app-state';
|
|||
export const defaultSourcePath = '/playground.scad';
|
||||
export const defaultModelColor = '#f9d72c';
|
||||
|
||||
export const blankProjectState: State = {
|
||||
params: {
|
||||
sourcePath: defaultSourcePath,
|
||||
source: '',
|
||||
features: [],
|
||||
},
|
||||
view: {
|
||||
color: defaultModelColor,
|
||||
layout: {
|
||||
mode: 'single',
|
||||
focus: 'editor'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function createInitialState(fs: any, state: State | null) {
|
||||
export function createInitialState(state: State | null, content: string = defaultScad): State {
|
||||
|
||||
type Mode = State['view']['layout']['mode'];
|
||||
const mode: Mode = window.matchMedia("(min-width: 768px)").matches
|
||||
|
|
@ -29,9 +14,11 @@ export function createInitialState(fs: any, state: State | null) {
|
|||
|
||||
const initialState: State = {
|
||||
params: {
|
||||
sourcePath: defaultSourcePath,
|
||||
source: defaultScad,
|
||||
activePath: defaultSourcePath,
|
||||
sources: [{path: defaultSourcePath, content}],
|
||||
features: [],
|
||||
exportFormat2D: 'svg',
|
||||
exportFormat3D: 'glb',
|
||||
},
|
||||
view: {
|
||||
layout: {
|
||||
|
|
@ -67,12 +54,12 @@ export function createInitialState(fs: any, state: State | null) {
|
|||
initialState.view.showAxes ??= true
|
||||
initialState.view.showShadows ??= true
|
||||
|
||||
fs.writeFile(initialState.params.sourcePath, initialState.params.source);
|
||||
if (initialState.params.sourcePath !== defaultSourcePath) {
|
||||
fs.writeFile(defaultSourcePath, defaultScad);
|
||||
}
|
||||
// fs.writeFile(initialState.params.sourcePath, initialState.params.source);
|
||||
// if (initialState.params.sourcePath !== defaultSourcePath) {
|
||||
// fs.writeFile(defaultSourcePath, defaultScad);
|
||||
// }
|
||||
|
||||
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union'];
|
||||
const defaultFeatures = ['lazy-union'];
|
||||
defaultFeatures.forEach(f => {
|
||||
if (initialState.params.features.indexOf(f) < 0)
|
||||
initialState.params.features.push(f);
|
||||
|
|
@ -81,3 +68,5 @@ export function createInitialState(fs: any, state: State | null) {
|
|||
return initialState;
|
||||
}
|
||||
|
||||
|
||||
export const blankProjectState: State = createInitialState(null, '');
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { checkSyntax, render, RenderArgs, RenderOutput } from "../runner/actions";
|
||||
import { MultiLayoutComponentId, SingleLayoutComponentId, State, StatePersister } from "./app-state";
|
||||
import { MultiLayoutComponentId, SingleLayoutComponentId, Source, State, StatePersister } from "./app-state";
|
||||
import { VALID_EXPORT_FORMATS_2D, VALID_EXPORT_FORMATS_3D, VALID_RENDER_FORMATS } from './formats';
|
||||
import { bubbleUpDeepMutations } from "./deep-mutate";
|
||||
import { formatBytes, formatMillis } from '../utils'
|
||||
import { downloadUrl, fetchSource, formatBytes, formatMillis, readFileAsDataURL } from '../utils'
|
||||
|
||||
import JSZip from 'jszip';
|
||||
import { ProcessStreams } from "../runner/openscad-runner";
|
||||
import { is2DFormatExtension } from "./formats";
|
||||
import { convertOffToGlb, parseOff } from "../multimaterial/off2glb";
|
||||
|
||||
export class Model {
|
||||
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,
|
||||
|
|
@ -12,7 +18,7 @@ export class Model {
|
|||
|
||||
init() {
|
||||
if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering &&
|
||||
this.state.params.source.trim() != '') {
|
||||
this.source.trim() != '') {
|
||||
this.processSource();
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +41,14 @@ export class Model {
|
|||
return false;
|
||||
}
|
||||
|
||||
setFormats(
|
||||
exportFormat2D: keyof typeof VALID_EXPORT_FORMATS_2D | undefined,
|
||||
exportFormat3D: keyof typeof VALID_EXPORT_FORMATS_3D | undefined) {
|
||||
this.mutate(s => {
|
||||
if (exportFormat2D != null) s.params.exportFormat2D = exportFormat2D;
|
||||
if (exportFormat3D != null) s.params.exportFormat3D = exportFormat3D;
|
||||
});
|
||||
}
|
||||
setVar(name: string, value: any) {
|
||||
this.mutate(s => s.params.vars = {...s.params.vars ?? {}, [name]: value});
|
||||
this.render({isPreview: true, now: false});
|
||||
|
|
@ -103,46 +117,181 @@ export class Model {
|
|||
openFile(path: string) {
|
||||
// alert(`TODO: open ${path}`);
|
||||
if (this.mutate(s => {
|
||||
s.params.source = new TextDecoder("utf-8").decode(this.fs.readFileSync(path));
|
||||
if (s.params.sourcePath != path) {
|
||||
s.params.sourcePath = path;
|
||||
if (s.params.activePath != path) {
|
||||
s.params.activePath = path;
|
||||
if (!s.params.sources.find(src => src.path === path)) {
|
||||
let content = '';
|
||||
try {
|
||||
content = new TextDecoder("utf-8").decode(this.fs.readFileSync(path));
|
||||
} catch (e) {
|
||||
console.error('Error while reading file:', e);
|
||||
}
|
||||
s.params.sources = [...s.params.sources, {path, content}];
|
||||
}
|
||||
s.lastCheckerRun = undefined;
|
||||
s.output = undefined;
|
||||
s.export = undefined;
|
||||
}
|
||||
})) {
|
||||
this.processSource();
|
||||
}
|
||||
}
|
||||
|
||||
get source(): string {
|
||||
return this.state.params.sources.find(src => src.path === this.state.params.activePath)?.content ?? '';
|
||||
}
|
||||
set source(source: string) {
|
||||
if (this.mutate(s => { s.params.source = source; })) {
|
||||
if (this.mutate(s => s.params.sources = s.params.sources.map(src => src.path === s.params.activePath ? {path: src.path, content: source} : src))) {
|
||||
this.processSource();
|
||||
}
|
||||
}
|
||||
|
||||
private processSource() {
|
||||
const params = this.state.params;
|
||||
// const params = this.state.params;
|
||||
// if (isFileWritable(params.sourcePath)) {
|
||||
// const absolutePath = params.sourcePath.startsWith('/') ? params.sourcePath : `/${params.sourcePath}`;
|
||||
this.fs.writeFile(params.sourcePath, params.source);
|
||||
// this.fs.writeFile(params.sourcePath, params.source);
|
||||
// }
|
||||
if (this.state.params.activePath.endsWith('.scad')) {
|
||||
this.checkSyntax();
|
||||
}
|
||||
this.render({isPreview: true, now: false});
|
||||
}
|
||||
checkSyntax() {
|
||||
|
||||
async checkSyntax() {
|
||||
this.mutate(s => s.checkingSyntax = true);
|
||||
checkSyntax(this.state.params.source, this.state.params.sourcePath)({now: false, callback: (checkerRun, err) => this.mutate(s => {
|
||||
if (err != null) {
|
||||
console.error('Error while checking syntax:', err)
|
||||
} else {
|
||||
try {
|
||||
const checkerRun = await checkSyntax({
|
||||
activePath: this.state.params.activePath,
|
||||
sources: this.state.params.sources,
|
||||
})({now: false});
|
||||
this.mutate(s => {
|
||||
s.lastCheckerRun = checkerRun;
|
||||
s.parameterSet = checkerRun?.parameterSet;
|
||||
s.checkingSyntax = false;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error while checking syntax:', err)
|
||||
}
|
||||
})});
|
||||
}
|
||||
|
||||
render({isPreview, now}: {isPreview: boolean, now: boolean}) {
|
||||
rawStreamsCallback(ps: ProcessStreams) {
|
||||
this.mutate(s => {
|
||||
if ('stdout' in ps) {
|
||||
s.currentRunLogs?.push(['stdout', ps.stdout]);
|
||||
} else {
|
||||
s.currentRunLogs?.push(['stderr', ps.stderr]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async export() {
|
||||
if (this.state.output) {
|
||||
const normalPassThrough =
|
||||
(this.state.is2D && this.state.params.exportFormat2D === 'svg')
|
||||
|| (!this.state.is2D && this.state.params.exportFormat3D === 'off');
|
||||
|
||||
const glbPassThrough =
|
||||
(!this.state.is2D && this.state.params.exportFormat3D === 'glb')
|
||||
&& (this.state.output.displayFile?.name.endsWith('.glb') ?? false)
|
||||
&& (this.state.output.displayFileURL != null);
|
||||
|
||||
if (normalPassThrough || glbPassThrough) {
|
||||
this.mutate(s => s.export = s.output);
|
||||
if (glbPassThrough) {
|
||||
downloadUrl(this.state.output.displayFileURL!, this.state.output.displayFile!.name);
|
||||
} else {
|
||||
downloadUrl(this.state.output.outFileURL, this.state.output.outFile.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.mutate(s => {
|
||||
s.currentRunLogs ??= [];
|
||||
s.exporting = true;
|
||||
});
|
||||
|
||||
const outFile = this.state.output?.outFile;
|
||||
const outFileURL = this.state.output?.outFileURL;
|
||||
if (!outFile || !outFileURL) {
|
||||
throw new Error('No output file to export');
|
||||
}
|
||||
|
||||
const scadPath = '/export.scad'
|
||||
const sources: Source[] = [
|
||||
{
|
||||
path: scadPath,
|
||||
content: `import("${outFile.name}");`
|
||||
},
|
||||
{
|
||||
path: outFile.name,
|
||||
url: outFileURL,
|
||||
}
|
||||
];
|
||||
let {features, exportFormat2D, exportFormat3D} = this.state.params;
|
||||
|
||||
const renderArgs: RenderArgs = {
|
||||
mountArchives: false,
|
||||
scadPath,
|
||||
sources,
|
||||
extraArgs: [], isPreview: false,
|
||||
features,
|
||||
renderFormat: this.state.is2D ? exportFormat2D : exportFormat3D,
|
||||
streamsCallback: ps => console.log('Export', JSON.stringify(ps)),
|
||||
};
|
||||
|
||||
try {
|
||||
const output = await render(renderArgs)({now: true});
|
||||
const outFileURL = URL.createObjectURL(output.outFile);
|
||||
this.mutate(s => {
|
||||
s.exporting = false;
|
||||
if (s.export?.outFileURL?.startsWith('blob:') ?? false) {
|
||||
URL.revokeObjectURL(s.export!.outFileURL);
|
||||
}
|
||||
s.export = {
|
||||
outFile: output.outFile,
|
||||
outFileURL,
|
||||
elapsedMillis: output.elapsedMillis,
|
||||
formattedElapsedMillis: formatMillis(output.elapsedMillis),
|
||||
formattedOutFileSize: formatBytes(output.outFile.size),
|
||||
};
|
||||
downloadUrl(s.export.outFileURL, output.outFile.name);
|
||||
});
|
||||
} catch (err) {
|
||||
this.mutate(s => {
|
||||
s.exporting = false;
|
||||
console.error('Error while exporting:', err)
|
||||
s.error = `${err}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async saveProject() {
|
||||
if (this.state.params.sources.length == 1) {
|
||||
const content = this.state.params.sources[0].content;
|
||||
const contentBytes = new TextEncoder().encode(content);
|
||||
const blob = new Blob([contentBytes], {type: 'text/plain'});
|
||||
const file = new File([blob], this.state.params.activePath.split('/').pop()!);
|
||||
downloadUrl(URL.createObjectURL(file), file.name);
|
||||
} else {
|
||||
const zip = new JSZip();
|
||||
for (const source of this.state.params.sources) {
|
||||
let path = source.path
|
||||
if (path.startsWith('/')) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
zip.file(path, await fetchSource(source));
|
||||
}
|
||||
zip.generateAsync({type: 'blob'}).then(blob => {
|
||||
const file = new File([blob], 'project.zip');
|
||||
downloadUrl(URL.createObjectURL(file), file.name);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async render({isPreview, mountArchives, now, retryInOtherDim}: {isPreview: boolean, mountArchives?: boolean, now: boolean, retryInOtherDim?: boolean}) {
|
||||
mountArchives ??= true;
|
||||
retryInOtherDim ??= true;
|
||||
const setRendering = (s: State, value: boolean) => {
|
||||
if (isPreview) {
|
||||
s.previewing = value;
|
||||
|
|
@ -150,41 +299,142 @@ export class Model {
|
|||
s.rendering = value;
|
||||
}
|
||||
}
|
||||
this.mutate(s => setRendering(s, true));
|
||||
this.mutate(s => {
|
||||
s.currentRunLogs = [];
|
||||
setRendering(s, true);
|
||||
});
|
||||
|
||||
const {source, sourcePath, vars, features} = this.state.params;
|
||||
let {
|
||||
activePath,
|
||||
sources,
|
||||
vars,
|
||||
features,
|
||||
} = this.state.params;
|
||||
|
||||
render({source, sourcePath, vars, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
|
||||
let is2D = this.state.is2D;
|
||||
|
||||
const extension = activePath.split('.').pop() ?? '';
|
||||
if (!activePath.endsWith('.scad')) {
|
||||
const resourcePath = activePath;
|
||||
const loaderPath = '/load-resource.scad';
|
||||
is2D = is2DFormatExtension(extension);
|
||||
|
||||
mountArchives = false;
|
||||
activePath = loaderPath;
|
||||
sources = [
|
||||
{
|
||||
path: activePath,
|
||||
content: `${is2D ? 'linear_extrude(1) ' : ''} import("${resourcePath}");`,
|
||||
},
|
||||
...sources.filter(s => s.path === resourcePath),
|
||||
];
|
||||
}
|
||||
|
||||
const renderArgs: RenderArgs = {
|
||||
mountArchives,
|
||||
scadPath: activePath,
|
||||
sources,
|
||||
vars,
|
||||
features,
|
||||
isPreview,
|
||||
renderFormat: this.state.is2D ? 'svg' : 'off',
|
||||
streamsCallback: this.rawStreamsCallback.bind(this)
|
||||
};
|
||||
try {
|
||||
let output = await render(renderArgs)({now});
|
||||
let displayFile = output.outFile;
|
||||
if (output.outFile.name.endsWith('.svg') || output.outFile.name.endsWith('.dxf')) {
|
||||
is2D = true;
|
||||
const fn = output.outFile.name;
|
||||
const extrudedOutput = await render({
|
||||
mountArchives: false,
|
||||
scadPath: '/extruded.scad',
|
||||
sources: [
|
||||
{
|
||||
path: '/extruded.scad',
|
||||
content: `linear_extrude(0.1) import("${fn}");`,
|
||||
},
|
||||
{
|
||||
path: `/${fn}`,
|
||||
url: await readFileAsDataURL(output.outFile),
|
||||
},
|
||||
],
|
||||
vars: {},
|
||||
features,
|
||||
isPreview: false,
|
||||
renderFormat: 'off',
|
||||
streamsCallback: this.rawStreamsCallback.bind(this)
|
||||
})({now});
|
||||
displayFile = extrudedOutput.outFile;
|
||||
} else {
|
||||
is2D = false;
|
||||
}
|
||||
if (displayFile.name.endsWith('.off')) {
|
||||
const offData = parseOff(await displayFile.text());
|
||||
displayFile = new File([await convertOffToGlb(offData)], displayFile.name.replace('.off', '.glb'));
|
||||
}
|
||||
const outFileURL = URL.createObjectURL(output.outFile);
|
||||
const displayFileURL = displayFile && await readFileAsDataURL(displayFile);
|
||||
this.mutate(s => {
|
||||
setRendering(s, false);
|
||||
if (err != null) {
|
||||
console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err)
|
||||
s.error = `${err}`;
|
||||
} else if (output) {
|
||||
s.error = undefined;
|
||||
s.is2D = is2D;
|
||||
s.lastCheckerRun = {
|
||||
logText: output.logText,
|
||||
markers: output.markers,
|
||||
}
|
||||
if (s.output?.stlFileURL) {
|
||||
URL.revokeObjectURL(s.output.stlFileURL);
|
||||
if (s.output?.outFileURL?.startsWith('blob:') ?? false) {
|
||||
URL.revokeObjectURL(s.output!.outFileURL);
|
||||
}
|
||||
if (s.output?.displayFileURL?.startsWith('blob:') ?? false) {
|
||||
URL.revokeObjectURL(s.output!.displayFileURL!);
|
||||
}
|
||||
|
||||
s.output = {
|
||||
isPreview: isPreview,
|
||||
stlFile: output.stlFile,
|
||||
stlFileURL: URL.createObjectURL(output.stlFile),
|
||||
outFile: output.outFile,
|
||||
outFileURL,
|
||||
displayFile,
|
||||
displayFileURL,
|
||||
elapsedMillis: output.elapsedMillis,
|
||||
formattedElapsedMillis: formatMillis(output.elapsedMillis),
|
||||
formattedStlFileSize: formatBytes(output.stlFile.size),
|
||||
formattedOutFileSize: formatBytes(output.outFile.size),
|
||||
};
|
||||
|
||||
if (!isPreview) {
|
||||
const audio = document.getElementById('complete-sound') as HTMLAudioElement;
|
||||
audio?.play();
|
||||
}
|
||||
}
|
||||
});
|
||||
}})
|
||||
} catch (err) {
|
||||
this.mutate(s => {
|
||||
setRendering(s, false);
|
||||
console.error('Error while doing ' + (isPreview ? 'preview' : 'rendering') + ':', err)
|
||||
s.error = `${err}`;
|
||||
});
|
||||
}
|
||||
if (retryInOtherDim) {
|
||||
let is2D: boolean | undefined;
|
||||
let is3D: boolean | undefined;
|
||||
let isMixed: boolean | undefined;
|
||||
for (const [pipe, line] of this.state.currentRunLogs ?? []) {
|
||||
if (line == 'Current top level object is not a 3D object.') {
|
||||
is3D = false;
|
||||
} else if (line == 'Top level object is a 3D object:') {
|
||||
is3D = true;
|
||||
} else if (line == 'Current top level object is not a 2D object.') {
|
||||
is2D = false;
|
||||
} else if (line == 'Top level object is a 2D object:') {
|
||||
is2D = true;
|
||||
} else if (line.includes('WARNING: Mixing 2D and 3D objects is not supported')) {
|
||||
isMixed = true;
|
||||
}
|
||||
}
|
||||
if (is2D === false || is3D === false) {//} || isMixed !== undefined) {
|
||||
this.mutate(s => s.is2D = !(is2D === false));
|
||||
this.render({isPreview, now: true, retryInOtherDim: false});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
src/utils.ts
91
src/utils.ts
|
|
@ -1,5 +1,7 @@
|
|||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
||||
|
||||
import { Source } from "./state/app-state";
|
||||
|
||||
export function mapObject(o: any, f: (key: string, value: any) => any, ifPred: (key: string) => boolean) {
|
||||
const ret = [];
|
||||
for (const key of Object.keys(o)) {
|
||||
|
|
@ -28,19 +30,55 @@ export function turnIntoDelayableExecution<T extends any[], R>(
|
|||
job: (...args: T) => AbortablePromise<R>) {
|
||||
let pendingId: number | null;
|
||||
let runningJobKillSignal: (() => void) | null;
|
||||
// return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
|
||||
// (async () => {
|
||||
// try {
|
||||
// const result = await job;
|
||||
// // console.log(result);
|
||||
|
||||
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result?: R, error?: any) => void}) => {
|
||||
// let parameterSet: ParameterSet | undefined = undefined;
|
||||
// if (result.outputs && result.outputs.length == 1) {
|
||||
// let [[, content]] = result.outputs;
|
||||
// content = new TextDecoder().decode(content as any);
|
||||
// try {
|
||||
// parameterSet = JSON.parse(content)
|
||||
// // console.log('PARAMETER SET', JSON.stringify(parameterSet, null, 2))
|
||||
// } catch (e) {
|
||||
// console.error(`Error while parsing parameter set: ${e}\n${content}`);
|
||||
// }
|
||||
// } else {
|
||||
// console.error('No output from runner!');
|
||||
// }
|
||||
|
||||
// res({
|
||||
// ...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
|
||||
// sourcePath: sources[0].path,
|
||||
// skipLines: 1,
|
||||
// }}),
|
||||
// parameterSet,
|
||||
// });
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// rej(e);
|
||||
// }
|
||||
// })()
|
||||
// return () => job.kill();
|
||||
// });
|
||||
//return (...args: T) => async ({now, callback}: {now: boolean, callback: (result?: R, error?: any) => void}) => {
|
||||
return (...args: T) => ({now}: {now: boolean}) => AbortablePromise<R>((resolve, reject) => {
|
||||
let abortablePromise: AbortablePromise<R> | undefined = undefined;
|
||||
(async () => {
|
||||
const doExecute = async () => {
|
||||
if (runningJobKillSignal) {
|
||||
runningJobKillSignal();
|
||||
runningJobKillSignal = null;
|
||||
}
|
||||
const abortablePromise = job(...args);
|
||||
abortablePromise = job(...args);
|
||||
runningJobKillSignal = abortablePromise.kill;
|
||||
try {
|
||||
callback(await abortablePromise);
|
||||
resolve(await abortablePromise);
|
||||
} catch (e) {
|
||||
callback(undefined, e);
|
||||
reject(e);
|
||||
} finally {
|
||||
runningJobKillSignal = null;
|
||||
}
|
||||
|
|
@ -54,7 +92,9 @@ export function turnIntoDelayableExecution<T extends any[], R>(
|
|||
} else {
|
||||
pendingId = window.setTimeout(doExecute, delay);
|
||||
}
|
||||
};
|
||||
})();
|
||||
return () => abortablePromise?.kill();
|
||||
});
|
||||
}
|
||||
|
||||
export function validateStringEnum<T extends string>(
|
||||
|
|
@ -67,7 +107,7 @@ export const validateString = (s: string, orElse: () => string = () => '') => s
|
|||
export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
|
||||
if (!(a instanceof Array)) return orElse();
|
||||
return a.map(validateElement);
|
||||
}
|
||||
};
|
||||
|
||||
export function formatBytes(n: number) {
|
||||
if (n < 1024) {
|
||||
|
|
@ -102,4 +142,43 @@ export const isInStandaloneMode =
|
|||
// true
|
||||
Boolean(('standalone' in window.navigator) && (window.navigator.standalone));
|
||||
|
||||
export function downloadUrl(url: string, filename: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename)
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
}
|
||||
|
||||
export async function fetchSource({content, path, url}: Source) {
|
||||
if (content) {
|
||||
return content;
|
||||
} else if (url) {
|
||||
if (path.endsWith('.scad') || path.endsWith('.json')) {
|
||||
content = await (await fetch(url)).text();
|
||||
return content;
|
||||
} else {
|
||||
// Fetch bytes
|
||||
const response = await fetch(url);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const data = new Uint8Array(buffer);
|
||||
return data;
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid source: ' + JSON.stringify({path, content, url}));
|
||||
}
|
||||
}
|
||||
|
||||
export function readFileAsDataURL(file: File) {
|
||||
// TO data URI:
|
||||
return new Promise<string>((res, rej) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
res(reader.result as string);
|
||||
}
|
||||
reader.onerror = rej;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
// return URL.createObjectURL(file);
|
||||
}
|
||||
21
tests/e2e.test.js
Normal file
21
tests/e2e.test.js
Normal 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);
|
||||
});
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2021",
|
||||
"ES2022",
|
||||
"WebWorker",
|
||||
],
|
||||
"rootDir": "src",
|
||||
|
|
@ -28,5 +28,9 @@
|
|||
"src/**/*.tsx",
|
||||
"src/**/*.js",
|
||||
// "public"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"src/wasm/*.js"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const WorkboxPlugin = require('workbox-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
const path = require('path');
|
||||
|
||||
|
|
@ -42,6 +43,10 @@ module.exports = [{
|
|||
port: 4000,
|
||||
},
|
||||
plugins: [
|
||||
new webpack.EnvironmentPlugin({
|
||||
'process.env.NODE_ENV': 'development',
|
||||
}),
|
||||
...(process.env.NODE_ENV === 'production' ? [
|
||||
new WorkboxPlugin.GenerateSW({
|
||||
// these options encourage the ServiceWorkers to get in there fast
|
||||
// and not allow any straggling "old" SWs to hang around
|
||||
|
|
@ -61,6 +66,7 @@ module.exports = [{
|
|||
},
|
||||
}],
|
||||
}),
|
||||
] : []),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue