diff --git a/app/api/src/docker/openscad/openscad.js b/app/api/src/docker/openscad/openscad.js index 32dfa4b..a86d2c9 100644 --- a/app/api/src/docker/openscad/openscad.js +++ b/app/api/src/docker/openscad/openscad.js @@ -1,4 +1,4 @@ -const { runScad } = require('./runScad') +const { runScad, stlExport } = require('./runScad') const middy = require('middy') const { cors } = require('middy/middlewares') const AWS = require('aws-sdk') @@ -12,25 +12,52 @@ const { const s3 = new AWS.S3() +const openScadStlKey = (eventBody) => { + const { file } = JSON.parse(eventBody) + return `${makeHash(JSON.stringify(file))}.stl` +} + const preview = async (req, _context, callback) => { _context.callbackWaitsForEmptyEventLoop = false const eventBody = req.body console.log('eventBody', eventBody) const key = `${makeHash(eventBody)}.png` + const stlKey = openScadStlKey(eventBody) + console.log('key', key) + const stlParams = { + Bucket: process.env.BUCKET, + Key: stlKey, + } + const params = { Bucket: process.env.BUCKET, Key: key, } - const previousAsset = await checkIfAlreadyExists(params, s3) + const [previousAssetStl, previousAssetPng] = await Promise.all([ + checkIfAlreadyExists(stlParams, s3), + checkIfAlreadyExists(params, s3), + ]) + const type = previousAssetStl.isAlreadyInBucket ? 'stl' : 'png' + const previousAsset = previousAssetStl.isAlreadyInBucket + ? previousAssetStl + : previousAssetPng if (previousAsset.isAlreadyInBucket) { console.log('already in bucket') const response = { statusCode: 200, body: JSON.stringify({ - url: getObjectUrl(params, s3), - consoleMessage: previousAsset.consoleMessage, + url: getObjectUrl( + { + Bucket: process.env.BUCKET, + Key: previousAssetStl.isAlreadyInBucket ? stlKey : key, + }, + s3 + ), + consoleMessage: + previousAsset.consoleMessage || previousAssetPng.consoleMessage, + type, }), } callback(null, response) @@ -50,39 +77,46 @@ const preview = async (req, _context, callback) => { }) } -// const stl = async (req, _context, callback) => { -// _context.callbackWaitsForEmptyEventLoop = false -// const eventBody = Buffer.from(req.body, 'base64').toString('ascii') -// console.log(eventBody, 'eventBody') -// const { file } = JSON.parse(eventBody) -// const { error, result, tempFile } = await stlExport({ file }) -// if (error) { -// const response = { -// statusCode: 400, -// body: { error, tempFile }, -// } -// callback(null, response) -// } else { -// console.log(`got result in route: ${result}, file is: ${tempFile}`) -// const fs = require('fs') -// const stl = fs.readFileSync(`/tmp/${tempFile}/output.stl`, { -// encoding: 'base64', -// }) -// console.log('encoded stl', stl) -// const response = { -// statusCode: 200, -// headers: { -// 'content-type': 'application/stl', -// }, -// body: stl, -// isBase64Encoded: true, -// } -// console.log('callback fired') -// callback(null, response) -// } -// } +const stl = async (req, _context, callback) => { + _context.callbackWaitsForEmptyEventLoop = false + const eventBody = req.body + console.log(eventBody, 'eventBody') + const stlKey = openScadStlKey(eventBody) + + console.log('key', stlKey) + + const params = { + Bucket: process.env.BUCKET, + Key: stlKey, + } + console.log('original params', params) + const previousAsset = await checkIfAlreadyExists(params, s3) + if (previousAsset.isAlreadyInBucket) { + console.log('already in bucket') + const response = { + statusCode: 200, + body: JSON.stringify({ + url: getObjectUrl({ ...params }, s3), + consoleMessage: previousAsset.consoleMessage, + }), + } + callback(null, response) + return + } + const { file } = JSON.parse(eventBody) + const { error, consoleMessage, fullPath } = await stlExport({ file }) + await storeAssetAndReturnUrl({ + error, + callback, + fullPath, + consoleMessage, + key: stlKey, + s3, + params, + }) +} module.exports = { - // stl: middy(stl).use(cors()), + stl: middy(stl).use(cors()), preview: middy(loggerWrap(preview)).use(cors()), } diff --git a/app/api/src/docker/openscad/runScad.js b/app/api/src/docker/openscad/runScad.js index d782096..89662a6 100644 --- a/app/api/src/docker/openscad/runScad.js +++ b/app/api/src/docker/openscad/runScad.js @@ -35,14 +35,15 @@ module.exports.runScad = async ({ module.exports.stlExport = async ({ file } = {}) => { const tempFile = await makeFile(file, '.scad', nanoid) + const fullPath = `/tmp/${tempFile}/output.stl` try { - const result = await runCommand( - `openscad -o /tmp/${tempFile}/output.stl /tmp/${tempFile}/main.scad`, - 300000 // lambda will time out before this, we might need to look at background jobs if we do git integration stl generation + const consoleMessage = await runCommand( + `xvfb-run --auto-servernum --server-args "-screen 0 1024x768x24" openscad -o ${fullPath} /tmp/${tempFile}/main.scad`, + 60000 // lambda will time out before this, we might need to look at background jobs if we do git integration stl generation ) - return { result, tempFile } + return { consoleMessage, fullPath } } catch (error) { - return { error, tempFile } + return { error, fullPath } } } diff --git a/app/api/src/docker/serverless.yml b/app/api/src/docker/serverless.yml index e6ff158..e380ba4 100644 --- a/app/api/src/docker/serverless.yml +++ b/app/api/src/docker/serverless.yml @@ -63,18 +63,21 @@ functions: timeout: 25 environment: BUCKET: cad-preview-bucket-prod-001 - # openscadstl: - # image: - # name: openscadimage - # command: - # - openscad.stl - # entryPoint: - # - '/entrypoint.sh' - # events: - # - http: - # path: openscad/stl - # method: post - # timeout: 30 + openscadstl: + image: + name: openscadimage + command: + - openscad.stl + entryPoint: + - '/entrypoint.sh' + events: + - http: + path: openscad/stl + method: post + cors: true + timeout: 30 + environment: + BUCKET: cad-preview-bucket-prod-001 cadquerystl: image: name: cadqueryimage diff --git a/app/web/package.json b/app/web/package.json index d541175..d8cb3ba 100644 --- a/app/web/package.json +++ b/app/web/package.json @@ -20,6 +20,7 @@ "@redwoodjs/forms": "^0.31.0", "@redwoodjs/router": "^0.31.0", "@redwoodjs/web": "^0.31.0", + "browser-fs-access": "^0.17.2", "cloudinary-react": "^1.6.7", "controlkit": "^0.1.9", "get-active-classes": "^0.0.11", diff --git a/app/web/src/components/IdeContainer/IdeContainer.js b/app/web/src/components/IdeContainer/IdeContainer.js index 536b08c..08948c9 100644 --- a/app/web/src/components/IdeContainer/IdeContainer.js +++ b/app/web/src/components/IdeContainer/IdeContainer.js @@ -29,7 +29,7 @@ const IdeContainer = () => { }) thunkDispatch((dispatch, getState) => { const state = getState() - if (state.ideType === 'openScad') { + if (['png', 'INIT'].includes(state.objectData?.type)) { dispatch({ type: 'setLoading' }) requestRender({ state, diff --git a/app/web/src/components/IdeToolbarNew/IdeToolbarNew.js b/app/web/src/components/IdeToolbarNew/IdeToolbarNew.js index e278378..141a18c 100644 --- a/app/web/src/components/IdeToolbarNew/IdeToolbarNew.js +++ b/app/web/src/components/IdeToolbarNew/IdeToolbarNew.js @@ -5,7 +5,10 @@ import { useIdeState, codeStorageKey } from 'src/helpers/hooks/useIdeState' import { copyTextToClipboard } from 'src/helpers/clipboard' import { requestRender } from 'src/helpers/hooks/useIdeState' import { encode, decode } from 'src/helpers/compress' -import { flow } from 'lodash/fp' +import { flow, identity } from 'lodash/fp' +import { fileSave } from 'browser-fs-access' +import { MeshBasicMaterial, Mesh, Scene } from 'three' +import { STLExporter } from 'three/examples/jsm/exporters/STLExporter' export const githubSafe = (url) => url.includes('github.com') @@ -80,6 +83,63 @@ const IdeToolbarNew = ({ cadPackage }) => { copyTextToClipboard(window.location.href) } } + const PullTitleFromFirstLine = (code) => { + const firstLine = code.split('\n').filter(identity)[0] + if (!(firstLine.startsWith('//') || firstLine.startsWith('#'))) { + return 'object.stl' + } + return ( + (firstLine.replace(/^(\/\/|#)\s*(.+)/, (_, __, titleWithSpaces) => + titleWithSpaces.replaceAll(/\s/g, '-') + ) || 'object') + '.stl' + ) + } + + const handleStlDownload = (({ geometry, fileName, type }) => () => { + const makeStlBlobFromGeo = flow( + (geo) => new Mesh(geo, new MeshBasicMaterial()), + (mesh) => new Scene().add(mesh), + (scene) => new STLExporter().parse(scene), + (stl) => + new Blob([stl], { + type: 'text/plain', + }) + ) + const saveFile = (geometry) => { + const blob = makeStlBlobFromGeo(geometry) + fileSave(blob, { + fileName, + extensions: ['.stl'], + }) + } + if (geometry) { + if (type === 'geometry') { + saveFile(geometry) + } else { + thunkDispatch((dispatch, getState) => { + const state = getState() + if (state.ideType === 'openScad') { + thunkDispatch((dispatch, getState) => { + const state = getState() + dispatch({ type: 'setLoading' }) + requestRender({ + state, + dispatch, + code: state.code, + viewerSize: state.viewerSize, + camera: state.camera, + specialCadProcess: 'stl', + }).then((result) => result && saveFile(result.data)) + }) + } + }) + } + } + })({ + type: state.objectData?.type, + geometry: state.objectData?.data, + fileName: PullTitleFromFirstLine(state.code), + }) return ( @@ -97,6 +157,12 @@ const IdeToolbarNew = ({ cadPackage }) => { > Copy link + diff --git a/app/web/src/components/IdeViewer/IdeViewer.js b/app/web/src/components/IdeViewer/IdeViewer.js index c55530b..d3fa40d 100644 --- a/app/web/src/components/IdeViewer/IdeViewer.js +++ b/app/web/src/components/IdeViewer/IdeViewer.js @@ -9,33 +9,16 @@ import { } 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, resetLoading, setLoading }) { - const [loadedGeometry, setLoadedGeometry] = useState() +function Asset({ geometry: incomingGeo }) { const mesh = useRef() const ref = useUpdate((geometry) => { - geometry.attributes = loadedGeometry.attributes + geometry.attributes = incomingGeo.attributes }) - useEffect(() => { - if (url) { - const loader = new STLLoader() - setLoading() - loader.load( - url, - (geometry) => { - setLoadedGeometry(geometry) - resetLoading() - }, - null, - resetLoading - ) - } - }, [url]) - if (!loadedGeometry) return null + if (!incomingGeo) return null return ( @@ -158,9 +141,6 @@ const IdeViewer = () => { const [isDragging, setIsDragging] = useState(false) const [image, setImage] = useState() - const resetLoading = () => thunkDispatch({ type: 'resetLoading' }) - const setLoading = () => thunkDispatch({ type: 'setLoading' }) - useEffect(() => { setImage(state.objectData?.type === 'png' && state.objectData?.data) setIsDragging(false) @@ -192,7 +172,7 @@ const IdeViewer = () => { )}
{ }) thunkDispatch((dispatch, getState) => { const state = getState() - if (state.ideType === 'openScad') { + if (['png', 'INIT'].includes(state.objectData?.type)) { dispatch({ type: 'setLoading' }) requestRender({ state, @@ -223,7 +203,7 @@ const IdeViewer = () => { /> - {state.ideType === 'openScad' && ( + {state.objectData?.type === 'png' && ( <> @@ -235,13 +215,11 @@ const IdeViewer = () => { )} - {state.ideType === 'cadQuery' && ( - - )} +
{state.isLoading && ( diff --git a/app/web/src/helpers/cadPackages/cadQueryController.js b/app/web/src/helpers/cadPackages/cadQueryController.js index 14d268c..e630097 100644 --- a/app/web/src/helpers/cadPackages/cadQueryController.js +++ b/app/web/src/helpers/cadPackages/cadQueryController.js @@ -1,4 +1,9 @@ -import { lambdaBaseURL } from './common' +import { + lambdaBaseURL, + stlToGeometry, + createHealthyResponse, + createUnhealthyResponse, +} from './common' export const render = async ({ code }) => { const body = JSON.stringify({ @@ -14,47 +19,26 @@ export const render = async ({ code }) => { 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: cleanedErrorMessage, + message: error, time: new Date(), }, } } const data = await response.json() - return { - status: 'healthy', - objectData: { - type: 'stl', - data: data.url, - }, - message: { - type: 'message', - message: data.consoleMessage || 'Successful Render', - time: new Date(), - }, - } + const geometry = await stlToGeometry(data.url) + return createHealthyResponse({ + type: 'geometry', + data: geometry, + consoleMessage: data.consoleMessage, + date: new Date(), + }) } 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: 'network issue', - time: new Date(), - }, - } + return createUnhealthyResponse(new Date()) } } diff --git a/app/web/src/helpers/cadPackages/common.js b/app/web/src/helpers/cadPackages/common.js index 6288359..bdb1af5 100644 --- a/app/web/src/helpers/cadPackages/common.js +++ b/app/web/src/helpers/cadPackages/common.js @@ -1,3 +1,40 @@ +import { STLLoader } from 'three/examples/jsm/loaders/STLLoader' + export const lambdaBaseURL = process.env.CAD_LAMBDA_BASE_URL || 'https://2inlbple1b.execute-api.us-east-1.amazonaws.com/prod2' + +export const stlToGeometry = (url) => + new Promise((resolve, reject) => { + new STLLoader().load(url, resolve, null, reject) + }) + +export function createHealthyResponse({ date, data, consoleMessage, type }) { + return { + status: 'healthy', + objectData: { + type, + data: data, + }, + message: { + type: 'message', + message: consoleMessage, + time: date, + }, + } +} + +export function createUnhealthyResponse(date, message = 'network issue') { + // 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, + time: date, + }, + } +} diff --git a/app/web/src/helpers/cadPackages/openScadController.js b/app/web/src/helpers/cadPackages/openScadController.js index a11ddd4..365b8ae 100644 --- a/app/web/src/helpers/cadPackages/openScadController.js +++ b/app/web/src/helpers/cadPackages/openScadController.js @@ -1,4 +1,9 @@ -import { lambdaBaseURL } from './common' +import { + lambdaBaseURL, + stlToGeometry, + createHealthyResponse, + createUnhealthyResponse, +} from './common' export const render = async ({ code, settings }) => { const pixelRatio = window.devicePixelRatio || 1 @@ -41,51 +46,61 @@ export const render = async ({ code, settings }) => { }) if (response.status === 400) { const { error } = await response.json() - const cleanedErrorMessage = error.replace( - /["|']\/tmp\/.+\/main.scad["|']/g, - "'main.scad'" - ) - return { - status: 'error', - message: { - type: 'error', - message: cleanedErrorMessage, - time: new Date(), - }, - } + const cleanedErrorMessage = cleanError(error) + return createUnhealthyResponse(new Date(), cleanedErrorMessage) } const data = await response.json() - return { - status: 'healthy', - objectData: { - type: 'png', - data: data.url, - }, - message: { - type: 'message', - message: data.consoleMessage, - time: new Date(), - }, - } + const type = data.type !== 'stl' ? 'png' : 'geometry' + const newData = data.type !== 'stl' ? data.url : stlToGeometry(data.url) + return createHealthyResponse({ + type, + data: await newData, + consoleMessage: data.consoleMessage, + date: new Date(), + }) } 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: 'network issue', - time: new Date(), + return createUnhealthyResponse(new Date()) + } +} + +export const stl = async ({ code, settings }) => { + const body = JSON.stringify({ + settings: {}, + file: code, + }) + try { + const response = await fetch(lambdaBaseURL + '/openscad/stl', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', }, + body, + }) + if (response.status === 400) { + const { error } = await response.json() + const cleanedErrorMessage = cleanError(error) + return createUnhealthyResponse(new Date(), cleanedErrorMessage) } + const data = await response.json() + const geometry = await stlToGeometry(data.url) + return createHealthyResponse({ + type: 'geometry', + data: geometry, + consoleMessage: data.consoleMessage, + date: new Date(), + }) + } catch (e) { + return createUnhealthyResponse(new Date()) } } const openScad = { render, - // more functions to come + stl, } export default openScad + +function cleanError(error) { + return error.replace(/["|']\/tmp\/.+\/main.scad["|']/g, "'main.scad'") +} diff --git a/app/web/src/helpers/hooks/useIdeState.js b/app/web/src/helpers/hooks/useIdeState.js index 5169f5d..5ab0aa5 100644 --- a/app/web/src/helpers/hooks/useIdeState.js +++ b/app/web/src/helpers/hooks/useIdeState.js @@ -9,7 +9,10 @@ function withThunk(dispatch, getState) { } const initCodeMap = { - openScad: ` + openScad: `// involute donut + +// ^ first comment is used for download title (i.e "involute-donut.stl") + color(c="DarkGoldenrod")rotate_extrude()translate([20,0])circle(d=30); donut(); module donut() { @@ -23,7 +26,11 @@ module stick(basewid, angl){ translate([0,0,10])sphere(9); } }`, - cadQuery: `import cadquery as cq + cadQuery: `# demo shaft coupler + +# ^ first comment is used for download title (i.e. "demo-shaft-coupler.stl") + +import cadquery as cq from cadquery import exporters diam = 5.0 @@ -49,7 +56,7 @@ export const useIdeState = () => { ], code, objectData: { - type: 'stl', + type: 'INIT', data: null, }, layout: { @@ -138,17 +145,22 @@ export const requestRender = ({ code, camera, viewerSize, + specialCadProcess = null, }) => { - state.ideType !== 'INIT' && - !state.isLoading && - cadPackages[state.ideType] - .render({ - code, - settings: { - camera, - viewerSize, - }, - }) + if ( + state.ideType !== 'INIT' && + (!state.isLoading || state.objectData?.type === 'INIT') + ) { + const renderFn = specialCadProcess + ? cadPackages[state.ideType][specialCadProcess] + : cadPackages[state.ideType].render + return renderFn({ + code, + settings: { + camera, + viewerSize, + }, + }) .then(({ objectData, message, status }) => { if (status === 'error') { dispatch({ @@ -160,7 +172,9 @@ export const requestRender = ({ type: 'healthyRender', payload: { objectData, message, lastRunCode: code }, }) + return objectData } }) .catch(() => dispatch({ type: 'resetLoading' })) // TODO should probably display something to the user here + } } diff --git a/app/yarn.lock b/app/yarn.lock index 059eee6..abc10d5 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -3097,6 +3097,11 @@ resolved "https://registry.yarnpkg.com/@types/line-column/-/line-column-1.0.0.tgz#fa5a59c21e885fef3739a273b43dacf55b63437f" integrity sha512-wbw+IDRw/xY/RGy+BL6f4Eey4jsUgHQrMuA4Qj0CSG3x/7C2Oc57pmRoM2z3M4DkylWRz+G1pfX06sCXQm0J+w== +"@types/lodash@^4.14.170": + version "4.14.170" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6" + integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q== + "@types/long@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" @@ -4822,6 +4827,11 @@ brorand@^1.0.1, brorand@^1.1.0: resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" integrity sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8= +browser-fs-access@^0.17.2: + version "0.17.2" + resolved "https://registry.yarnpkg.com/browser-fs-access/-/browser-fs-access-0.17.2.tgz#6debfe35ebce77a1eecca7822a449c740a8de9db" + integrity sha512-z3H37rU3fmKkGJ1r09v3hAwByUI0vYIh8a7LsUaQnnMxdVUJm1UzsffvNK6Qnvk9jGqvY7uiX2DPu4BZXavcWA== + browser-process-hrtime@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz#3c9b4b7d782c8121e56f10106d84c0d0ffc94626"