add customizer

This commit is contained in:
ochafik 2024-07-08 02:21:33 +01:00
commit 76492bc903
10 changed files with 341 additions and 16 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ public/openscad.js
public/openscad.wasm
src/wasm
package-lock.json
.DS_Store

View file

@ -9,6 +9,7 @@ import Footer from './Footer';
import { ModelContext, FSContext } from './contexts';
import PanelSwitcher from './PanelSwitcher';
import { ConfirmDialog } from 'primereact/confirmdialog';
import CustomizerPanel from './CustomizerPanel';
// import "primereact/resources/themes/lara-light-indigo/theme.css";
@ -30,11 +31,11 @@ export function App({initialState, statePersister, fs}: {initialState: State, st
viewer: {
editor: 2,
viewer: 3,
customizer: 2,
customizer: 1,
},
customizer: {
editor: 0,
viewer: 0,
viewer: 1,
customizer: 3,
}
}
@ -78,7 +79,11 @@ export function App({initialState, statePersister, fs}: {initialState: State, st
${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')} /> */}
<CustomizerPanel className={`
opacity-animated
${layout.mode === 'single' && layout.focus !== 'customizer' ? 'opacity-0' : ''}
${layout.mode === 'single' ? `absolute-fill` : ''}
`} style={getPanelStyle('customizer')} />
</div>
<Footer />

View file

@ -0,0 +1,219 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
import { CSSProperties, useContext } from 'react';
import { ModelContext, FSContext } from './contexts';
import { Dropdown } from 'primereact/dropdown';
import { Slider } from 'primereact/slider';
import { Checkbox } from 'primereact/checkbox';
import { InputNumber } from 'primereact/inputnumber';
import { InputText } from 'primereact/inputtext';
import { Fieldset } from 'primereact/fieldset';
import { Parameter } from '../state/customizer-types';
import { Button } from 'primereact/button';
export default function CustomizerPanel({className, style}: {className?: string, style?: CSSProperties}) {
const model = useContext(ModelContext);
if (!model) throw new Error('No model');
const state = model.state;
const handleChange = (name: string, value: any) => {
model.setVar(name, value);
};
const groupedParameters = (state.parameterSet?.parameters ?? []).reduce((acc, param) => {
if (!acc[param.group]) {
acc[param.group] = [];
}
acc[param.group].push(param);
return acc;
}, {} as { [key: string]: any[] });
const groups = Object.entries(groupedParameters);
const collapsedTabSet = new Set(state.view.collapsedCustomizerTabs ?? []);
const setTabOpen = (name: string, open: boolean) => {
if (open) {
collapsedTabSet.delete(name);
} else {
collapsedTabSet.add(name)
}
model.mutate(s => s.view.collapsedCustomizerTabs = Array.from(collapsedTabSet));
}
return (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
overflow: 'scroll',
...style,
bottom: 'unset',
}}>
{groups.map(([group, params]) => (
<Fieldset
style={{
margin: '5px 10px 5px 10px',
// backgroundColor: 'transparent',
backgroundColor: 'rgba(255,255,255,0.4)',
}}
onCollapse={() => setTabOpen(group, false)}
onExpand={() => setTabOpen(group, true)}
collapsed={collapsedTabSet.has(group)}
key={group}
legend={group}
toggleable={true}>
{params.map((param) => (
<ParameterInput
key={param.name}
value={(state.params.vars ?? {})[param.name]}
param={param}
handleChange={handleChange} />
))}
</Fieldset>
))}
</div>
);
};
function ParameterInput({param, value, className, style, handleChange}: {param: Parameter, value: any, className?: string, style?: CSSProperties, handleChange: (key: string, value: any) => void}) {
return (
<div
// ref={ref}
// onClick={state.view.layout.mode === 'single' ? handleClick : undefined}
style={{
flex: 1,
...style,
display: 'flex',
flexDirection: 'column',
}}>
<div
style={{
flex: 1,
display: 'flex',
margin: '10px -10px 10px 5px',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div
// onClick={swallowClick}
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
}}>
<label><b>{param.name}</b></label>
<div>{param.caption}</div>
</div>
<div
// onClick={swallowClick}
style={{
// flex: 1,
// margin: '10px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}>
{param.type === 'number' && 'options' in param && (
<Dropdown
style={{flex: 1}}
value={value || param.initial}
options={param.options}
onChange={(e) => handleChange(param.name, e.value)}
optionLabel="name"
optionValue="value"
/>
)}
{param.type === 'string' && param.options && (
<Dropdown
// style={{flex: 1}}
value={value || param.initial}
options={param.options}
onChange={(e) => handleChange(param.name, e.value)}
optionLabel="name"
optionValue="value"
/>
)}
{param.type === 'boolean' && (
<Checkbox
// style={{flex: 1}}
checked={value ?? param.initial}
onChange={(e) => handleChange(param.name, e.checked)}
/>
)}
{!Array.isArray(param.initial) && param.type === 'number' && !('options' in param) && (
<InputNumber
// style={{flex: 1}}
value={value || param.initial}
showButtons
size={5}
// buttonLayout='horizontal'
onValueChange={(e) => handleChange(param.name, e.value)}
/>
)}
{param.type === 'string' && !param.options && (
<InputText
style={{flex: 1}}
value={value || param.initial}
onChange={(e) => handleChange(param.name, e.target.value)}
/>
)}
{Array.isArray(param.initial) && 'min' in param && (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'row',
}}>
{param.initial.map((_, index) => (
<InputNumber
style={{flex: 1}}
key={index}
value={value?.[index] ?? (param.initial as any)[index]}
min={param.min}
max={param.max}
showButtons
// buttonLayout='horizontal'
size={5}
step={param.step}
onValueChange={(e) => {
const newArray = [...(value ?? param.initial)];
newArray[index] = e.value;
handleChange(param.name, newArray);
}}
/>
))}
</div>
)}
<Button
onClick={() => handleChange(param.name, param.initial)}
style={{
marginRight: '0',
visibility: value === undefined || (JSON.stringify(value) === JSON.stringify(param.initial)) ? 'hidden' : 'visible',
}}
// tooltip={`Reset to default value (${JSON.stringify(param.initial)})`}
tooltipOptions={{position: 'left'}}
icon='pi pi-refresh'
className='p-button-text'/>
</div>
</div>
{!Array.isArray(param.initial) && param.type === 'number' && param.min !== undefined && (
<Slider
style={{
flex: 1,
minHeight: '5px',
margin: '5px 40px 5px 5px',
}}
value={value || param.initial}
min={param.min}
max={param.max}
step={param.step}
onChange={(e) => handleChange(param.name, e.value)}
/>
)}
</div>
);
}

View file

@ -28,6 +28,13 @@ export default function HelpMenu() {
url: 'http://openscad.org/documentation.html',
target: '_blank'
},
{
label: 'Customizer Syntax',
icon: 'pi pi-book',
url: 'https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Customizer',
target: '_blank'
},
{
label: 'OpenSCAD Cheatsheet',
icon: 'pi pi-palette',

View file

@ -16,8 +16,10 @@ export default function PanelSwitcher() {
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-sliders-h', label: 'Customize' },
];
if ((state.parameterSet?.parameters?.length ?? 0) > 0) {
singleTargets.push({ id: 'customizer', icon: 'pi pi-sliders-h', label: 'Customize' });
}
const multiTargets = singleTargets;
return (
@ -45,7 +47,6 @@ export default function PanelSwitcher() {
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)}
/>
@ -59,7 +60,7 @@ export default function PanelSwitcher() {
// justifyContent: 'center'
}}
model={singleTargets.map(({icon, label, id}) =>
({icon, label, disabled: id === 'customizer', command: () => model.changeSingleVisibility(id)}))} />
({icon, label/*, disabled: id === 'customizer' && state?.parameterSet == null*/, command: () => model.changeSingleVisibility(id)}))} />
</>
}
<SettingsMenu style={{

View file

@ -5,20 +5,23 @@ import { getParentDir } from '../fs/filesystem';
import { spawnOpenSCAD } from "./openscad-runner";
import { processMergedOutputs } from "./output-parser";
import { AbortablePromise, turnIntoDelayableExecution } from '../utils';
import { ParameterSet } from '../state/customizer-types';
const syntaxDelay = 300;
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[]};
type SyntaxCheckOutput = {logText: string, markers: monaco.editor.IMarkerData[], parameterSet?: ParameterSet};
export const checkSyntax =
turnIntoDelayableExecution(syntaxDelay, (source: string, sourcePath: string) => {
// const timestamp = Date.now();
source = '$preview=true;\n' + source;
const outFile = 'out.json';
const job = spawnOpenSCAD({
inputs: [[sourcePath, source + '\n']],
args: [sourcePath, "-o", "out.ast"],
args: [sourcePath, "-o", outFile, "--export-format=param"],
// workingDir: sourcePath.startsWith('/') ? getParentDir(sourcePath) : '/home'
outputPaths: [outFile],
});
return AbortablePromise<SyntaxCheckOutput>((res, rej) => {
@ -26,10 +29,28 @@ export const checkSyntax =
try {
const result = await job;
// console.log(result);
res(processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
let parameterSet: ParameterSet | undefined = undefined;
if (result.outputs && result.outputs.length == 1) {
let [[, content]] = result.outputs;
content = new TextDecoder().decode(content as any);
try {
parameterSet = JSON.parse(content)
// console.log('PARAMETER SET', JSON.stringify(parameterSet, null, 2))
} catch (e) {
console.error(`Error while parsing parameter set: ${e}\n${content}`);
}
} else {
console.error('No output from runner!');
}
res({
...processMergedOutputs(result.mergedOutputs, {shiftSourceLines: {
sourcePath,
skipLines: 1,
}}));
}}),
parameterSet,
});
} catch (e) {
console.error(e);
rej(e);
@ -45,12 +66,23 @@ export type RenderOutput = {stlFile: File, logText: string, markers: monaco.edit
export type RenderArgs = {
source: string,
sourcePath: string,
vars?: {[name: string]: any},
features?: string[],
extraArgs?: string[],
isPreview: boolean
}
function formatValue(any: any): string {
if (typeof any === 'string') {
return `"${any}"`;
} else if (any instanceof Array) {
return `[${any.map(formatValue).join(', ')}]`;
} else {
return `${any}`;
}
}
export const render =
turnIntoDelayableExecution(renderDelay, ({sourcePath, source, isPreview, features, extraArgs}: RenderArgs) => {
turnIntoDelayableExecution(renderDelay, ({sourcePath, source, isPreview, vars, features, extraArgs}: RenderArgs) => {
const prefixLines: string[] = [];
if (isPreview) {
@ -62,6 +94,7 @@ export const render =
sourcePath,
"-o", "out.stl",
"--export-format=binstl",
...(Object.entries(vars ?? {}).flatMap(([k, v]) => [`-D${k}=${formatValue(v)}`])),
...(features ?? []).map(f => `--enable=${f}`),
...(extraArgs ?? [])
]

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 { ParameterSet } from './customizer-types';
export type MultiLayoutComponentId = 'editor' | 'viewer' | 'customizer';
export type SingleLayoutComponentId = MultiLayoutComponentId;
@ -9,6 +10,7 @@ export interface State {
params: {
sourcePath: string,
source: string,
vars?: {[name: string]: any},
features: string[],
},
@ -21,6 +23,8 @@ export interface State {
mode: 'multi',
} & { [K in MultiLayoutComponentId]: boolean })
collapsedCustomizerTabs?: string[],
color: string,
showAxes?: boolean,
showShadows?: boolean,
@ -35,6 +39,7 @@ export interface State {
previewing?: boolean,
checkingSyntax?: boolean,
parameterSet?: ParameterSet,
error?: string,
output?: {
isPreview: boolean,

View file

@ -0,0 +1,46 @@
export type ParameterOption = {
name: string;
value: number | string;
};
export type BaseParameter = {
caption: string;
group: string;
name: string;
type: 'number' | 'string' | 'boolean';
};
export type NumberParameter = BaseParameter & {
type: 'number';
initial: number;
min?: number;
max?: number;
step?: number;
options?: ParameterOption[];
};
export type StringParameter = BaseParameter & {
type: 'string';
initial: string;
options?: ParameterOption[];
};
export type BooleanParameter = BaseParameter & {
type: 'boolean';
initial: boolean;
};
export type VectorParameter = BaseParameter & {
type: 'number';
initial: number[];
min: number;
max: number;
step: number;
};
export type Parameter = NumberParameter | StringParameter | BooleanParameter | VectorParameter;
export type ParameterSet = {
parameters: Parameter[];
title: string;
};

View file

@ -63,6 +63,7 @@ export async function readStateFromFragment(): Promise<State | null> {
sourcePath: validateString(params?.sourcePath),
source: validateString(params?.source),
features: validateArray(params?.features, validateString),
vars: params?.vars, // TODO: validate!
},
view: {
layout: {
@ -72,6 +73,7 @@ export async function readStateFromFragment(): Promise<State | null> {
viewer: validateBoolean(view?.layout['viewer']),
customizer: validateBoolean(view?.layout['customizer']),
},
collapsedCustomizerTabs: validateArray(view?.collapsedCustomizerTabs, validateString),
color: validateString(view?.color, () => defaultModelColor),
showAxes: validateBoolean(view?.layout?.showAxis, () => true),
showShadows: validateBoolean(view?.layout?.showShadow, () => true),

View file

@ -35,6 +35,11 @@ export class Model {
return false;
}
setVar(name: string, value: any) {
this.mutate(s => s.params.vars = {...s.params.vars ?? {}, [name]: value});
this.render({isPreview: true, now: false});
}
set logsVisible(value: boolean) {
if (value) {
if (this.state.view.layout.mode === 'single') {
@ -131,6 +136,7 @@ export class Model {
console.error('Error while checking syntax:', err)
} else {
s.lastCheckerRun = checkerRun;
s.parameterSet = checkerRun?.parameterSet;
s.checkingSyntax = false;
}
})});
@ -146,9 +152,9 @@ export class Model {
}
this.mutate(s => setRendering(s, true));
const {source, sourcePath, features} = this.state.params;
const {source, sourcePath, vars, features} = this.state.params;
render({source, sourcePath, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
render({source, sourcePath, vars, features, extraArgs: [], isPreview})({now, callback: (output, err) => {
this.mutate(s => {
setRendering(s, false);
if (err != null) {