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

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 model = useContext(ModelContext);
const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null)
const isFileWritable = (path: string) => getParentDir(path) === '/home'
if (editor) {
const checkerRun = model.state.lastCheckerRun;
const editorModel = editor.getModel();
if (editorModel && checkerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', checkerRun.markers);
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 = state.lastCheckerRun;
const editorModel = editor.getModel();
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,98 +113,436 @@ 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 ?? {})
}}>
<Editor
className="openscad-editor"
defaultLanguage="openscad"
value={model.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"/>
{/* <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 absolute-fill"
defaultLanguage="openscad"
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,
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 className="logs-container">
<pre><code id="logs">{model.state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
</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)
const state = model.state;
return (
<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}
orbitControls
shadows
modelProps={{
color: '#f9d72c',
}}
url={state.output?.stlFileURL ?? ''}
/>
}
</div>
)
}
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');
if (editor) {
const editorModel = editor.getModel();
if (editorModel && state.lastCheckerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', state.lastCheckerRun.markers);
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>
{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 model = new Model(state, setState);
useEffect(() => model.init());
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]),
}
}
}
// const stlModelRef = forwardRef();
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>}
{state.output?.stlFileURL &&
<StlViewer
style={{
flex: 1
}}
// ref={stlModelRef}
showAxes={true}
orbitControls
shadows
modelProps={{
color: '#f9d72c',
<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',
// }}
>
}}
url={state.output?.stlFileURL ?? ''}
/>
}
</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>
)}
<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>
{/* <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>
</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 => {
s.lastCheckerRun = checkerRun;
s.checkingSyntax = false;
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,30 +117,33 @@ 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);
render(renderArgs)({now, callback: output => {
const {source, sourcePath, features} = this.state.params;
render({source, sourcePath, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
this.mutate(s => {
s.lastCheckerRun = {
logText: output.logText,
markers: output.markers,
}
if (s.output?.stlFileURL) {
URL.revokeObjectURL(s.output.stlFileURL);
}
s.output = {
isPreview: isPreview,
stlFile: output.stlFile,
stlFileURL: URL.createObjectURL(output.stlFile),
elapsedMillis: output.elapsedMillis,
formattedElapsedMillis: formatMillis(output.elapsedMillis),
formattedStlFileSize: formatBytes(output.stlFile.size),
};
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,
}
if (s.output?.stlFileURL) {
URL.revokeObjectURL(s.output.stlFileURL);
}
s.output = {
isPreview: isPreview,
stlFile: output.stlFile,
stlFileURL: URL.createObjectURL(output.stlFile),
elapsedMillis: output.elapsedMillis,
formattedElapsedMillis: formatMillis(output.elapsedMillis),
formattedStlFileSize: formatBytes(output.stlFile.size),
};
}
});
}})
}

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);
@ -49,8 +55,9 @@ addEventListener('message', async (e) => {
const archiveNames = allArchiveNames;
// instance.FS.mkdir('tmp');
instance.FS.mkdir('/tmp/run');
// instance.FS.mkdir('tmp');
// 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) {
instance.FS.writeFile(path, content);
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();
@ -93,9 +109,8 @@ addEventListener('message', async (e) => {
console.debug(result);
postMessage(result);
} catch (e) {
console.error(e);
} catch (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();