Add demo for cadhub customizer

relates to #437
This commit is contained in:
Kurt Hutten
2021-08-07 11:30:27 +10:00
parent 1336ffc437
commit 0cc335ea9b
9 changed files with 526 additions and 414 deletions

View File

@@ -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>
}

View File

@@ -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',
})
}
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 = {

View File

@@ -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)
}

View File

@@ -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: 'Hooks “thickness” = object\s width = prints 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,