CadHub Customizer #461
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Cadhub",
|
||||
"Customizer",
|
||||
"Hutten",
|
||||
"cadquery",
|
||||
|
||||
@@ -1,41 +1,30 @@
|
||||
import { useRender } from 'src/components/IdeWrapper/useRender'
|
||||
import { useIdeContext } from 'src/helpers/hooks/useIdeContext'
|
||||
import { genParams } from 'src/helpers/cadPackages/jsCad/jscadParams'
|
||||
import { Switch } from '@headlessui/react'
|
||||
import Svg from 'src/components/Svg/Svg'
|
||||
import {
|
||||
CadhubStringParam,
|
||||
CadhubBooleanParam,
|
||||
CadhubNumberParam,
|
||||
} from './customizerConverter'
|
||||
|
||||
const Customizer = () => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [shouldLiveUpdate, setShouldLiveUpdate] = React.useState(false)
|
||||
const ref = React.useRef()
|
||||
const jsCadCustomizerElement = ref.current
|
||||
const { state, thunkDispatch } = useIdeContext()
|
||||
const customizerParams = state?.customizerParams
|
||||
const currentParameters = state?.currentParameters
|
||||
const currentParameters = state?.currentParameters || {}
|
||||
const handleRender = useRender()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (jsCadCustomizerElement && customizerParams) {
|
||||
genParams(
|
||||
customizerParams,
|
||||
jsCadCustomizerElement,
|
||||
currentParameters || {},
|
||||
(values, source) => {
|
||||
thunkDispatch({ type: 'setCurrentCustomizerParams', payload: values })
|
||||
if (shouldLiveUpdate) {
|
||||
handleRender()
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
const updateCustomizerParam = (paramName: string, paramValue: any) => {
|
||||
const payload = {
|
||||
...currentParameters,
|
||||
[paramName]: paramValue,
|
||||
}
|
||||
}, [
|
||||
jsCadCustomizerElement,
|
||||
customizerParams,
|
||||
currentParameters,
|
||||
shouldLiveUpdate,
|
||||
])
|
||||
if (!state.customizerParams) return null
|
||||
thunkDispatch({ type: 'setCurrentCustomizerParams', payload })
|
||||
shouldLiveUpdate && setTimeout(() => handleRender())
|
||||
}
|
||||
if (!customizerParams?.length) return null
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 bg-ch-gray-600 bg-opacity-60 text-ch-gray-300 text-lg font-fira-sans ${
|
||||
@@ -59,7 +48,6 @@ const Customizer = () => {
|
||||
<Switch
|
||||
checked={shouldLiveUpdate}
|
||||
onChange={(newValue) => {
|
||||
setShouldLiveUpdate
|
||||
if (newValue) handleRender()
|
||||
setShouldLiveUpdate(newValue)
|
||||
}}
|
||||
@@ -86,15 +74,184 @@ const Customizer = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={`${open ? 'h-full' : 'h-0'} overflow-y-auto px-12`}>
|
||||
<div
|
||||
id="jscad-customizer-block"
|
||||
ref={ref}
|
||||
// JSCAD param UI injected here.
|
||||
/>
|
||||
<div className={`${open ? 'h-full pb-32' : 'h-0'} overflow-y-auto px-12`}>
|
||||
<div>
|
||||
{customizerParams.map((param, index) => {
|
||||
const otherProps = {
|
||||
value: currentParameters[param.name],
|
||||
onChange: (value) => updateCustomizerParam(param.name, value),
|
||||
}
|
||||
if (param.type === 'string') {
|
||||
return <StringParam key={index} param={param} {...otherProps} />
|
||||
} else if (param.type === 'number') {
|
||||
return <NumberParam key={index} param={param} {...otherProps} />
|
||||
} else if (param.type === 'boolean') {
|
||||
return <BooleanParam key={index} param={param} {...otherProps} />
|
||||
}
|
||||
return <div key={index}>{JSON.stringify(param)}</div>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Customizer
|
||||
|
||||
function CustomizerParamBase({
|
||||
name,
|
||||
caption,
|
||||
children,
|
||||
}: {
|
||||
name: string
|
||||
caption: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className="grid items-center my-2"
|
||||
style={{ gridTemplateColumns: 'auto 8rem' }}
|
||||
>
|
||||
<div className=" text-sm font-fira-sans">
|
||||
<div className="font-bold text-base">{name}</div>
|
||||
<div>{caption}</div>
|
||||
</div>
|
||||
<div className="w-full">{children}</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BooleanParam({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
param: CadhubBooleanParam
|
||||
value: any
|
||||
onChange: Function
|
||||
}) {
|
||||
return (
|
||||
<CustomizerParamBase name={param.name} caption={param.caption}>
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue)
|
||||
}}
|
||||
className={`${
|
||||
value ? 'bg-ch-gray-300' : 'bg-ch-gray-600'
|
||||
} relative inline-flex items-center h-6 rounded-full w-11 mr-6 border border-ch-gray-300`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
value ? 'translate-x-6' : 'translate-x-1'
|
||||
} inline-block w-4 h-4 transform bg-white rounded-full`}
|
||||
/>
|
||||
</Switch>
|
||||
</CustomizerParamBase>
|
||||
)
|
||||
}
|
||||
|
||||
function StringParam({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
param: CadhubStringParam
|
||||
value: any
|
||||
onChange: Function
|
||||
}) {
|
||||
return (
|
||||
<CustomizerParamBase name={param.name} caption={param.caption}>
|
||||
<input
|
||||
className="bg-transparent h-8 border border-ch-gray-300 px-2 text-sm w-full"
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={param.placeholder}
|
||||
onChange={({ target }) => onChange(target?.value)}
|
||||
/>
|
||||
</CustomizerParamBase>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberParam({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
param: CadhubNumberParam
|
||||
value: any
|
||||
onChange: Function
|
||||
}) {
|
||||
const [isFocused, isFocusedSetter] = React.useState(false)
|
||||
const [localValue, localValueSetter] = React.useState(0)
|
||||
const [isLocked, isLockedSetter] = React.useState(false)
|
||||
const [pixelsDragged, pixelsDraggedSetter] = React.useState(0)
|
||||
const step = param.step || 1
|
||||
const commitChange = () => {
|
||||
let num = localValue
|
||||
if (typeof param.step === 'number') {
|
||||
num = Math.round(num / step) * step
|
||||
}
|
||||
if (typeof param.min === 'number') {
|
||||
num = Math.max(param.min, num)
|
||||
}
|
||||
if (typeof param.max === 'number') {
|
||||
num = Math.min(param.max, num)
|
||||
}
|
||||
num = Number(num.toFixed(2))
|
||||
localValueSetter(num)
|
||||
onChange(num)
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (!isFocused) commitChange()
|
||||
}, [isFocused])
|
||||
React.useEffect(() => localValueSetter(value), [value])
|
||||
return (
|
||||
<CustomizerParamBase name={param.name} caption={param.caption}>
|
||||
<div className="flex h-8 border border-ch-gray-300">
|
||||
<input
|
||||
className={`bg-transparent px-2 text-sm w-full ${
|
||||
(param.max && param.max < localValue) ||
|
||||
(param.min && param.min > localValue)
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
type="number"
|
||||
value={localValue}
|
||||
onFocus={() => isFocusedSetter(true)}
|
||||
onBlur={() => isFocusedSetter(false)}
|
||||
onKeyDown={({ key }) => key === 'Enter' && commitChange()}
|
||||
onChange={({ target }) => {
|
||||
const num = Number(target?.value)
|
||||
localValueSetter(num)
|
||||
}}
|
||||
max={param.max}
|
||||
min={param.min}
|
||||
step={step}
|
||||
/>
|
||||
<div
|
||||
className="w-6 border-l border-ch-gray-500 items-center hidden md:flex"
|
||||
style={{ cursor: 'ew-resize' }}
|
||||
onMouseDown={({ target }) => {
|
||||
isLockedSetter(true)
|
||||
target?.requestPointerLock?.()
|
||||
pixelsDraggedSetter(localValue)
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
isLockedSetter(false)
|
||||
document?.exitPointerLock?.()
|
||||
commitChange()
|
||||
}}
|
||||
onMouseMove={({ movementX }) => {
|
||||
if (isLocked && movementX) {
|
||||
pixelsDraggedSetter(pixelsDragged + (movementX * step) / 8) // one step per 8 pixels
|
||||
localValueSetter(Number(pixelsDragged.toFixed(2)))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Svg className="w-6" name="switch-horizontal" />
|
||||
</div>
|
||||
</div>
|
||||
</CustomizerParamBase>
|
||||
)
|
||||
}
|
||||
|
||||
120
app/web/src/components/Customizer/customizerConverter.ts
Normal file
120
app/web/src/components/Customizer/customizerConverter.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// CadHub
|
||||
|
||||
type CadhubTypeNames = 'number' | 'string' | 'boolean'
|
||||
|
||||
interface CadhubParamBase {
|
||||
type: CadhubTypeNames
|
||||
caption: string
|
||||
name: string
|
||||
}
|
||||
export interface CadhubStringParam extends CadhubParamBase {
|
||||
type: 'string'
|
||||
initial: string
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
}
|
||||
export interface CadhubBooleanParam extends CadhubParamBase {
|
||||
type: 'boolean'
|
||||
initial?: boolean
|
||||
}
|
||||
export interface CadhubNumberParam extends CadhubParamBase {
|
||||
type: 'number'
|
||||
initial: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
decimal?: number
|
||||
}
|
||||
|
||||
export type CadhubParams =
|
||||
| CadhubStringParam
|
||||
| CadhubBooleanParam
|
||||
| CadhubNumberParam
|
||||
|
||||
// OpenSCAD
|
||||
const openscadValues = `
|
||||
|
|
||||
// slider widget for number with max. value
|
||||
sliderWithMax =34; // [50]
|
||||
|
||||
// slider widget for number in range
|
||||
sliderWithRange =34; // [10:100]
|
||||
|
||||
//step slider for number
|
||||
stepSlider=2; //[0:5:100]
|
||||
|
||||
// slider widget for number in range
|
||||
sliderCentered =0; // [-10:0.1:10]
|
||||
|
||||
// spinbox with step size 1
|
||||
Spinbox= 5;
|
||||
|
||||
// Text box for string
|
||||
String="hello";
|
||||
|
||||
// Text box for string with length 8
|
||||
String2="length"; //8
|
||||
|
||||
//description
|
||||
Variable = true;
|
||||
`
|
||||
|
||||
const openscadConverted: CadhubParams[] = [
|
||||
{
|
||||
type: 'number',
|
||||
name: 'sliderWithMax',
|
||||
caption: 'slider widget for number with max. value',
|
||||
initial: 34,
|
||||
step: 1,
|
||||
max: 50,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'sliderWithRange',
|
||||
caption: 'slider widget for number in range',
|
||||
initial: 34,
|
||||
step: 1,
|
||||
min: 10,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'stepSlider',
|
||||
caption: 'step slider for number',
|
||||
initial: 2,
|
||||
step: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'sliderCentered',
|
||||
caption: 'slider widget for number in range',
|
||||
initial: 0,
|
||||
step: 0.1,
|
||||
min: -10,
|
||||
max: 10,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Spinbox',
|
||||
caption: 'spinbox with step size 1',
|
||||
initial: 5,
|
||||
step: 1,
|
||||
},
|
||||
|
||||
{
|
||||
type: 'string',
|
||||
name: 'String',
|
||||
caption: 'Text box for string',
|
||||
initial: 'hello',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'String2',
|
||||
caption: 'Text box for string with length 8',
|
||||
initial: 'length',
|
||||
maxLength: 8,
|
||||
},
|
||||
|
||||
{ type: 'boolean', name: 'Variable', caption: 'description', initial: true },
|
||||
]
|
||||
@@ -27,6 +27,7 @@ type SvgNames =
|
||||
| 'refresh'
|
||||
| 'save'
|
||||
| 'share'
|
||||
| 'switch-horizontal'
|
||||
| 'terminal'
|
||||
| 'trash'
|
||||
| 'x'
|
||||
@@ -503,6 +504,21 @@ const Svg = ({
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'switch-horizontal': (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={strokeWidth}
|
||||
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
terminal: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
|
||||
import { State } from 'src/helpers/hooks/useIdeState'
|
||||
import { CadhubParams } from 'src/components/Customizer/customizerConverter'
|
||||
|
||||
export const lambdaBaseURL =
|
||||
process.env.CAD_LAMBDA_BASE_URL ||
|
||||
@@ -45,14 +46,12 @@ export function createHealthyResponse({
|
||||
consoleMessage,
|
||||
type,
|
||||
customizerParams,
|
||||
currentParameters,
|
||||
}: {
|
||||
date: Date
|
||||
data: any
|
||||
consoleMessage: string
|
||||
type: HealthyResponse['objectData']['type']
|
||||
customizerParams?: any
|
||||
currentParameters?: any
|
||||
customizerParams?: CadhubParams[]
|
||||
}): HealthyResponse {
|
||||
return {
|
||||
status: 'healthy',
|
||||
@@ -66,7 +65,6 @@ export function createHealthyResponse({
|
||||
time: date,
|
||||
},
|
||||
customizerParams,
|
||||
currentParameters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +97,7 @@ export function createUnhealthyResponse(
|
||||
|
||||
export const timeoutErrorMessage = `timeout: We're currently limited to a 30s execution time. You can try again, sometimes it works the second time`
|
||||
|
||||
export type RenderResponse = HealthyResponse | ErrorResponse
|
||||
export interface DefaultKernelExport {
|
||||
render: (arg: RenderArgs) => Promise<HealthyResponse | ErrorResponse>
|
||||
render: (arg: RenderArgs) => Promise<RenderResponse>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
RenderArgs,
|
||||
DefaultKernelExport,
|
||||
RenderResponse,
|
||||
createUnhealthyResponse,
|
||||
createHealthyResponse,
|
||||
} from '../common'
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Color,
|
||||
Mesh,
|
||||
} from 'three'
|
||||
import { jsCadToCadhubParams } from './jscadParams'
|
||||
|
||||
const materials = {
|
||||
mesh: {
|
||||
@@ -70,15 +72,46 @@ function CSG2Object3D(obj) {
|
||||
}
|
||||
|
||||
let scriptWorker
|
||||
let currentParameters = {}
|
||||
const scriptUrl = '/demo-worker.js'
|
||||
let resolveReference = null
|
||||
let response = null
|
||||
|
||||
const callResolve = () => {
|
||||
if (resolveReference) resolveReference()
|
||||
resolveReference
|
||||
type ResolveFn = (RenderResponse) => void
|
||||
|
||||
class WorkerHelper {
|
||||
callResolve: null | ResolveFn = null
|
||||
previousCode = ''
|
||||
resolver = (response: RenderResponse) => {
|
||||
this.callResolve && this.callResolve(response)
|
||||
this.callResolve = null
|
||||
}
|
||||
render = (
|
||||
code: string,
|
||||
parameters: { [key: string]: any }
|
||||
): Promise<RenderResponse> => {
|
||||
const response: Promise<RenderResponse> = new Promise(
|
||||
(resolve: ResolveFn) => {
|
||||
this.callResolve = resolve
|
||||
}
|
||||
)
|
||||
if (!(code && this.previousCode !== code)) {
|
||||
// we are not evaluating code, but reacting to parameters change
|
||||
scriptWorker.postMessage({
|
||||
action: 'updateParams',
|
||||
worker: 'script',
|
||||
params: parameters,
|
||||
})
|
||||
} else {
|
||||
scriptWorker.postMessage({
|
||||
action: 'runScript',
|
||||
worker: 'script',
|
||||
script: code,
|
||||
params: parameters || {},
|
||||
url: 'jscad_script',
|
||||
})
|
||||
|
fundamentally i haven't changed how your code works, I just wanted to group things a little better in this file as I found some of the module level variables hard to keep track of and so instead moved some of the stuff that required state into this class. Plus one other thing that made me uncomfortable was how the function awaited a promise just to return another module level variable This render function now sets up promise -> posts a message to the worker -> then returns the promise where the promise now contains the final response. I at least personally find it easier to reason about. fundamentally i haven't changed how your code works, I just wanted to group things a little better in this file as I found some of the module level variables hard to keep track of and so instead moved some of the stuff that required state into this class. Plus one other thing that made me uncomfortable was how the function awaited a promise just to return another module level variable `response`.
This render function now sets up promise -> posts a message to the worker -> then returns the promise where the promise now contains the final response. I at least personally find it easier to reason about.
yes, it looks cleaner. thnx :) yes, it looks cleaner. thnx :)
|
||||
}
|
||||
this.previousCode = code
|
||||
return response
|
||||
}
|
||||
}
|
||||
const workerHelper = new WorkerHelper()
|
||||
|
||||
export const render: DefaultKernelExport['render'] = async ({
|
||||
code,
|
||||
@@ -86,10 +119,8 @@ export const render: DefaultKernelExport['render'] = async ({
|
||||
settings,
|
||||
}: RenderArgs) => {
|
||||
if (!scriptWorker) {
|
||||
console.trace(
|
||||
'************************** creating new worker ************************'
|
||||
)
|
||||
const baseURI = document.baseURI.toString()
|
||||
const scriptUrl = '/demo-worker.js'
|
||||
const script = `let baseURI = '${baseURI}'
|
||||
importScripts(new URL('${scriptUrl}',baseURI))
|
||||
let worker = jscadWorker({
|
||||
@@ -103,65 +134,31 @@ export const render: DefaultKernelExport['render'] = async ({
|
||||
const blob = new Blob([script], { type: 'text/javascript' })
|
||||
scriptWorker = new Worker(window.URL.createObjectURL(blob))
|
||||
let parameterDefinitions = []
|
||||
scriptWorker.addEventListener('message', (e) => {
|
||||
const data = e.data
|
||||
scriptWorker.addEventListener('message', ({ data }) => {
|
||||
if (data.action == 'parameterDefinitions') {
|
||||
parameterDefinitions = data.data
|
||||
} else if (data.action == 'entities') {
|
||||
if (data.error) {
|
||||
response = createUnhealthyResponse(new Date(), data.error)
|
||||
workerHelper.resolver(createUnhealthyResponse(new Date(), data.error))
|
||||
} else {
|
||||
response = createHealthyResponse({
|
||||
type: 'geometry',
|
||||
data: [...data.entities.map(CSG2Object3D).filter((o) => o)],
|
||||
consoleMessage: data.scriptStats,
|
||||
date: new Date(),
|
||||
customizerParams: parameterDefinitions,
|
||||
currentParameters,
|
||||
})
|
||||
workerHelper.resolver(
|
||||
createHealthyResponse({
|
||||
type: 'geometry',
|
||||
data: data.entities.map(CSG2Object3D).filter((o) => o),
|
||||
consoleMessage: data.scriptStats,
|
||||
date: new Date(),
|
||||
customizerParams: jsCadToCadhubParams(parameterDefinitions || []),
|
||||
})
|
||||
)
|
||||
}
|
||||
callResolve()
|
||||
}
|
||||
})
|
||||
|
||||
callResolve()
|
||||
response = null
|
||||
workerHelper.resolver()
|
||||
scriptWorker.postMessage({ action: 'init', baseURI, alias: [] })
|
||||
}
|
||||
|
||||
if (
|
||||
parameters &&
|
||||
currentParameters &&
|
||||
JSON.stringify(parameters) !== JSON.stringify(currentParameters)
|
||||
) {
|
||||
// we are not evaluating code, but reacting to parameters change
|
||||
scriptWorker.postMessage({
|
||||
action: 'updateParams',
|
||||
worker: 'script',
|
||||
params: parameters,
|
||||
})
|
||||
} else {
|
||||
scriptWorker.postMessage({
|
||||
action: 'runScript',
|
||||
worker: 'script',
|
||||
script: code,
|
||||
params: parameters || {},
|
||||
url: 'jscad_script',
|
||||
})
|
||||
}
|
||||
// we need this to keep the form filled with same data when new parameter definitions arrive
|
||||
// each render of the script could provide new paramaters. In case some of them are still rpesent
|
||||
// it is expected for them to stay the same and not just reset
|
||||
currentParameters = parameters || {}
|
||||
|
||||
const waitResult = new Promise((resolve) => {
|
||||
resolveReference = resolve
|
||||
})
|
||||
|
||||
await waitResult
|
||||
resolveReference = null
|
||||
if (parameters) delete response.customizerParams
|
||||
return response
|
||||
return workerHelper.render(code, parameters)
|
||||
}
|
||||
|
||||
const jsCadController: DefaultKernelExport = {
|
||||
|
||||
@@ -1,216 +1,130 @@
|
||||
import type { RawCustomizerParams } from '../common'
|
||||
import { CadhubParams } from 'src/components/Customizer/customizerConverter'
|
||||
|
||||
const GROUP_SELECTOR = 'DIV[type="group"]'
|
||||
const INPUT_SELECTOR = 'INPUT, SELECT'
|
||||
type JscadTypeNames =
|
||||
| 'group'
|
||||
| 'text'
|
||||
| 'int'
|
||||
| 'number'
|
||||
| 'slider'
|
||||
| 'email'
|
||||
| 'password'
|
||||
| 'date'
|
||||
| 'url'
|
||||
| 'checkbox'
|
||||
| 'color'
|
||||
| 'choice'
|
||||
| 'radio'
|
||||
|
||||
function forEachInput(
|
||||
target: HTMLElement,
|
||||
callback: (e: HTMLInputElement) => void
|
||||
) {
|
||||
target.querySelectorAll(INPUT_SELECTOR).forEach(callback)
|
||||
interface JscadParamBase {
|
||||
type: JscadTypeNames
|
||||
caption: string
|
||||
name: string
|
||||
}
|
||||
interface JscadGroupParam extends JscadParamBase {
|
||||
type: 'group'
|
||||
initial?: 'open' | 'closed'
|
||||
}
|
||||
interface JscadTextParam extends JscadParamBase {
|
||||
type: 'text'
|
||||
initial: string
|
||||
placeholder: string
|
||||
size: number
|
||||
maxLength: number
|
||||
}
|
||||
interface JscadIntNumberSliderParam extends JscadParamBase {
|
||||
type: 'int' | 'number' | 'slider'
|
||||
initial: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}
|
||||
interface JscadDateParam extends JscadParamBase {
|
||||
type: 'date'
|
||||
initial: string
|
||||
min: string
|
||||
max: string
|
||||
placeholder: string
|
||||
}
|
||||
interface JscadEmailPasswordColorParam extends JscadParamBase {
|
||||
type: 'email' | 'password' | 'color'
|
||||
initial: string
|
||||
}
|
||||
interface JscadUrlParam extends JscadParamBase {
|
||||
type: 'url'
|
||||
initial: string
|
||||
maxLength: number
|
||||
size: number
|
||||
placeholder: string
|
||||
}
|
||||
interface JscadCheckboxParam extends JscadParamBase {
|
||||
type: 'checkbox'
|
||||
checked: boolean
|
||||
initial: boolean
|
||||
}
|
||||
interface JscadChoiceRadioParam extends JscadParamBase {
|
||||
type: 'choice' | 'radio'
|
||||
initial: number | string
|
||||
values: (string | number)[]
|
||||
captions?: string[]
|
||||
}
|
||||
|
||||
function forEachGroup(target: HTMLElement, callback: (e: HTMLElement) => void) {
|
||||
target.querySelectorAll(GROUP_SELECTOR).forEach(callback)
|
||||
}
|
||||
type JsCadParams =
|
||||
| JscadGroupParam
|
||||
| JscadTextParam
|
||||
| JscadIntNumberSliderParam
|
||||
| JscadDateParam
|
||||
| JscadEmailPasswordColorParam
|
||||
| JscadUrlParam
|
||||
| JscadCheckboxParam
|
||||
| JscadChoiceRadioParam
|
||||
|
||||
const numeric = { number: 1, float: 1, int: 1, range: 1, slider: 1 }
|
||||
|
||||
function applyRange(inp) {
|
||||
const label = inp.previousElementSibling
|
||||
if (label && label.tagName == 'LABEL') {
|
||||
const info = label.querySelector('I')
|
||||
if (info) info.innerHTML = inp.value
|
||||
}
|
||||
}
|
||||
|
||||
export function genParams(
|
||||
defs,
|
||||
target,
|
||||
storedParams = {},
|
||||
callback: (values: RawCustomizerParams, source: any) => void = undefined,
|
||||
buttons = ['reset', 'save', 'load', 'edit', 'link']
|
||||
) {
|
||||
const funcs = {
|
||||
group: () => '',
|
||||
choice: inputChoice,
|
||||
radio: inputRadio,
|
||||
float: inputNumber,
|
||||
range: inputNumber,
|
||||
slider: inputNumber,
|
||||
int: inputNumber,
|
||||
text: inputNumber,
|
||||
url: inputNumber,
|
||||
email: inputNumber,
|
||||
date: inputNumber,
|
||||
password: inputNumber,
|
||||
color: inputNumber,
|
||||
// TODO radio similar options as choice
|
||||
checkbox: function ({ name, value }) {
|
||||
const checkedStr = value === 'checked' || value === true ? 'checked' : ''
|
||||
return `<input type="checkbox" name="${name}" ${checkedStr}/>`
|
||||
},
|
||||
number: inputNumber,
|
||||
}
|
||||
|
||||
function inputRadio({ name, type, captions, value, values }) {
|
||||
if (!captions) captions = values
|
||||
|
||||
let ret = '<div type="radio">'
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const checked =
|
||||
value == values[i] || value == captions[i] ? 'checked' : ''
|
||||
ret += `<label><input type="radio" _type="${type}" name="${name}" numeric="${
|
||||
typeof values[0] == 'number' ? '1' : '0'
|
||||
}" value="${values[i]}" ${checked}/>${captions[i]}</label>`
|
||||
}
|
||||
return ret + '</div>'
|
||||
}
|
||||
|
||||
function inputChoice({ name, type, captions, value, values }) {
|
||||
if (!captions) captions = values
|
||||
|
||||
let ret = `<select _type="${type}" name="${name}" numeric="${
|
||||
typeof values[0] == 'number' ? '1' : '0'
|
||||
}">`
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const checked =
|
||||
value == values[i] || value == captions[i] ? 'selected' : ''
|
||||
ret += `<option value="${values[i]}" ${checked}>${captions[i]}</option>`
|
||||
}
|
||||
return ret + '</select>'
|
||||
}
|
||||
|
||||
function inputNumber(def) {
|
||||
let { name, type, value, min, max, step, placeholder, live } = def
|
||||
if (value === null || value === undefined) value = numeric[type] ? 0 : ''
|
||||
let inputType = type
|
||||
if (type == 'int' || type == 'float') inputType = 'number'
|
||||
if (type == 'range' || type == 'slider') inputType = 'range'
|
||||
let str = `<input _type="${type}" type="${inputType}" name="${name}"`
|
||||
if (step !== undefined) str += ` step="${step || ''}"`
|
||||
if (min !== undefined) str += ` min="${min || ''}"`
|
||||
if (max !== undefined) str += ` max="${max || ''}"`
|
||||
if (value !== undefined) str += ` value="${value}"`
|
||||
str += ` live="${live ? 1 : 0}"`
|
||||
if (placeholder !== undefined) str += ` placeholder="${placeholder}"`
|
||||
return str + '/>'
|
||||
}
|
||||
|
||||
let html = ''
|
||||
let closed = false
|
||||
const missing = {}
|
||||
|
||||
defs.forEach((def) => {
|
||||
const { type, caption, name } = def
|
||||
|
||||
if (storedParams[name] !== undefined) {
|
||||
def.value = storedParams[name]
|
||||
} else {
|
||||
def.value = def.initial || def['default'] || def.checked
|
||||
}
|
||||
|
||||
if (type == 'group') {
|
||||
closed = def.value == 'closed'
|
||||
}
|
||||
def.closed = closed
|
||||
|
||||
html += `<div class="form-line" type="${def.type}" closed="${
|
||||
closed ? 1 : 0
|
||||
}" `
|
||||
if (type == 'group') html += ` name="${name}"`
|
||||
html += `">`
|
||||
|
||||
html += `<label`
|
||||
if (type == 'group') html += ` name="${name}"`
|
||||
html += `>`
|
||||
if (type == 'checkbox') html += funcs[type](def)
|
||||
html += `${caption}<i>${def.value}</i></label>`
|
||||
|
||||
if (funcs[type] && type != 'checkbox') html += funcs[type](def)
|
||||
|
||||
if (!funcs[type]) missing[type] = 1
|
||||
|
||||
html += '</div>\n'
|
||||
})
|
||||
|
||||
const missingKeys = Object.keys(missing)
|
||||
if (missingKeys.length) console.log('missing param impl', missingKeys)
|
||||
|
||||
function _callback(source = 'change') {
|
||||
if (callback && source !== 'group') callback(getParams(target), source)
|
||||
}
|
||||
|
||||
html += '<div class="jscad-param-buttons"><div>'
|
||||
buttons.forEach((button) => {
|
||||
const { id, name } =
|
||||
typeof button === 'string' ? { id: button, name: button } : button
|
||||
html += `<button action="${id}"><b>${name}</b></button>`
|
||||
})
|
||||
html += '</div></div>'
|
||||
|
||||
target.innerHTML = html
|
||||
|
||||
forEachInput(target, (inp) => {
|
||||
const type = inp.type
|
||||
inp.addEventListener('input', function (evt) {
|
||||
applyRange(inp)
|
||||
if (inp.getAttribute('live') === '1') _callback('live')
|
||||
export function jsCadToCadhubParams(input: JsCadParams[]): CadhubParams[] {
|
||||
return input
|
||||
.map((param): CadhubParams => {
|
||||
switch (param.type) {
|
||||
case 'slider':
|
||||
case 'number':
|
||||
case 'int':
|
||||
return {
|
||||
type: 'number',
|
||||
caption: param.caption,
|
||||
name: param.name,
|
||||
initial: param.initial,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
step: param.step,
|
||||
decimal: param.step % 1 === 0 && param.initial % 1 === 0 ? 0 : 2,
|
||||
}
|
||||
case 'text':
|
||||
case 'url':
|
||||
case 'email':
|
||||
case 'password':
|
||||
case 'color':
|
||||
case 'date':
|
||||
return {
|
||||
type: 'string',
|
||||
caption: param.caption,
|
||||
name: param.name,
|
||||
initial: param.initial,
|
||||
placeholder:
|
||||
param.type === 'text' ||
|
||||
param.type === 'date' ||
|
||||
param.type === 'url'
|
||||
? param.placeholder
|
||||
: '',
|
||||
maxLength:
|
||||
param.type === 'text' || param.type === 'url'
|
||||
? param.maxLength
|
||||
: undefined,
|
||||
}
|
||||
case 'checkbox':
|
||||
return {
|
||||
type: 'boolean',
|
||||
caption: param.caption,
|
||||
name: param.name,
|
||||
initial: !!param.initial,
|
||||
}
|
||||
}
|
||||
})
|
||||
if (inp.getAttribute('live') !== '1')
|
||||
inp.addEventListener('change', () => _callback('change'))
|
||||
})
|
||||
|
||||
function groupClick(evt) {
|
||||
let groupDiv = evt.target
|
||||
if (groupDiv.tagName === 'LABEL') groupDiv = groupDiv.parentNode
|
||||
const closed = groupDiv.getAttribute('closed') == '1' ? '0' : '1'
|
||||
do {
|
||||
groupDiv.setAttribute('closed', closed)
|
||||
groupDiv = groupDiv.nextElementSibling
|
||||
} while (groupDiv && groupDiv.getAttribute('type') != 'group')
|
||||
_callback('group')
|
||||
}
|
||||
|
||||
forEachGroup(target, (div) => {
|
||||
div.onclick = groupClick
|
||||
})
|
||||
}
|
||||
|
||||
function getParams(target: HTMLElement): RawCustomizerParams {
|
||||
const params = {}
|
||||
if (!target) return params
|
||||
|
||||
forEachGroup(target, (elem) => {
|
||||
const name = elem.getAttribute('name')
|
||||
params[name] = elem.getAttribute('closed') == '1' ? 'closed' : ''
|
||||
})
|
||||
|
||||
forEachInput(target, (elem) => {
|
||||
const name = elem.name
|
||||
let value: RawCustomizerParams[string] = elem.value
|
||||
if (elem.tagName == 'INPUT') {
|
||||
if (elem.type == 'checkbox') value = elem?.checked
|
||||
if (elem.type == 'range' || elem.type == 'color') applyRange(elem)
|
||||
}
|
||||
|
||||
if (
|
||||
numeric[elem.getAttribute('type')] ||
|
||||
elem.getAttribute('numeric') == '1'
|
||||
) {
|
||||
value = parseFloat(String(value || 0))
|
||||
} else if (
|
||||
value &&
|
||||
typeof value === 'string' &&
|
||||
/^(\d+|\d+\.\d+)$/.test(value.trim())
|
||||
) {
|
||||
value = parseFloat(String(value || 0))
|
||||
}
|
||||
if (elem.type == 'radio' && !elem.checked) return // skip if not checked radio button
|
||||
|
||||
params[name] = value
|
||||
})
|
||||
return params
|
||||
.filter((a) => a)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useReducer } from 'react'
|
||||
import { cadPackages } from 'src/helpers/cadPackages'
|
||||
import type { RootState } from '@react-three/fiber'
|
||||
import type { RawCustomizerParams } from 'src/helpers/cadPackages/common'
|
||||
import { CadhubParams } from 'src/components/Customizer/customizerConverter'
|
||||
|
||||
function withThunk(dispatch, getState) {
|
||||
return (actionOrThunk) =>
|
||||
@@ -69,14 +70,14 @@ const main = ({length=200}) => {
|
||||
|
||||
return [transpCube, star2D, line2D, ...logo]
|
||||
}
|
||||
const getParameterDefinitions = ()=>{
|
||||
const getParameterDefinitions = () => {
|
||||
return [
|
||||
{type:'slider', name:'length', initial:200, caption:'Length', min:100, max:500},
|
||||
{ name: 'group1', type: 'group', caption: 'Group 1: Text Entry' },
|
||||
{ name: 'text', type: 'text', initial: '', size: 20, maxLength: 20, caption: 'Plain Text:', placeholder: '20 characters' },
|
||||
{ name: 'text', type: 'text', initial: '', size: 20, maxLength: 20, caption: 'Hook’s “thickness” = object\’s width = print’s height', placeholder: '20 characters' },
|
||||
{ name: 'int', type: 'int', initial: 20, min: 1, max: 100, step: 1, caption: 'Integer:' },
|
||||
{ name: 'number', type: 'number', initial: 2.0, min: 1.0, max: 10.0, step: 0.1, caption: 'Number:' },
|
||||
{ name: 'date', type: 'date', initial: '2020-01-01', min: '2020-01-01', max: '2030-12-31', caption: 'Date:', placeholder: 'YYYY-MM-DD' },
|
||||
{ name: 'date', type: 'date', initial: '2020-01-01', min: '2020-01-01', max: '2030-12-31', caption: 'Choose between classic hook with screw holes (0) or “bracket” system (1)', placeholder: 'YYYY-MM-DD' },
|
||||
{ name: 'email', type: 'email', initial: 'me@example.com', caption: 'Email:' },
|
||||
{ name: 'url', type: 'url', initial: 'www.example.com', size: 40, maxLength: 40, caption: 'Url:', placeholder: '40 characters' },
|
||||
{ name: 'password', type: 'password', initial: '', caption: 'Password:' },
|
||||
@@ -116,7 +117,7 @@ export interface State {
|
||||
data: any
|
||||
quality: 'low' | 'high'
|
||||
}
|
||||
customizerParams?: any[]
|
||||
customizerParams: CadhubParams[]
|
||||
currentParameters?: RawCustomizerParams
|
||||
layout: any
|
||||
camera: {
|
||||
@@ -152,6 +153,7 @@ export const initialState: State = {
|
||||
data: null,
|
||||
quality: 'low',
|
||||
},
|
||||
customizerParams: [],
|
||||
layout: initialLayout,
|
||||
camera: {},
|
||||
viewerSize: { width: 0, height: 0 },
|
||||
@@ -175,11 +177,17 @@ export const useIdeState = (): [State, (actionOrThunk: any) => any] => {
|
||||
case 'updateCode':
|
||||
return { ...state, code: payload }
|
||||
case 'healthyRender':
|
||||
const currentParameters =
|
||||
payload.currentParameters &&
|
||||
Object.keys(payload.currentParameters).length
|
||||
? payload.currentParameters
|
||||
: state.currentParameters
|
||||
const customizerParams: CadhubParams[] = payload?.customizerParams
|
||||
?.length
|
||||
? payload.customizerParams
|
||||
: state.customizerParams
|
||||
const currentParameters = {}
|
||||
customizerParams.forEach((param) => {
|
||||
currentParameters[param.name] =
|
||||
typeof state?.currentParameters?.[param.name] !== 'undefined'
|
||||
? state?.currentParameters?.[param.name]
|
||||
: param.initial
|
||||
})
|
||||
return {
|
||||
...state,
|
||||
objectData: {
|
||||
@@ -187,7 +195,7 @@ export const useIdeState = (): [State, (actionOrThunk: any) => any] => {
|
||||
type: payload.objectData?.type,
|
||||
data: payload.objectData?.data,
|
||||
},
|
||||
customizerParams: payload.customizerParams || state.customizerParams,
|
||||
customizerParams,
|
||||
currentParameters,
|
||||
consoleMessages: payload.message
|
||||
? [...state.consoleMessages, payload.message]
|
||||
@@ -203,7 +211,7 @@ export const useIdeState = (): [State, (actionOrThunk: any) => any] => {
|
||||
isLoading: false,
|
||||
}
|
||||
case 'setCurrentCustomizerParams':
|
||||
if (!Object.keys(payload).length) return state
|
||||
if (!Object.keys(payload || {}).length) return state
|
||||
return {
|
||||
...state,
|
||||
currentParameters: payload,
|
||||
|
||||
@@ -114,103 +114,3 @@ label {
|
||||
input.error, textarea.error {
|
||||
border: 1px solid red;
|
||||
}
|
||||
|
||||
#jscad-customizer-block {
|
||||
padding-bottom: 60px; /* hack because it gets cut off at the bottom for some reason*/
|
||||
}
|
||||
#jscad-customizer-block > .form-line{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 5px 15px;
|
||||
position: relative;
|
||||
}
|
||||
#jscad-customizer-block > .form-line:hover{
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
#jscad-customizer-block > .form-line[type="group"]{
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding-left: 50px;
|
||||
padding-bottom: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#jscad-customizer-block > .form-line[type="group"] > label{
|
||||
cursor: pointer;
|
||||
}
|
||||
#jscad-customizer-block > .form-line[closed="1"]:not([type="group"]){
|
||||
display: none;
|
||||
}
|
||||
|
||||
#jscad-customizer-block > .form-line[type="group"]:before {
|
||||
position: absolute;
|
||||
content: ">";
|
||||
left: 18px;
|
||||
top: 13px;
|
||||
font-size: 30px;
|
||||
transform: rotate(90deg);
|
||||
font-family: monospace;
|
||||
|
||||
}
|
||||
#jscad-customizer-block > .form-line[type="group"][closed="1"]:before {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
#jscad-customizer-block > .form-line select,
|
||||
#jscad-customizer-block > .form-line input[type="text"],
|
||||
#jscad-customizer-block > .form-line input[type="range"],
|
||||
#jscad-customizer-block > .form-line input[type="slider"],
|
||||
#jscad-customizer-block > .form-line input[type="number"],
|
||||
#jscad-customizer-block > .form-line input[type="int"],
|
||||
#jscad-customizer-block > .form-line input[type="date"],
|
||||
#jscad-customizer-block > .form-line input[type="email"],
|
||||
#jscad-customizer-block > .form-line input[type="url"],
|
||||
#jscad-customizer-block > .form-line input[type="password"]
|
||||
{
|
||||
background: rgba(73, 73, 73, 0.65);
|
||||
border: 1px solid #FFFFFF;
|
||||
width: 50%;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
#jscad-customizer-block > .form-line > div{
|
||||
width: 50%;
|
||||
}
|
||||
#jscad-customizer-block > .form-line > div[type="radio"] > label{
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
}
|
||||
#jscad-customizer-block > .form-line[type="checkbox"] > label > input{
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
#jscad-customizer-block > .form-line > label i{
|
||||
display: none;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
#jscad-customizer-block > .form-line[type="range"] > label i,
|
||||
#jscad-customizer-block > .form-line[type="slider"] > label i,
|
||||
#jscad-customizer-block > .form-line[type="color"] > label i{
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#jscad-customizer-block > .form-line input[type="range"]{
|
||||
position: relative;
|
||||
top: 6px;
|
||||
}
|
||||
#jscad-customizer-block > .form-line > label > i{
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 70%;
|
||||
}
|
||||
|
||||
#jscad-customizer-block .form-line label{
|
||||
font-family: Fira Sans;
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
|
||||
color: #CFCFD8;
|
||||
}
|
||||
Reference in New Issue
Block a user
The following is just here to test implementation for OpenSCAD and will be removed later.