Add stl download for OpenSCAD and CadQuery IDEs #331

Merged
Irev-Dev merged 1 commits from kurt/330-stl-download into main 2021-05-30 00:47:32 +02:00
12 changed files with 311 additions and 168 deletions
Showing only changes of commit bd58e6c7cb - Show all commits

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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'
Irev-Dev commented 2021-05-30 00:31:21 +02:00 (Migrated from github.com)
Review

nice library that abstracts a bunch browser compatibility issues with saving files from the browser.
more context : https://github.com/zalo/CascadeStudio/pull/39#issuecomment-766911556

nice library that abstracts a bunch browser compatibility issues with saving files from the browser. more context : https://github.com/zalo/CascadeStudio/pull/39#issuecomment-766911556
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 }) => () => {
Irev-Dev commented 2021-05-30 00:32:15 +02:00 (Migrated from github.com)
Review

not needed, but didn't want the {256 hash}.stl to be the file name, so if there's a comment as the first line it will use that for the title.

not needed, but didn't want the `{256 hash}.stl` to be the file name, so if there's a comment as the first line it will use that for the title.
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,
Irev-Dev commented 2021-05-30 00:37:29 +02:00 (Migrated from github.com)
Review

if we're currently showing 3d in the preview than we're golden, if not than it must be openscad, so we should trigger an stl download.

if we're currently showing 3d in the preview than we're golden, if not than it must be openscad, so we should trigger an stl download.
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 (
<IdeContext.Provider value={{ state, thunkDispatch }}>
@@ -97,6 +157,12 @@ const IdeToolbarNew = ({ cadPackage }) => {
>
Copy link
</button>
<button
onClick={handleStlDownload}
className="border-2 text-gray-700 px-2 text-sm m-1 ml-2"
>
Download STL
</button>
</nav>
<IdeContainer />
</div>

View File

@@ -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
Irev-Dev commented 2021-05-30 00:32:47 +02:00 (Migrated from github.com)
Review

Asset component is much cleaner now as we're making the three geometry higher in the stack.

Asset component is much cleaner now as we're making the three geometry higher in the stack.
})
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 (
<mesh ref={mesh} scale={[1, 1, 1]}>
<bufferGeometry attach="geometry" ref={ref} />
@@ -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 = () => {
)}
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
className={`opacity-0 absolute inset-0 transition-opacity duration-500 ${
!(isDragging || state.ideType !== 'openScad')
!(isDragging || state.objectData?.type !== 'png')
? 'hover:opacity-50'
: 'opacity-100'
}`}
@@ -208,7 +188,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 = () => {
/>
<ambientLight />
<pointLight position={[15, 5, 10]} />
{state.ideType === 'openScad' && (
{state.objectData?.type === 'png' && (
<>
<Sphere position={[0, 0, 0]} color={pink400} />
<Box position={[0, 50, 0]} size={[1, 100, 1]} color={indigo900} />
@@ -235,13 +215,11 @@ const IdeViewer = () => {
<Box position={[50, 0, 0]} size={[100, 1, 1]} color={pink400} />
</>
)}
{state.ideType === 'cadQuery' && (
<Asset
url={state.objectData?.type === 'stl' && state.objectData?.data}
resetLoading={resetLoading}
setLoading={setLoading}
/>
)}
<Asset
geometry={
state.objectData?.type === 'geometry' && state.objectData?.data
}
/>
</Canvas>
</div>
{state.isLoading && (

View File

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

View File

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

View File

@@ -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,
Irev-Dev commented 2021-05-30 00:36:09 +02:00 (Migrated from github.com)
Review

Previously we were storing the aws url here and than was downloading and managing the geometry, but instead doing that here, it's much easier to grab that geometry again to download the stl

Previously we were storing the aws url here and than <Asset /> was downloading and managing the geometry, but instead doing that here, it's much easier to grab that geometry again to download the stl
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'")
}

View File

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

View File

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