Gooood progress

This commit is contained in:
ochafik 2023-03-26 07:48:04 +01:00
commit 6528baa7b6
17 changed files with 752 additions and 171 deletions

8
.gitignore vendored
View file

@ -1,10 +1,6 @@
node_modules
libs
dist
public/libraries
public/openscad-worker-inlined.js
public/openscad.js
public/openscad.wasm
js
src/*.js
build
src/wasm
package-lock.json

View file

@ -18,6 +18,7 @@ Should work:
Planned:
- Open any file in the VFS. Setup [OPENSCADPATH](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Libraries#Setting_OPENSCADPATH)
- Customizer support. Probably by adding --export-json or --export-format=customizer-json to OpenSCAD.
- Mobile (iOS) editing support: switch to https://www.npmjs.com/package/react-codemirror ?
- Preview rendering: have OpenSCAD export the preview scene to a rich format (e.g. glTF, with some parts being translucent when prefixed w/ % modifier) and display it using https://modelviewer.dev/ maybe)

View file

@ -25,17 +25,10 @@
content="OpenSCAD Playground: edit .scad models and render them to STL from the browser (WebAssembly build OpenSCAD, with Monaco editor and STL Viewer)"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!-- https://developers.google.com/web/fundamentals/web-app-manifest/ -->
<link rel="manifest" href="manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>OpenSCAD Playground</title>
<style>
#root,
@ -47,6 +40,45 @@
width: 100vw;
height: 100vh;
}
.p-tabmenu-nav {
justify-content: center;
}
.absolute-fill {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
}
.monaco-editor {
background-color: rgba(255,255,255,0.5) !important;
}
.overflow-guard,
.monaco-scrollable-element,
.monaco-editor-background {
background-color: transparent !important;
}
.opacity-animated {
transition: opacity 0.5s ease-in-out;
}
.opacity-0 {
opacity: 0;
}
@media only screen and (max-height: 600px),
only screen and (max-width: 600px),
only screen and (max-width: 767px),
only screen and (max-aspect-ratio: 1/2) {
.github-fork-ribbon {
display: none;
}
}
</style>
<style>.github-fork-ribbon:before { background-color: #333; }</style>
@ -61,15 +93,5 @@
<noscript>You need to enable JavaScript to run the OpenSCAD Playground.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -1,7 +1,7 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import React, { forwardRef, useContext, useEffect, useRef, useState } from 'react';
import {ModelContext, State} from './app-state'
import React, { CSSProperties, forwardRef, useContext, useEffect, useRef, useState } from 'react';
import {ModelContext, MultiLayoutComponentId, SingleLayoutComponentId, State} from './app-state'
import Editor, { loader, Monaco } from '@monaco-editor/react';
// import './App.css';
import openscadEditorOptions from './language/openscad-editor-options';
@ -10,6 +10,18 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import {StlViewer} from "react-stl-viewer";
import { Button } from 'primereact/button';
import { ProgressBar } from 'primereact/progressbar';
import { MenuItem } from 'primereact/menuitem';
import { TreeSelect } from 'primereact/treeselect';
import TreeNode from 'primereact/treenode';
import { TabMenu } from 'primereact/tabmenu';
import { Badge } from 'primereact/badge';
import { Menu } from 'primereact/menu';
import { ToggleButton } from 'primereact/togglebutton';
import { getParentDir } from './filesystem';
import { BreadCrumb } from 'primereact/breadcrumb';
import { ScrollPanel } from 'primereact/scrollpanel';
import { Splitter, SplitterPanel } from 'primereact/splitter';
import { Toolbar } from 'primereact/toolbar';
// import "primereact/resources/themes/lara-light-indigo/theme.css";
// import "primereact/resources/primereact.min.css";
@ -18,17 +30,70 @@ import { ProgressBar } from 'primereact/progressbar';
let monacoInstance: Monaco
loader.init().then(mi => monacoInstance = mi);
function EditorPanel() {
const isFileWritable = (path: string) => getParentDir(path) === '/home'
const FSContext = React.createContext<FS | undefined>(undefined);
function listFilesAsNodes(fs: FS, path: string): TreeNode[] {
const files: [string, string][] = []
const dirs: [string, string][] = []
for (const name of fs.readdirSync(path)) {
const childPath = `${path}/${name}`;
const stat = fs.lstatSync(childPath);
const isDirectory = stat.isDirectory();
if (!isDirectory && !name.endsWith('.scad')) {
continue;
}
(isDirectory ? dirs : files).push([name, childPath]);
}
[files, dirs].forEach(arr => arr.sort(([a], [b]) => a.localeCompare(b)));
const nodes: TreeNode[] = []
for (const [arr, isDirectory] of [[files, false], [dirs, true]] as [[string, string][], boolean][]) {
for (const [name, path] of arr) {
const children = isDirectory ? listFilesAsNodes(fs, path) : undefined;
if (isDirectory && children!.length == 0) {
continue;
}
nodes.push({
// icon: path == '/home' ? 'pi-home' : ...
icon: isDirectory ? 'pi pi-folder' : isFileWritable(path) ? 'pi pi-file' : 'pi pi-lock',
label: name,
data: path,
key: path,
children,
selectable: !isDirectory // && (name == 'LICENSE' || name.endsWith('.scad') || name.endsWith('.scad')
});
}
}
return nodes;
}
function EditorPanel({className, style}: {className?: string, style?: CSSProperties}) {
const model = useContext(ModelContext);
if (!model) throw new Error('No model');
const state = model.state;
const fs = useContext(FSContext);
const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null)
// const [modelFile, setModelFile] = useState(state.params.sourcePath);
if (editor) {
const checkerRun = model.state.lastCheckerRun;
const checkerRun = state.lastCheckerRun;
const editorModel = editor.getModel();
if (editorModel && checkerRun) {
if (editorModel) {
if (checkerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', checkerRun.markers);
}
// if (modelFile !== state.params.sourcePath) {
// editor.setModel(monaco.editor.createModel('', 'openscad'));
// setModelFile(state.params.sourcePath)
// }
}
}
const onMount = (editor: monaco.editor.IStandaloneCodeEditor) => {
editor.addAction({
id: "openscad-render",
@ -48,59 +113,116 @@ function EditorPanel() {
setEditor(editor)
}
// const breadCrumbsHome: MenuItem = {
// icon: 'pi pi-home'
// }
// const breadCrumbsItems: MenuItem[] = [
// {
// label: 'input.scad',
// command: () => alert('ok')
// }
// ]
const fsItems = fs && listFilesAsNodes(fs, '/home') || [];
// console.log('getParentDir(', state.params.sourcePath, ')', getParentDir(state.params.sourcePath))
return (
<div className="editor-panel" style={{
<div className={`editor-panel ${className ?? ''}`} style={{
// maxWidth: '5 0vw',
display: 'flex',
flexDirection: 'column',
position: 'relative'
// position: 'relative',
// width: '100%', height: '100%',
...(style ?? {})
}}>
{/* <BreadCrumb model={breadCrumbsItems} home={breadCrumbsHome}/> */}
<TreeSelect
title='OpenSCAD Playground Files'
value={state.params.sourcePath}
onChange={(e) => model.openFile(String(e.value))}
filter
options={fsItems} />
{/* <SplitPane type="vertical"> */}
{/* <div className='flex flex-column'> */}
{/* <Splitter style={{
// height: '300px'
}} layout="vertical"> */}
{/* <SplitterPanel className="flex flex-column align-items-center justify-content-center"
style={{
// width: '100%', height: '100%'
}}> */}
<div style={{
position: 'relative',
flex: 1
}}>
<Editor
className="openscad-editor"
className="openscad-editor absolute-fill"
defaultLanguage="openscad"
value={model.state.params.source}
path={state.params.sourcePath}
value={state.params.source}
onChange={s => model.source = s ?? ''}
onMount={onMount} // TODO: This looks a bit silly, does it trigger a re-render??
options={openscadEditorOptions}
height="50vh"/>
<div className="logs-container">
<pre><code id="logs">{model.state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
options={{
...openscadEditorOptions,
readOnly: !isFileWritable(state.params.sourcePath)
}}
// height="100%"
// width="100%"
// height="80vh"
/>
</div>
{/* </SplitterPanel>
<SplitterPanel className="flex flex-column align-items-center justify-content-center" style={{position: 'relative'}}> */}
{/* <ScrollPanel style={{
// width: '100%', height: '200px'
}} className="custombar2"> */}
<div style={{
display: state.view.logs ? undefined : 'none',
overflowY: 'scroll',
height: 'calc(min(200px, 30vh))',
// position: 'relative',
// maxWidth: '100%',
// maxWidth: '50vw',
}}>
<pre><code id="logs" style={{
// maxWidth: '200px'
}}>{state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
</div>
{/* </ScrollPanel> */}
{/* <div className="logs-container">
</div> */}
{/* </SplitterPanel>
</Splitter> */}
{/* </SplitPane> */}
{/* </div> */}
</div>
)
}
export function ViewerPanel({className, style}: {className?: string, style?: CSSProperties}) {
const model = useContext(ModelContext);
if (!model) throw new Error('No model');
export function App({initialState}: {initialState: State}) {
const [state, setState] = useState(initialState);
const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null)
if (editor) {
const editorModel = editor.getModel();
if (editorModel && state.lastCheckerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', state.lastCheckerRun.markers);
}
}
const model = new Model(state, setState);
useEffect(() => model.init());
// const stlModelRef = forwardRef();
const state = model.state;
return (
<ModelContext.Provider value={model}>
<div style={{display: 'flex', flexDirection: 'column', flex: 1}}>
<div style={{display: 'flex', flexDirection: 'row', flex: 1}}>
<div style={{width: "50vw"}}>
<EditorPanel/>
</div>
<div style={{display: 'flex', flexDirection: 'column', flex: 1, position: 'relative'}}>
{(state.rendering || state.previewing) &&
<ProgressBar mode="indeterminate" style={{ height: '6px' }}></ProgressBar>}
<div className={className}
style={{display: 'flex', flexDirection: 'column',
flex: 1,
position: 'relative',
width: '100%',
height: '100%',
...(style ?? {})
}}>
{state.output?.stlFileURL &&
<StlViewer
style={{
flex: 1
// width: '100%'
}}
// ref={stlModelRef}
showAxes={true}
@ -115,31 +237,312 @@ export function App({initialState}: {initialState: State}) {
}
</div>
</div>
<div style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
<Button onClick={() => model.render({isPreview: false, now: true})}>Render</Button>
{state.output && state.output.formattedElapsedMillis}
&nbsp;
{state.previewing && 'previewing... '}
{state.rendering && 'rendering... '}
{state.checkingSyntax && 'checking syntax... '}
)
}
{state.output && (
<a href={state.output.stlFileURL}
target="_blank"
download={state.output.isPreview ? "preview.stl" : "render.stl"}
title="STL Download">
{state.output.isPreview ? "preview.stl" : "render.stl"} ({state.output.formattedStlFileSize})
</a>
)}
function downloadOutput(state: State) {
if (!state.output) return;
const a = document.createElement('a')
a.href = state.output.stlFileURL
a.download = state.output.isPreview ? 'preview.stl' : 'render.stl';
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function Footer({style}: {style?: CSSProperties}) {
const model = useContext(ModelContext);
if (!model) throw new Error('No model');
const menu = useRef<Menu>(null);
const state = model.state;
const severityByMarkerSeverity = new Map<monaco.MarkerSeverity, 'danger' | 'warning' | 'info'>([
[monaco.MarkerSeverity.Error, 'danger'],
[monaco.MarkerSeverity.Warning, 'warning'],
[monaco.MarkerSeverity.Info, 'info'],
]);
const markers = state.lastCheckerRun?.markers ?? [];
const getBadge = (s: monaco.MarkerSeverity) => {
const count = markers.filter(m => m.severity == s).length;
const sev = s == monaco.MarkerSeverity.Error ? 'danger'
: s == monaco.MarkerSeverity.Warning ? 'warning'
: s == monaco.MarkerSeverity.Info ? 'info'
: 'success';
return <>{count > 0 && <Badge value={count} severity={severityByMarkerSeverity.get(s)}></Badge>}</>;
};
const maxMarkerSeverity = markers.length == 0 ? undefined : markers.map(m => m.severity).reduce((a, b) => Math.max(a, b));
return <>
<ProgressBar mode="indeterminate"
style={{
marginLeft: '5px',
marginRight: '5px',
visibility: state.rendering || state.previewing || state.checkingSyntax
? '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"
label="Render" />
{/* {state.error != null &&
<i className="pi pi-exclamation-circle" style={{color: 'red'}} title={state.error}></i>} */}
<Button type="button"
// label={state.view.logs ? "Hide logs" : "Show logs"}
label="Logs"
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)}
{getBadge(monaco.MarkerSeverity.Warning)}
{getBadge(monaco.MarkerSeverity.Info)}
</Button>
{/* {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>
<a target="_blank" className="text-fragment" href="http://openscad.org/documentation.html">Docs</a> |
<a target="_blank" className="text-fragment" href="http://openscad.org/cheatsheet/">Cheatsheet</a> |
<a target="_blank" href="https://github.com/openscad/openscad-playground/blob/rewrite1/LICENSE.md">LICENSES</a>
{state.output && (
<Button icon='pi pi-download'
title={`Download ${state.output.isPreview ? "preview.stl" : "render.stl"} (${state.output.formattedStlFileSize})`}
severity="secondary"
text
// label={state.output.isPreview ? "preview.stl" : "render.stl"}
iconPos='right'
onClick={() => downloadOutput(state)} />
// <a href={state.output.stlFileURL}
// target="_blank"
// download={state.output.isPreview ? "preview.stl" : "render.stl"}
// title="STL Download">
// {state.output.isPreview ? "preview.stl" : "render.stl"} ({state.output.formattedStlFileSize})
// </a>
)}
<ToggleButton
style={{
// flex: 1,
}}
onIcon='pi pi-table'
offIcon='pi pi-table'
onLabel=''
offLabel=''
// onLabel='Multi'
// offLabel='Single'
title='Change between single and multiple layout'
checked={state.view.layout.mode === 'multi'}
onChange={e => model.changeLayout(e.value ? 'multi' : 'single')} />
<Menu model={[
{
label: "openscad-playground",
icon: 'pi pi-github',
command: () => window.open('https://github.com/openscad/openscad-playground/tree/rewrite1', '_blank'),
},
{
label: 'LICENSES',
icon: 'pi pi-info-circle',
command: () => window.open('https://github.com/openscad/openscad-playground/blob/rewrite1/LICENSE.md', '_blank'),
},
{
label: 'OpenSCAD Docs',
icon: 'pi pi-book',
command: () => window.open('http://openscad.org/documentation.html', '_blank'),
},
{
label: 'OpenSCAD Cheatsheet',
icon: 'pi pi-palette',
command: () => window.open('http://openscad.org/cheatsheet/', '_blank'),
},
{
label: 'BOSL2 Cheatsheet',
icon: 'pi pi-palette',
command: () => window.open('https://github.com/revarbat/BOSL2/wiki/CheatSheet', '_blank'),
},
] as MenuItem[]} popup ref={menu} />
<Button title="Help & Licenses" rounded icon="pi pi-question-circle" onClick={(e) => menu.current && menu.current.toggle(e)} />
</div>
</>
}
export function App({initialState, fs}: {initialState: State, fs: FS}) {
const [state, setState] = useState(initialState);
const model = new Model(fs, state, setState);
useEffect(() => model.init());
// const breadCrumbsItems: MenuItem[] = [
// {
// label: 'input.scad',
// command: () => alert('ok')
const singleTargets: {id: SingleLayoutComponentId, icon: string, label: string}[] = [
{ id: 'editor', icon: 'pi pi-pencil', label: 'Edit' },
{ id: 'viewer', icon: 'pi pi-box', label: 'View' },
{ id: 'customizer', icon: 'pi pi-cog', label: 'Customize' },
];
const multiTargets = singleTargets;
// const multiTargets: {id: MultiLayoutComponentId, icon: string, label: string}[] = [
// { id: 'editor', icon: 'pi pi-pencil', label: 'Editor' },
// { id: 'viewer', icon: 'pi pi-box', label: 'Viewer' },
// { id: 'customizer', icon: 'pi pi-cog', label: 'Customizer' },
// ];
const zIndexOfPanelsDependingOnFocus = {
editor: {
editor: 3,
viewer: 1,
customizer: 0,
},
viewer: {
editor: 2,
viewer: 3,
customizer: 2,
},
customizer: {
editor: 0,
viewer: 0,
customizer: 3,
}
}
const layout = state.view.layout
const mode = state.view.layout.mode;
function getPanelStyle(id: MultiLayoutComponentId): CSSProperties {
if (layout.mode === 'multi') {
const itemCount = (layout.editor ? 1 : 0) + (layout.viewer ? 1 : 0) + (layout.customizer ? 1 : 0)
return {
flex: 1,
maxWidth: Math.floor(100/itemCount) + '%',
display: (state.view.layout as any)[id] ? 'flex' : 'none'
}
} else {
return {
flex: 1,
zIndex: Number((zIndexOfPanelsDependingOnFocus as any)[id][layout.focus]),
}
}
}
return (
<ModelContext.Provider value={model}>
<FSContext.Provider value={fs}>
<div className='flex flex-column' style={{height: '100vh'}}>
<div className=""
// style={{
// backgroundImage: 'url("logo192.png")',
// backgroundPositionX: 'left',
// backgroundPositionY: 'top',
// backgroundRepeat: 'no-repeat',
// }}
>
{/* <h1>
OpenSCAD
</h1> */}
<div className='flex flex-row' style={{
// justifyContent: 'center',
margin: '5px',
// marginTop: '70px',
}}>
{/* <span style={{flex: 1}} /> */}
{state.view.layout.mode === 'multi'
? <div className='flex flex-row gap-1' style={{
justifyContent: 'center',
flex: 1,
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}
disabled={id === 'customizer'}
// onClick={() => model.changeMultiVisibility(id, !(state.view.layout as any)[id])}
onChange={e => model.changeMultiVisibility(id, e.value)}
/>
)}
</div>
: <TabMenu
style={{
flex: 1,
// justifyContent: 'center'
}}
model={singleTargets.map(({icon, label, id}) =>
({icon, label, command: () => model.changeSingleVisibility(id)}))} />
}
</div>
</div>
<div className={mode === 'multi' ? 'flex flex-row' : 'flex flex-column'}
style={mode === 'multi' ? {flex: 1} : {
flex: 1,
position: 'relative'
}}>
{/* {position: 'relative'}}> */}
<EditorPanel className={`
opacity-animated
${layout.mode === 'single' && layout.focus !== 'editor' ? 'opacity-0' : ''}
${layout.mode === 'single' ? 'absolute-fill' : ''}
`} style={getPanelStyle('editor')} />
<ViewerPanel className={layout.mode === 'single' ? `absolute-fill` : ''} style={getPanelStyle('viewer')} />
{/* <CustomizerPanel className={`${getPanelClasses('customizer')} absolute-fill`} style={getPanelStyle('customizer')} /> */}
</div>
<Footer />
</div>
</FSContext.Provider>
</ModelContext.Provider>
);
}
// <Splitter style={{ flex: 1 }}>
// <SplitterPanel className="flex flex-column align-items-center justify-content-center">
// <EditorPanel />
// </SplitterPanel>
// <SplitterPanel className="flex flex-column align-items-center justify-content-center">
// <ViewerPanel/>
// </SplitterPanel>
// </Splitter>
// <Footer />

