add customizer
This commit is contained in:
parent
9929ffe324
commit
76492bc903
10 changed files with 341 additions and 16 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ public/openscad.js
|
|||
public/openscad.wasm
|
||||
src/wasm
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
219
src/components/CustomizerPanel.tsx
Normal file
219
src/components/CustomizerPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
46
src/state/customizer-types.ts
Normal file
46
src/state/customizer-types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue