Add react-stl-viewer + fix preview vs. render flow

This commit is contained in:
ochafik 2023-03-25 15:23:54 +00:00
commit 42d0d0a5d2
17 changed files with 313 additions and 109 deletions

View file

@ -36,3 +36,10 @@
transform: rotate(360deg);
}
}
.logs-container {
overflow-y: scroll;
height: calc(min(200px, 30vh));
position: relative;
}

View file

@ -1,55 +1,103 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import React, { useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import {ModelContext, State} from './app-state'
import Editor, { loader, Monaco } from '@monaco-editor/react';
import './App.css';
import openscadEditorOptions from './language/openscad-editor-options';
import { Model } from './model';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import {StlViewer} from "react-stl-viewer";
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)
if (editor) {
const checkerRun = model.state.lastCheckerRun;
const editorModel = editor.getModel();
if (editorModel && checkerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', checkerRun.markers);
}
}
const onMount = (editor: monaco.editor.IStandaloneCodeEditor) => {
editor.addAction({
id: "openscad-render",
label: "Render OpenSCAD",
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter,
monaco.KeyCode.F6,
],
run: () => model.render()
});
setEditor(editor)
}
return (
<div className="editor-panel" style={{
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<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"/>
<div className="logs-container">
<pre><code id="logs">{model.state.lastCheckerRun?.logText ?? 'No log yet!'}</code></pre>
</div>
</div>
)
}
export function App({initialState}: {initialState: State}) {
const [state, setState] = useState(initialState);
const [editor, setEditor] = useState(null as monaco.editor.IStandaloneCodeEditor | null)
const model = new Model(state, setState);
if (editor) {
const editorModel = editor.getModel();
if (editorModel && state.checkerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', state.checkerRun.markers);
if (editorModel && state.lastCheckerRun) {
monacoInstance.editor.setModelMarkers(editorModel, 'openscad', state.lastCheckerRun.markers);
}
}
const source = model.source;
const model = new Model(state, setState);
useEffect(() => model.init());
return (
<ModelContext.Provider value={model}>
<div className="App">
<header className="App-header">
<img src="logo.png" className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://openscad.org"
target="_blank"
rel="noopener noreferrer"
>
Learn OpenSCAD
</a>
<Editor
className="openscad-editor"
defaultLanguage="openscad"
value={source}
onChange={s => model.source = s ?? ''}
onMount={e => setEditor(e)} // TODO: This looks a bit silly, does it trigger a re-render??
options={openscadEditorOptions}
height="50vh"/>
</header>
<div style={{display: 'flex', flexDirection: 'column', flex: 1}}>
<div style={{display: 'flex', flexDirection: 'row', flex: 1}}>
<div style={{width: "50vw"}}>
<EditorPanel/>
</div>
{state.output?.stlFileURL &&
<StlViewer
style={{
flex: 1
}}
showAxes={true}
orbitControls
shadows
url={state.output?.stlFileURL ?? ''}
/>
}
</div>
<div style={{display: 'flex', flexDirection: 'row'}}>
<button onClick={() => model.render()}>Render</button>
{model.state.previewing && 'previewing... '}
{model.state.rendering && 'rendering... '}
{model.state.checkingSyntax && 'checking syntax... '}
</div>
</div>
</ModelContext.Provider>
);

View file

@ -1,12 +1,15 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { FS } from "./filesystem";
// import { FS } from "./filesystem";
interface EmscriptenFS extends FS {
};
declare interface FS {
readdir(path: string, cb: (err: any, files: string[]) => void): void;
symlink(target: string, source: string): void;
}
export let BrowserFS = (window as any)['BrowserFS'] as {
declare interface EmscriptenFS extends FS {}
declare type BrowserFSInterface = {
BFSRequire: (name: string) => any,
install: (windowOrSelf: Window) => void,

View file

@ -2,19 +2,22 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { spawnOpenSCAD } from "./openscad-runner";
import { joinMergedOutputs, parseMergedOutputs } from "./output-parser";
import { processMergedOutputs } from "./output-parser";
import { AbortablePromise, turnIntoDelayableExecution } from './utils';
const syntaxDelay = 300;
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[]};
export const checkSyntax = (source: string, callback: (out: SyntaxCheckOutput) => void) =>
turnIntoDelayableExecution(syntaxDelay, () => {
// const timestamp = Date.now();
export const checkSyntax =
turnIntoDelayableExecution(syntaxDelay, (source: string) => {
// const timestamp = Date.now();
source = '$preview=true;\n' + source;
const sourceFile = 'input.scad';
const job = spawnOpenSCAD({
inputs: [['input.scad', source + '\n']],
args: ["input.scad", "-o", "out.ast"],
inputs: [[sourceFile, source + '\n']],
args: [sourceFile, "-o", "out.ast"],
});
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
@ -22,9 +25,7 @@ export const checkSyntax = (source: string, callback: (out: SyntaxCheckOutput) =
try {
const result = await job;
// console.log(result);
const logText = joinMergedOutputs(result.mergedOutputs);
const markers = parseMergedOutputs(result.mergedOutputs);
res({logText, markers});
res(processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {[sourceFile]: 1}}));
} catch (e) {
console.error(e);
rej(e);
@ -32,25 +33,34 @@ export const checkSyntax = (source: string, callback: (out: SyntaxCheckOutput) =
})()
return () => job.kill();
});
}, callback);
var sourceFileName;
// var editor;
});
var renderDelay = 1000;
type RenderOutput = {stlFile: File, logText: string, markers: monaco.editor.IMarkerData[], elapsedMillis?: number}
export type RenderOutput = {stlFile: File, logText: string, markers: monaco.editor.IMarkerData[], elapsedMillis?: number}
export const render = (source: string, features: string[], callback: (result: RenderOutput) => void) =>
turnIntoDelayableExecution(renderDelay, () => {
export type RenderArgs = {
source: string,
features?: string[],
extraArgs?: string[],
isPreview?: boolean
}
export const render =
turnIntoDelayableExecution(renderDelay, (args: RenderArgs) => {
const prefixLines: string[] = [];
if (args.isPreview) prefixLines.push('$preview=false;');
const source = args.isPreview ? [...prefixLines, args.source].join('\n') : args.source
const inputFile = 'input.scad';
const job = spawnOpenSCAD({
// wasmMemory,
inputs: [['input.scad', source]],
args: [
"input.scad",
inputFile,
"-o", "out.stl",
"--export-format=binstl",
...features.map(f => `--enable=${f}`),
...(args.features ?? []).map(f => `--enable=${f}`),
...(args.extraArgs ?? [])
],
outputPaths: ['out.stl']
});
@ -61,8 +71,9 @@ export const render = (source: string, features: string[], callback: (result: Re
const result = await job;
console.log(result);
const logText = joinMergedOutputs(result.mergedOutputs);
const markers = parseMergedOutputs(result.mergedOutputs);
const {logText, markers} = processMergedOutputs(result.mergedOutputs, {
shiftSourceLines: {[inputFile]: prefixLines.length}
});
if (result.error) {
reject(result.error);
@ -87,4 +98,4 @@ export const render = (source: string, features: string[], callback: (result: Re
return () => job.kill()
});
}, callback);
});

View file

@ -8,23 +8,27 @@ import { Model } from "./model"
export interface State {
params: {
source: string,
features: string[],
},
checkerRun?: {
lastCheckerRun?: {
logText: string,
markers: monaco.editor.IMarkerData[]
}
rendering?: boolean,
previewing?: boolean,
checkingSyntax?: boolean,
output?: {
path: string,
timestamp: number,
sizeBytes: number,
formattedSize: string,
stlFile: File,
stlFileURL: string,
},
};
export const ModelContext = createContext(new Model(
{
params: {
source: ''
source: '',
features: ['manifold', 'lazy-union'],
}
},
() => { throw new Error('Not implemented'); }

View file

@ -1,18 +1,19 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { State } from "./app-state";
import { validateString } from "./utils";
import { validateArray, validateString } from "./utils";
export function writeStateInFragment(state: State) {
window.location.hash = encodeURIComponent(JSON.stringify(state));
window.location.hash = encodeURIComponent(JSON.stringify(state.params));
}
export function readStateFromFragment(): State | null {
if (window.location.hash.startsWith('#') && window.location.hash.length > 1) {
try {
const state = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
const params = JSON.parse(decodeURIComponent(window.location.hash.substring(1)));
return {
params: {
source: validateString(state.params?.source),
source: validateString(params?.source),
features: validateArray(params?.features, validateString),
},
};
} catch (e) {

View file

@ -2,7 +2,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import {App} from './App';
import reportWebVitals from './reportWebVitals';
import { createEditorFS } from './filesystem';
@ -10,6 +9,7 @@ import { registerOpenSCADLanguage } from './language/openscad-register-language'
import { zipArchives } from './zip-archives';
import {readStateFromFragment} from './fragment-state'
import { State } from './app-state';
import './index.css';
(async () => {
@ -23,6 +23,12 @@ import { State } from './app-state';
}
} as State;
const defaultFeatures = ['manifold', 'fast-csg', 'lazy-union'];
defaultFeatures.forEach(f => {
if (initialState.params.features.indexOf(f) < 0)
initialState.params.features.push(f);
});
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);

View file

@ -1,4 +1,5 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
export default `
$fa=undef;
$fs=undef;

View file

@ -1,7 +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 { FS, readDirAsArray, Symlinks } from '../filesystem';
import { readDirAsArray, Symlinks } from '../filesystem';
import { ParsedFile, ParsedFunctionoidDef, parseOpenSCAD, stripComments } from './openscad-pseudoparser';
import builtinSignatures from './openscad-builtins'
import { mapObject } from '../utils';

View file

@ -1,25 +1,32 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { checkSyntax } from "./actions";
import { checkSyntax, render, RenderArgs, RenderOutput } from "./actions";
import { State } from "./app-state";
import { bubbleUpDeepMutations } from "./deep-mutate";
import { writeStateInFragment } from "./fragment-state";
export class Model {
constructor(private state: State, private setState_: (state: State) => void) {}
constructor(public state: State, private setStateCallback?: (state: State) => void) {
}
init() {
if (!this.state.output && !this.state.lastCheckerRun && !this.state.previewing && !this.state.checkingSyntax && !this.state.rendering &&
this.state.params.source.trim() != '') {
this.processSource();
}
}
private setState(state: State) {
this.state = state;
this.setState_(state);
writeStateInFragment(state);
this.setStateCallback && this.setStateCallback(state);
}
mutate(f: (state: State) => void) {
const state = this.state;
const mutated = bubbleUpDeepMutations(state, f);
const mutated = bubbleUpDeepMutations(this.state, f);
// No matter how deep the mutation happened, the top-level object's identity
// will have changed iff the mutated values are different.
if (mutated !== state) {
if (mutated !== this.state) {
this.setState(mutated);
return true;
}
@ -27,16 +34,73 @@ export class Model {
return false;
}
get source(): string {
return this.state.params.source;//.source, this.state.editor;//params.source.content;
}
// get features() { return this.state_.params.features; }
set source(source) {
if (this.mutate(s => s.params.source = source)) {
checkSyntax(source, checkerRun => this.mutate(s => s.checkerRun = checkerRun))({now: false});
// get source(): string { return this.state_.params.source; }
set source(source: string) {
if (this.mutate(s => { s.params.source = source; })) {
this.processSource();
}
}
private processSource() {
this.mutate(s => {
s.previewing = true;
s.checkingSyntax = true;
});
checkSyntax(this.state.params.source)({now: false, callback: checkerRun => this.mutate(s => {
s.lastCheckerRun = checkerRun;
s.checkingSyntax = false;
})});
render({...this.renderArgs, isPreview: true})({now: false, callback: output => this.handleRenderOutput(output, s => {
s.previewing = 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;
// })});
// }
// preview() {
// this.mutate(s => s.previewing = true);
// render({...this.renderArgs, isPreview: true})({now: false, callback: output => this.handleRenderOutput(output, s => {
// s.previewing = false;
// })});
// }
private handleRenderOutput(output: RenderOutput, extraMutations: (s: State) => void) {
this.mutate(s => {
s.lastCheckerRun = {
logText: output.logText,
markers: output.markers,
}
if (s.output?.stlFileURL) {
URL.revokeObjectURL(s.output.stlFileURL);
}
s.output = {
stlFile: output.stlFile,
stlFileURL: URL.createObjectURL(output.stlFile),
};
extraMutations(s);
});
}
private get renderArgs(): RenderArgs {
const source = this.state.params.source;
const features = this.state.params.features;
return {source, features, extraArgs: ['-D$preview=true']};
}
render() {
this.mutate(s => s.rendering = true);
render(this.renderArgs)({now: true, callback: output => this.handleRenderOutput(output, s => {
s.rendering = false;
})})
}
}

View file

@ -1,16 +1,32 @@
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { MergedOutputs } from "./openscad-worker";
export function joinMergedOutputs(mergedOutputs: MergedOutputs) {
const ignoredLogs = new Set([
'Could not initialize localization.'
]);
type MergedOutputsOptions = {
shiftSourceLines: {[path: string]: number}
}
export const processMergedOutputs = (outputs: MergedOutputs, opts: MergedOutputsOptions) => ({
logText: joinMergedOutputs(outputs, opts),
markers: parseMergedOutputs(outputs, opts)
});
export function joinMergedOutputs(mergedOutputs: MergedOutputs, opts: MergedOutputsOptions) {
let allLines = [];
for (const {stderr, stdout, error} of mergedOutputs){
allLines.push(stderr ?? stdout ?? `EXCEPTION: ${error}`);
const line = stderr ?? stdout ?? `EXCEPTION: ${error}`;
if (ignoredLogs.has(line)) {
continue;
}
allLines.push(line);
}
return allLines.join("\n");
}
export function parseMergedOutputs(mergedOutputs: MergedOutputs): monaco.editor.IMarkerData[] {
export function parseMergedOutputs(mergedOutputs: MergedOutputs, opts: MergedOutputsOptions): monaco.editor.IMarkerData[] {
let unmatchedLines = [];
const markers = [];
@ -25,6 +41,10 @@ export function parseMergedOutputs(mergedOutputs: MergedOutputs): monaco.editor.
severity: monaco.MarkerSeverity.Error
})
}
const getLine = (path: string, lineStr: string) => {
const shift = opts.shiftSourceLines[path] ?? 0;
return Number(lineStr) - shift;
}
for (const {stderr, stdout, error} of mergedOutputs){
if (stderr) {
if (stderr.startsWith('ERROR:')) errorCount++;
@ -33,14 +53,14 @@ export function parseMergedOutputs(mergedOutputs: MergedOutputs): monaco.editor.
let m = /^ERROR: Parser error in file "([^"]+)", line (\d+): (.*)$/.exec(stderr)
if (m) {
const [_, file, line, error] = m
addError(error, file, Number(line));
addError(error, file, getLine(file, line));
continue;
}
m = /^ERROR: Parser error: (.*?) in file ([^",]+), line (\d+)$/.exec(stderr)
if (m) {
const [_, error, file, line] = m
addError(error, file, Number(line));
addError(error, file, getLine(file, line));
continue;
}
@ -48,9 +68,9 @@ export function parseMergedOutputs(mergedOutputs: MergedOutputs): monaco.editor.
if (m) {
const [_, warning, file, line] = m
markers.push({
startLineNumber: Number(line),
startLineNumber: getLine(file, line),
startColumn: 1,
endLineNumber: Number(line),
endLineNumber: getLine(file, line),
endColumn: 1000,
message: warning,
severity: monaco.MarkerSeverity.Warning

View file

@ -22,25 +22,27 @@ export function AbortablePromise<T>(f: (resolve: (result: T) => void, reject: (e
return Object.assign(promise, {kill: kill!});
}
export function turnIntoDelayableExecution<T>(delay: number, job: () => AbortablePromise<T>, callback: (result: T) => void) {
// <T extends any[]>(...args: T)
export function turnIntoDelayableExecution<T extends any[], R>(
delay: number,
job: (...args: T) => AbortablePromise<R>) {
let pendingId: number | null;
let runningJobKillSignal: (() => void) | null;
const doExecute = async () => {
if (runningJobKillSignal) {
runningJobKillSignal();
runningJobKillSignal = null;
return (...args: T) => async ({now, callback}: {now: boolean, callback: (result: R) => void}) => {
const doExecute = async () => {
if (runningJobKillSignal) {
runningJobKillSignal();
runningJobKillSignal = null;
}
const abortablePromise = job(...args);
runningJobKillSignal = abortablePromise.kill;
try {
callback(await abortablePromise);
} finally {
runningJobKillSignal = null;
}
}
const abortablePromise = job();
runningJobKillSignal = abortablePromise.kill;
try {
callback(await abortablePromise);
} finally {
runningJobKillSignal = null;
}
}
return async ({now}: {now: boolean}) => {
if (pendingId) {
clearTimeout(pendingId);
pendingId = null;
@ -54,7 +56,7 @@ export function turnIntoDelayableExecution<T>(delay: number, job: () => Abortabl
}
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[]) => {
export const validateArray = <T>(a: Array<T>, validateElement: (e: T) => T, orElse: () => T[] = () => []) => {
if (!(a instanceof Array)) return orElse();
return a.map(validateElement);
}