Front end changes for cadquery

Basic changes to get the proof of concept working. Lots of attention
was given to the store/reducer to solve existing problems with async
code and stale closures, it seems even today how to handle this with
use reducer is not quiet settle, I guess because once an app reaches
a certain level of maturity everyone grabs an off the shelf solution
to state management. I ended up implementing thunks because they are
really rather simple. Interesting thread about it all here:
https://gist.github.com/astoilkov/013c513e33fe95fa8846348038d8fe42#gistcomment-3377800

I also move some of settings that were persisted in the openScad
controller into the data store as I ulimately thing what I was doing
in that file was very confusing, with the fact that it had to be
called multiple times with different information before it would be able
to render something properly.
This commit is contained in:
Kurt Hutten
2021-04-18 12:28:23 +10:00
parent 1a702ddae1
commit 2153e5b1bf
9 changed files with 295 additions and 162 deletions

View File

@@ -1,6 +1,7 @@
import { useContext, useRef, useEffect } from 'react'
import { Mosaic, MosaicWindow } from 'react-mosaic-component'
import { IdeContext } from 'src/components/IdeToolbarNew'
import { requestRender } from 'src/helpers/hooks/useIdeState'
import IdeEditor from 'src/components/IdeEditor'
import IdeViewer from 'src/components/IdeViewer'
import IdeConsole from 'src/components/IdeConsole'
@@ -13,7 +14,7 @@ const ELEMENT_MAP = {
}
const IdeContainer = () => {
const { state, dispatch } = useContext(IdeContext)
const { state, thunkDispatch } = useContext(IdeContext)
const viewerDOM = useRef(null)
const debounceTimeoutId = useRef
@@ -22,12 +23,22 @@ const IdeContainer = () => {
function handleViewerSizeUpdate() {
if (viewerDOM !== null && viewerDOM.current) {
const { width, height } = viewerDOM.current.getBoundingClientRect()
dispatch({
type: 'render',
payload: {
code: state.code,
viewerSize: { width, height },
},
thunkDispatch({
type: 'updateViewerSize',
payload: { viewerSize: { width, height } },
})
thunkDispatch((dispatch, getState) => {
const state = getState()
if (state.ideType === 'openScad') {
dispatch({ type: 'setLoading' })
requestRender({
state,
dispatch,
code: state.code,
viewerSize: { width, height },
camera: state.camera,
})
}
})
}
}
@@ -49,20 +60,27 @@ const IdeContainer = () => {
return (
<div id="cadhub-ide" className="flex-auto h-full">
<Mosaic
renderTile={(id, path) => (
<MosaicWindow path={path} title={id} className={id.toLowerCase()}>
{id === 'Viewer' ? (
<div id="view-wrapper" className="h-full" ref={viewerDOM}>
{ELEMENT_MAP[id]}
</div>
) : (
ELEMENT_MAP[id]
)}
</MosaicWindow>
)}
renderTile={(id, path) => {
const title = id === 'Editor' ? `${id} (${state.ideType})` : id
return (
<MosaicWindow
path={path}
title={title}
className={id.toLowerCase()}
>
{id === 'Viewer' ? (
<div id="view-wrapper" className="h-full" ref={viewerDOM}>
{ELEMENT_MAP[id]}
</div>
) : (
ELEMENT_MAP[id]
)}
</MosaicWindow>
)
}}
value={state.layout}
onChange={(newLayout) =>
dispatch({ type: 'setLayout', payload: { message: newLayout } })
thunkDispatch({ type: 'setLayout', payload: { message: newLayout } })
}
onRelease={handleViewerSizeUpdate}
/>

View File

@@ -2,10 +2,15 @@ import { useContext, useEffect, Suspense, lazy } from 'react'
import { isBrowser } from '@redwoodjs/prerender/browserUtils'
import { IdeContext } from 'src/components/IdeToolbarNew'
import { codeStorageKey } from 'src/helpers/hooks/useIdeState'
import { requestRender } from 'src/helpers/hooks/useIdeState'
const Editor = lazy(() => import('@monaco-editor/react'))
const IdeEditor = () => {
const { state, dispatch } = useContext(IdeContext)
const { state, thunkDispatch } = useContext(IdeContext)
const ideTypeToLanguageMap = {
cadQuery: 'python',
openScad: 'cpp',
}
const scriptKey = 'encoded_script'
useEffect(() => {
@@ -17,7 +22,7 @@ const IdeEditor = () => {
const [key, scriptBase64] = hash.slice(1).split('=')
if (key === scriptKey) {
const script = atob(scriptBase64)
dispatch({ type: 'updateCode', payload: script })
thunkDispatch({ type: 'updateCode', payload: script })
}
}, [])
useEffect(() => {
@@ -27,25 +32,38 @@ const IdeEditor = () => {
}, [state.code])
function handleCodeChange(value, _event) {
dispatch({ type: 'updateCode', payload: value })
thunkDispatch({ type: 'updateCode', payload: value })
}
function handleSaveHotkey(event) {
//ctrl|meta + s is very intuitive for most devs
const { key, ctrlKey, metaKey } = event
if (key === 's' && (ctrlKey || metaKey)) {
event.preventDefault()
dispatch({ type: 'render', payload: { code: state.code } })
thunkDispatch((dispatch, getState) => {
const state = getState()
dispatch({ type: 'setLoading' })
requestRender({
state,
dispatch,
code: state.code,
viewerSize: state.viewerSize,
camera: state.camera,
})
})
localStorage.setItem(codeStorageKey, state.code)
}
}
return (
<div className="h-full" onKeyDown={handleSaveHotkey}>
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
className="h-full"
onKeyDown={handleSaveHotkey}
>
<Suspense fallback={<div>. . . loading</div>}>
<Editor
defaultValue={state.code}
// TODO #247 cpp seems better than js for the time being
defaultLanguage="cpp"
defaultLanguage={ideTypeToLanguageMap[state.ideType] || 'cpp'}
language={ideTypeToLanguageMap[state.ideType] || 'cpp'}
onChange={handleCodeChange}
/>
</Suspense>

View File

@@ -3,15 +3,26 @@ import IdeContainer from 'src/components/IdeContainer'
import { isBrowser } from '@redwoodjs/prerender/browserUtils'
import { useIdeState, codeStorageKey } from 'src/helpers/hooks/useIdeState'
import { copyTextToClipboard } from 'src/helpers/clipboard'
import { requestRender } from 'src/helpers/hooks/useIdeState'
export const IdeContext = createContext()
const IdeToolbarNew = () => {
const [state, dispatch] = useIdeState()
const [state, thunkDispatch] = useIdeState()
function setIdeType(ide) {
dispatch({ type: 'setIdeType', payload: { message: ide } })
thunkDispatch({ type: 'setIdeType', payload: { message: ide } })
}
function handleRender() {
dispatch({ type: 'render', payload: { code: state.code } })
thunkDispatch((dispatch, getState) => {
const state = getState()
dispatch({ type: 'setLoading' })
requestRender({
state,
dispatch,
code: state.code,
viewerSize: state.viewerSize,
camera: state.camera,
})
})
localStorage.setItem(codeStorageKey, state.code)
}
function handleMakeLink() {
@@ -23,21 +34,17 @@ const IdeToolbarNew = () => {
}
return (
<IdeContext.Provider value={{ state, dispatch }}>
<IdeContext.Provider value={{ state, thunkDispatch: thunkDispatch }}>
<div className="h-full flex flex-col">
<nav className="flex">
{/* <button
onClick={() => setIdeType('openCascade')}
<button
onClick={() =>
setIdeType(state.ideType === 'openScad' ? 'cadQuery' : 'openScad')
}
className="p-2 br-2 border-2 m-2 bg-blue-200"
>
Switch to OpenCascade
Switch to {state.ideType === 'openScad' ? 'CadQuery' : 'OpenSCAD'}
</button>
<button
onClick={() => setIdeType('openScad')}
className="p-2 br-2 border-2 m-2 bg-indigo-200"
>
Switch to OpenSCAD
</button> */}
<button onClick={handleRender} className="p-2 br-2 border-2 m-2">
Render
</button>

View File

@@ -1,11 +1,41 @@
import { IdeContext } from 'src/components/IdeToolbarNew'
import { useRef, useState, useEffect, useContext } from 'react'
import { Canvas, extend, useFrame, useThree } from 'react-three-fiber'
import {
Canvas,
extend,
useFrame,
useThree,
useUpdate,
} from 'react-three-fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { Vector3 } from 'three'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { requestRender } from 'src/helpers/hooks/useIdeState'
extend({ OrbitControls })
function Asset({ url }) {
const [loadedGeometry, setLoadedGeometry] = useState()
const mesh = useRef()
const ref = useUpdate((geometry) => {
geometry.attributes = loadedGeometry.attributes
})
useEffect(() => {
if (url) {
const decoded = atob(url)
const loader = new STLLoader()
setLoadedGeometry(loader.parse(decoded))
}
}, [url])
if (!loadedGeometry) return null
return (
<mesh ref={mesh} scale={[1, 1, 1]}>
<bufferGeometry attach="geometry" ref={ref} />
<meshStandardMaterial color="#F472B6" />
</mesh>
)
}
let debounceTimeoutId
function Controls({ onCameraChange, onDragStart }) {
const controls = useRef()
@@ -85,7 +115,7 @@ function Controls({ onCameraChange, onDragStart }) {
}
}, [])
useFrame(() => controls.current.update())
useFrame(() => controls.current?.update())
return (
<orbitControls
ref={controls}
@@ -115,9 +145,8 @@ function Sphere(props) {
</mesh>
)
}
let currentCode // I have no idea why this works and using state.code is the dispatch doesn't but it was always stale
const IdeViewer = () => {
const { state, dispatch } = useContext(IdeContext)
const { state, thunkDispatch } = useContext(IdeContext)
const [isDragging, setIsDragging] = useState(false)
const [image, setImage] = useState()
@@ -127,8 +156,7 @@ const IdeViewer = () => {
'data:image/png;base64,' + state.objectData?.data
)
setIsDragging(false)
}, [state.objectData])
currentCode = state.code
}, [state.objectData?.type, state.objectData?.data])
const openSCADDeepOceanThemeBackground = '#323232'
// the following are tailwind colors in hex, can't use these classes to color three.js meshes.
@@ -151,41 +179,64 @@ const IdeViewer = () => {
isDragging ? 'opacity-25' : 'opacity-100'
}`}
>
<img src={image} className="h-full w-full" />
<img alt="code-cad preview" src={image} className="h-full w-full" />
</div>
)}
{state.isLoading && (
<div className="inset-0 absolute flex items-center justify-center">
<div className="h-16 w-16 bg-pink-600 rounded-full animate-ping"></div>
</div>
)}
<div
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
className={`opacity-0 absolute inset-0 transition-opacity duration-500 ${
isDragging ? 'opacity-100' : 'hover:opacity-50'
!(isDragging || state.ideType !== 'openScad')
? 'hover:opacity-50'
: 'opacity-100'
}`}
onMouseDown={() => setIsDragging(true)}
>
<Canvas>
<Controls
onDragStart={() => setIsDragging(true)}
onCameraChange={(camera) =>
dispatch({
type: 'render',
payload: {
code: currentCode,
camera,
},
onCameraChange={(camera) => {
thunkDispatch({
type: 'updateCamera',
payload: { camera },
})
}
thunkDispatch((dispatch, getState) => {
const state = getState()
if (state.ideType === 'openScad') {
dispatch({ type: 'setLoading' })
requestRender({
state,
dispatch,
code: state.code,
viewerSize: state.viewerSize,
camera,
})
}
})
}}
/>
<ambientLight />
<pointLight position={[15, 5, 10]} />
<Sphere position={[0, 0, 0]} color={pink400} />
<Box position={[0, 50, 0]} size={[1, 100, 1]} color={indigo900} />
<Box position={[0, 0, -50]} size={[1, 1, 100]} color={indigo300} />
<Box position={[50, 0, 0]} size={[100, 1, 1]} color={pink400} />
{state.ideType === 'openScad' && (
<>
<Sphere position={[0, 0, 0]} color={pink400} />
<Box position={[0, 50, 0]} size={[1, 100, 1]} color={indigo900} />
<Box
position={[0, 0, -50]}
size={[1, 1, 100]}
color={indigo300}
/>
<Box position={[50, 0, 0]} size={[100, 1, 1]} color={pink400} />
</>
)}
{state.ideType === 'cadQuery' && (
<Asset url={state.objectData?.data} />
)}
</Canvas>
</div>
{state.isLoading && (
<div className="inset-0 absolute flex items-center justify-center">
<div className="h-16 w-16 bg-pink-600 rounded-full animate-ping"></div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,68 @@
let openScadBaseURL = process.env.CADQUERY_BASE_URL
export const render = async ({ code }) => {
const body = JSON.stringify({
settings: {},
file: code,
})
try {
const response = await fetch(openScadBaseURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body,
})
if (response.status === 400) {
// TODO add proper error messages for CadQuery
const { error } = await response.json()
const cleanedErrorMessage = error.replace(
/["|']\/tmp\/.+\/main.scad["|']/g,
"'main.scad'"
)
return {
status: 'error',
message: {
type: 'error',
message: addDateToLog(cleanedErrorMessage),
},
}
}
const data = await response.json()
return {
status: 'healthy',
objectData: {
type: 'stl',
data: data.imageBase64,
},
message: {
type: 'message',
message: addDateToLog(data.result),
},
}
} catch (e) {
// TODO handle errors better
// I think we should display something overlayed on the viewer window something like "network issue try again"
// and in future I think we need timeouts differently as they maybe from a user trying to render something too complex
// or something with minkowski in it :/ either way something like "render timed out, try again or here are tips to reduce part complexity" with a link talking about $fn and minkowski etc
return {
status: 'error',
message: {
type: 'error',
message: addDateToLog('network issue'),
},
}
}
}
const openScad = {
render,
// more functions to come
}
export default openScad
function addDateToLog(message) {
return `-> ${new Date().toLocaleString()}
${message}`
}

View File

@@ -1,7 +1,7 @@
import openScad from './openScadController'
import openCascade from './newCascadeController'
import cadQuery from './cadQueryController'
export const cadPackages = {
openScad,
openCascade,
}
cadQuery,
}

View File

@@ -1,35 +0,0 @@
// Rename this file to remove "new" once Cascade integration is complete
export const render = async ({ code, settings }) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
const shouldReject = Math.random() < 0.7
if (shouldReject) {
resolve({
objectData: {
type: 'stl',
data: ((Math.random() * 256 + 1) >>> 0).toString(2), // Randomized 8-bit numbers for funzies
},
message: {
type: 'message',
message: `bodies rendered by: ${code}`,
},
})
} else {
reject({
message: {
type: 'error',
message: 'unable to parse line: x',
},
})
}
}, 700)
})
}
const openCascade = {
render,
// More functions to come
}
export default openCascade

View File

@@ -2,33 +2,16 @@ let openScadBaseURL =
process.env.OPENSCAD_BASE_URL ||
'https://x2wvhihk56.execute-api.us-east-1.amazonaws.com/dev'
let lastViewPortSize = 'INIT'
let lastCameraSettings = 'INIT'
export const render = async ({ code, settings }) => {
const pixelRatio = window.devicePixelRatio || 1
const size = settings.viewerSize
? {
x: Math.round(settings.viewerSize?.width * pixelRatio),
y: Math.round(settings.viewerSize?.height * pixelRatio),
}
: lastViewPortSize
const camera = settings.camera || lastCameraSettings
if (settings.camera) {
lastCameraSettings = settings.camera
}
if (settings.viewerSize) {
lastViewPortSize = size
}
if ([camera, size].includes('INIT')) {
return {
status: 'insufficient-preview-info',
}
const size = {
x: Math.round(settings.viewerSize?.width * pixelRatio),
y: Math.round(settings.viewerSize?.height * pixelRatio),
}
const body = JSON.stringify({
settings: {
size,
camera,
camera: settings.camera,
},
file: code,
})

View File

@@ -1,6 +1,13 @@
import { useReducer } from 'react'
import { cadPackages } from 'src/helpers/cadPackages'
function withThunk(dispatch, getState) {
return (actionOrThunk) =>
typeof actionOrThunk === 'function'
? actionOrThunk(dispatch, getState)
: dispatch(actionOrThunk)
}
const donutInitCode = `
color(c="DarkGoldenrod")rotate_extrude()translate([20,0])circle(d=30);
donut();
@@ -17,16 +24,17 @@ module stick(basewid, angl){
}`
export const codeStorageKey = 'Last-openscad-code'
let mutableState = null
export const useIdeState = () => {
const code = localStorage.getItem(codeStorageKey) || donutInitCode
const initialState = {
ideType: 'openScad',
ideType: 'cadQuery',
consoleMessages: [{ type: 'message', message: 'Initialising OpenSCAD' }],
code,
objectData: {
type: 'stl',
data: 'some binary',
data: null,
},
layout: {
direction: 'row',
@@ -38,6 +46,8 @@ export const useIdeState = () => {
splitPercentage: 70,
},
},
camera: {},
viewerSize: { width: 0, height: 0 },
isLoading: false,
}
const reducer = (state, { type, payload }) => {
@@ -74,51 +84,64 @@ export const useIdeState = () => {
...state,
layout: payload.message,
}
case 'updateCamera':
return {
...state,
camera: payload.camera,
}
case 'updateViewerSize':
return {
...state,
viewerSize: payload.viewerSize,
}
case 'setLoading':
return {
...state,
isLoading: true,
}
case 'resetLoading':
return {
...state,
isLoading: false,
}
default:
return state
}
}
function dispatchMiddleware(dispatch, state) {
return ({ type, payload }) => {
switch (type) {
case 'render':
cadPackages[state.ideType]
.render({
code: payload.code,
settings: {
camera: payload.camera,
viewerSize: payload.viewerSize,
},
})
.then(({ objectData, message, status }) => {
if (status === 'insufficient-preview-info') return
if (status === 'error') {
dispatch({
type: 'errorRender',
payload: { message },
})
} else {
dispatch({
type: 'healthyRender',
payload: { objectData, message },
})
}
})
dispatch({ type: 'setLoading' })
break
default:
return dispatch({ type, payload })
}
}
}
const [state, dispatch] = useReducer(reducer, initialState)
return [state, dispatchMiddleware(dispatch, state)]
mutableState = state
const getState = () => mutableState
return [state, withThunk(dispatch, getState)]
}
export const requestRender = ({
state,
dispatch,
code,
camera,
viewerSize,
}) => {
cadPackages[state.ideType]
.render({
code,
settings: {
camera,
viewerSize,
},
})
.then(({ objectData, message, status }) => {
if (status === 'error') {
dispatch({
type: 'errorRender',
payload: { message },
})
} else {
dispatch({
type: 'healthyRender',
payload: { objectData, message },
})
}
})
.catch(() => dispatch({ type: 'resetLoading' })) // TODO should probably display something to the user here
}