3
src/BrowserFS.d.ts vendored
View file

@ -3,9 +3,12 @@
// import { FS } from "./filesystem";
declare interface FS {
writeFile(path: string, content: string): void;
readdir(path: string, cb: (err: any, files: string[]) => void): void;
readdirSync(path: string): string[];
symlink(target: string, source: string): void;
readFileSync(path: string): BufferSource;
lstatSync(path: string): {isDirectory(): boolean};
}
declare interface EmscriptenFS extends FS {}

View file

@ -1,6 +1,7 @@
// 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 './filesystem';
import { spawnOpenSCAD } from "./openscad-runner";
import { processMergedOutputs } from "./output-parser";
import { AbortablePromise, turnIntoDelayableExecution } from './utils';
@ -9,15 +10,15 @@ const syntaxDelay = 300;
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[]};
export const checkSyntax =
turnIntoDelayableExecution(syntaxDelay, (source: string) => {
turnIntoDelayableExecution(syntaxDelay, (source: string, sourcePath: string) => {
// const timestamp = Date.now();
source = '$preview=true;\n' + source;
const sourceFile = 'input.scad';
const job = spawnOpenSCAD({
inputs: [[sourceFile, source + '\n']],
args: [sourceFile, "-o", "out.ast"],
workingDir: getParentDir(sourcePath),
inputs: [[sourcePath, source + '\n']],
args: [sourcePath, "-o", "out.ast"],
});
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
@ -25,7 +26,7 @@ export const checkSyntax =
try {
const result = await job;
// console.log(result);
res(processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {[sourceFile]: 1}}));
res(processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {[sourcePath]: 1}}));
} catch (e) {
console.error(e);
rej(e);
@ -40,15 +41,15 @@ export type RenderOutput = {stlFile: File, logText: string, markers: monaco.edit
export type RenderArgs = {
source: string,
sourcePath: string,
features?: string[],
extraArgs?: string[],
isPreview: boolean
}
export const render =
turnIntoDelayableExecution(renderDelay, (params: RenderArgs) => {
const inputFile = 'input.scad';
const args = [
inputFile,
params.sourcePath,
"-o", "out.stl",
"--export-format=binstl",
...(params.features ?? []).map(f => `--enable=${f}`),
@ -63,7 +64,8 @@ export const render =
const job = spawnOpenSCAD({
// wasmMemory,
inputs: [['input.scad', source]],
workingDir: getParentDir(params.sourcePath),
inputs: [[params.sourcePath, source]],
args,
outputPaths: ['out.stl']
});
@ -75,7 +77,7 @@ export const render =
console.log(result);
const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
shiftSourceLines: {[inputFile]: prefixLines.length}
shiftSourceLines: {[params.sourcePath]: prefixLines.length}
});
if (result.error) {
@ -83,7 +85,10 @@ export const render =
}
const [output] = result.outputs ?? [];
if (!output) throw 'No output from runner!'
if (!output) {
reject(new Error('No output from runner!'));
return;
}
const [filePath, content] = output;
const filePathFragments = filePath.split('/');
const fileName = filePathFragments[filePathFragments.length - 1];

View file

@ -5,11 +5,26 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import React from 'react';
import { Model } from "./model"
export type MultiLayoutComponentId = 'editor' | 'viewer' | 'customizer';
export type SingleLayoutComponentId = MultiLayoutComponentId;
export interface State {
params: {
sourcePath: string,
source: string,
features: string[],
},
view: {
logs?: boolean,
layout: {
mode: 'single',
focus: SingleLayoutComponentId,
} | ({
mode: 'multi',
} & { [K in MultiLayoutComponentId]: boolean })
}
lastCheckerRun?: {
logText: string,
markers: monaco.editor.IMarkerData[]
@ -18,6 +33,7 @@ export interface State {
previewing?: boolean,
checkingSyntax?: boolean,
error?: string,
output?: {
isPreview: boolean,
stlFile: File,
@ -28,12 +44,8 @@ export interface State {
},
};
export const ModelContext = React.createContext(new Model(
{
params: {
source: '',
features: ['manifold', 'lazy-union'],
}
export const ModelContext = React.createContext<Model | null>(null);
},
() => { throw new Error('Not implemented'); }
));

View file

@ -36,6 +36,9 @@ function collectObjectEntriesDeeply(o: KVObject, out: KVEntriesMap = new Map()):
}
function bubbleChangesUp(o: KVObject, allOriginalEntries: KVEntriesMap) {
if (o == null || typeof o !== 'object') {
return o;
}
const entries = Object.entries(o);
const originalEntries = allOriginalEntries.get(o);
if (!originalEntries) {

View file

@ -10,6 +10,8 @@ export type FSMounts = {
export type Symlinks = {[alias: string]: string};
export const getParentDir = (path: string) => path.split('/').slice(0, -1).join('/');
export function readDirAsArray(fs: FS, path: string): Promise<string[] | undefined> {
return new Promise((res, rej) => fs.readdir(path, (err, files) => err ? rej(err) : res(files)));
}
@ -70,7 +72,7 @@ function configureAndInstallFS(windowOrSelf: Window, options: any) {
});
}
export async function createEditorFS(workingDir='/home') {
export async function createEditorFS(workingDir='/home'): Promise<FS> {
const archiveNames = Object.keys(zipArchives);
const librariesMounts = await getBrowserFSLibrariesMounts(archiveNames);
const allMounts: FSMounts = {};

View file

@ -1,20 +1,33 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { State } from "./app-state";
import { validateArray, validateString } from "./utils";
import { validateArray, validateBoolean, validateString, validateStringEnum } from "./utils";
export function writeStateInFragment(state: State) {
window.location.hash = encodeURIComponent(JSON.stringify(state.params));
window.location.hash = encodeURIComponent(JSON.stringify({
params: state.params,
view: state.view
}));
}
export function readStateFromFragment(): State | null {
if (window.location.hash.startsWith('#') && window.location.hash.length > 1) {
try {
const params = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
const {params, view} = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
return {
params: {
sourcePath: validateString(params?.sourcePath),
source: validateString(params?.source),
features: validateArray(params?.features, validateString),
},
view: {
layout: {
mode: validateStringEnum(view?.layout?.mode, ['multi', 'single']),
focus: validateStringEnum(view?.layout?.focus, ['editor', 'viewer', 'customizer'], s => false),
editor: validateBoolean(view?.layout['editor']),
viewer: validateBoolean(view?.layout['viewer']),
customizer: validateBoolean(view?.layout['customizer']),
}
}
};
} catch (e) {
console.error(e);

View file

@ -10,6 +10,7 @@ import {readStateFromFragment} from './fragment-state'
import { State } from './app-state';
import defaultScad from './default-scad'
import './index.css';
// import "react-widgets/styles.css";
import debug from 'debug';
const log = debug('app:log');
@ -24,16 +25,54 @@ if (process.env.NODE_ENV !== 'production') {
(async () => {
const workingDir = '/home';
const fs = await createEditorFS(workingDir);
const fs = await createEditorFS(workingDir)!;
await registerOpenSCADLanguage(fs, workingDir, zipArchives);
const initialState: State = readStateFromFragment() ?? {
type Mode = State['view']['layout']['mode'];
const mode: Mode = window.matchMedia("(min-width: 768px)").matches
? 'multi' : 'single';
const defaultSourcePath = '/home/playground.scad';
const initialState: State = {
params: {
sourcePath: defaultSourcePath,
source: defaultScad,
features: [],
}
},
view: {
layout: {
mode: 'multi',
editor: true,
viewer: true,
customizer: false,
} as any
},
...(readStateFromFragment() ?? {})
};
if (initialState.view.layout.mode != mode) {
if (mode === 'multi' && initialState.view.layout.mode === 'single') {
initialState.view.layout = {
mode,
editor: true,
viewer: true,
customizer: initialState.view.layout.focus == 'customizer'
}
} else if (mode === 'single' && initialState.view.layout.mode === 'multi') {
initialState.view.layout = {
mode,
focus: initialState.view.layout.viewer ? 'viewer'
: initialState.view.layout.customizer ? 'customizer'
: 'editor'
}
}
}
fs.writeFile(initialState.params.sourcePath, initialState.params.source);
if (initialState.params.sourcePath !== defaultSourcePath) {
fs.writeFile(defaultSourcePath, defaultScad);
}
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union'];
defaultFeatures.forEach(f => {
if (initialState.params.features.indexOf(f) < 0)
@ -45,7 +84,7 @@ if (process.env.NODE_ENV !== 'production') {
);
root.render(
<React.StrictMode>
<App initialState={initialState} />
<App initialState={initialState} fs={fs} />
</React.StrictMode>
);
})();

View file

@ -4,7 +4,7 @@ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default {
lineNumbers: 'on',
automaticLayout: true,
// automaticLayout: true,
scrollBeyondLastLine: false,
fontSize: 12,
language: 'openscad',

View file

@ -1,12 +1,12 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { checkSyntax, render, RenderArgs, RenderOutput } from "./actions";
import { State } from "./app-state";
import { MultiLayoutComponentId, SingleLayoutComponentId, State } from "./app-state";
import { bubbleUpDeepMutations } from "./deep-mutate";
import { writeStateInFragment } from "./fragment-state";
export class Model {
constructor(public state: State, private setStateCallback?: (state: State) => void) {
constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void) {
}
init() {
@ -34,6 +34,55 @@ export class Model {
return false;
}
set logsVisible(value: boolean) {
this.mutate(s => s.view.logs = value);
}
changeLayout(mode: 'multi' | 'single') {
if (this.state.view.layout.mode === mode) return;
this.mutate(s => {
s.view.layout = s.view.layout.mode === 'multi'
? {
mode: 'single',
focus: s.view.layout.editor ? 'editor' : s.view.layout.viewer ? 'viewer' : 'customizer'
}
: {
mode: 'multi',
editor: s.view.layout.focus === 'editor',
viewer: s.view.layout.focus === 'viewer',
customizer: s.view.layout.focus === 'customizer',
}
});
}
changeSingleVisibility(focus: SingleLayoutComponentId) {
this.mutate(s => {
if (s.view.layout.mode !== 'single') throw new Error('Wrong mode');
s.view.layout.focus = focus;
});
}
changeMultiVisibility(target: MultiLayoutComponentId, visible: boolean) {
this.mutate(s => {
if (s.view.layout.mode !== 'multi') throw new Error('Wrong mode');
s.view.layout[target] = visible
if ((s.view.layout.customizer ? 1 : 0) + (s.view.layout.editor ? 1 : 0) + (s.view.layout.viewer ? 1 : 0) == 0) {
// Select at least one panel
// s.view.layout.editor = true;
s.view.layout[target] = !visible;
}
})
}
openFile(path: string) {
// alert(`TODO: open ${path}`);
if (this.mutate(s => {
s.params.source = new TextDecoder("utf-8").decode(this.fs.readFileSync(path));
s.params.sourcePath = path;
})) {
this.processSource();
}
}
set source(source: string) {
if (this.mutate(s => { s.params.source = source; })) {
this.processSource();
@ -41,14 +90,20 @@ export class Model {
}
private processSource() {
const params = this.state.params;
// this.fs.writeFile(params.sourcePath, params.source);
this.checkSyntax();
this.render({isPreview: true, now: false});
}
checkSyntax() {
this.mutate(s => s.checkingSyntax = true);
checkSyntax(this.state.params.source)({now: false, callback: checkerRun => this.mutate(s => {
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 {
s.lastCheckerRun = checkerRun;
s.checkingSyntax = false;
}
})});
}
@ -62,13 +117,16 @@ export class Model {
}
this.mutate(s => setRendering(s, true));
const source = this.state.params.source;
const features = this.state.params.features;
const renderArgs = {source, features, extraArgs: [], isPreview};
// console.log('renderArgs', renderArgs);
const {source, sourcePath, features} = this.state.params;
render(renderArgs)({now, callback: output => {
render({source, sourcePath, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
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.lastCheckerRun = {
logText: output.logText,
markers: output.markers,
@ -85,7 +143,7 @@ export class Model {
formattedElapsedMillis: formatMillis(output.elapsedMillis),
formattedStlFileSize: formatBytes(output.stlFile.size),
};
setRendering(s, false);
}
});
}})
}

View file

@ -18,6 +18,7 @@ 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][],
args: string[],
outputPaths?: string[],

View file

@ -2,13 +2,13 @@
import OpenSCAD from "./wasm/openscad.js";
import { getBrowserFSLibrariesMounts, symlinkLibraries } from "./filesystem";
import { getBrowserFSLibrariesMounts, getParentDir, symlinkLibraries } from "./filesystem";
import { OpenSCADInvocation, OpenSCADInvocationResults } from "./openscad-runner";
import { zipArchives } from "./zip-archives";
declare var BrowserFS: BrowserFSInterface
// importScripts("browserfs.min.js");
importScripts("https://cdnjs.cloudflare.com/ajax/libs/BrowserFS/2.0.0/browserfs.min.js");
importScripts("browserfs.min.js");
// importScripts("https://cdnjs.cloudflare.com/ajax/libs/BrowserFS/2.0.0/browserfs.min.js");
const allArchiveNames = Object.keys(zipArchives)
const allZipMountsPromise = getBrowserFSLibrariesMounts(allArchiveNames);
@ -17,7 +17,7 @@ export type MergedOutputs = {stdout?: string, stderr?: string, error?: string}[]
addEventListener('message', async (e) => {
const { inputs, args, outputPaths, wasmMemory } = e.data as OpenSCADInvocation;
const { inputs, args, outputPaths, wasmMemory, workingDir } = e.data as OpenSCADInvocation;
const mergedOutputs: MergedOutputs = [];
try {
@ -33,8 +33,14 @@ addEventListener('message', async (e) => {
// console.log('stderr: ' + text);
mergedOutputs.push({ stderr: text })
},
ENV: {
OPENSCADPATH: '/libraries:/home'
}
});
// console.error('INSTANCE')
// console.error(Object.keys(instance))
// await browserFSInit;
await new Promise(async (resolve, reject) => {
BrowserFS.install(self);
@ -50,7 +56,8 @@ addEventListener('message', async (e) => {
const archiveNames = allArchiveNames;
// instance.FS.mkdir('tmp');
instance.FS.mkdir('/tmp/run');
// instance.FS.mkdir('/home');
// instance.FS.mkdir('/tmp/run');
instance.FS.mkdir('/libraries');
// await browserFSInit;
@ -64,9 +71,13 @@ addEventListener('message', async (e) => {
}, instance.ERRNO_CODES ?? {});
instance.FS.mount(BFS, {root: '/'}, '/libraries');
await symlinkLibraries(archiveNames, instance.FS, '/libraries', '/tmp/run');
// await symlinkLibraries(archiveNames, instance.FS, '/libraries', '/tmp/run');
await symlinkLibraries(archiveNames, instance.FS, '/libraries', '/home');
instance.FS.chdir('/tmp/run');
// const
// instance.FS.chdir('/tmp/run');
instance.FS.chdir('/home');
// instance.FS.chdir(workingDir);
// console.log('.', instance.FS.readdir('.'));
// console.log('fonts', instance.FS.readdir('fonts'));
@ -74,11 +85,16 @@ addEventListener('message', async (e) => {
if (inputs) {
for (const [path, content] of inputs) {
try {
// const parent = getParentDir(path);
instance.FS.writeFile(path, content);
} catch (e) {
console.error(`Error while trying to write ${path}`, e);
}
}
}
console.debug('Calling main ', args)
console.error('Calling main ', args)
const start = performance.now();
const exitCode = instance.callMain(args);
const end = performance.now();
@ -94,8 +110,7 @@ addEventListener('message', async (e) => {
postMessage(result);
} catch (e) {
console.error(e);
console.trace(e);//, e instanceof Error ? e.stack : '');
const error = `${e}`;
mergedOutputs.push({ error });
postMessage({

View file

@ -29,7 +29,7 @@ export function turnIntoDelayableExecution<T extends any[], R>(
let pendingId: number | null;
let runningJobKillSignal: (() => void) | null;
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result: R) => void}) => {
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result?: R, error?: any) => void}) => {
const doExecute = async () => {
if (runningJobKillSignal) {
runningJobKillSignal();
@ -39,6 +39,8 @@ export function turnIntoDelayableExecution<T extends any[], R>(
runningJobKillSignal = abortablePromise.kill;
try {
callback(await abortablePromise);
} catch (e) {
callback(undefined, e);
} finally {
runningJobKillSignal = null;
}
@ -55,6 +57,12 @@ export function turnIntoDelayableExecution<T extends any[], R>(
};
}
export function validateStringEnum<T extends string>(
s: T, values: T[],
orElse: (s: string) => T = s => { throw new Error(`Unexpected value: ${s} (valid values: ${values.join(', ')})`); }): T {
return values.indexOf(s) < 0 ? orElse(s) : s;
}
export const validateBoolean = (s: boolean, orElse: () => boolean = () => false) => typeof s === 'boolean' ? s : orElse();
export const validateString = (s: string, orElse: () => string = () => '') => s != null && typeof s === 'string' ? s : orElse();
export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
if (!(a instanceof Array)) return orElse();

View file

@ -21,10 +21,10 @@ module.exports = {
}
]
},
{
test: /\.(png|gif|woff|woff2|eot|ttf|svg)$/,
loader: "url-loader?limit=100000"
},
// {
// test: /\.(png|gif|woff|woff2|eot|ttf|svg)$/,
// loader: "url-loader?limit=100000"
// },
],
},
resolve: {