diff --git a/app/api/db/schema.prisma b/app/api/db/schema.prisma index d91dda8..163f4da 100644 --- a/app/api/db/schema.prisma +++ b/app/api/db/schema.prisma @@ -38,6 +38,7 @@ enum CadPackage { openscad cadquery jscad // TODO #422, add jscad to db schema when were ready to enable saving of jscad projects + curv } model Project { diff --git a/app/api/src/docker/curv/Dockerfile b/app/api/src/docker/curv/Dockerfile new file mode 100644 index 0000000..ffda554 --- /dev/null +++ b/app/api/src/docker/curv/Dockerfile @@ -0,0 +1,55 @@ +FROM public.ecr.aws/lts/ubuntu:20.04_stable + +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update --fix-missing -qq +RUN apt-get -y -qq install git software-properties-common dirmngr apt-transport-https lsb-release ca-certificates xvfb unzip maim clang cmake git-core libboost-all-dev libopenexr-dev libtbb-dev libglm-dev libpng-dev libeigen3-dev dbus-x11 libxcursor-dev libxinerama-dev libxrandr-dev libglu1-mesa-dev libgles2-mesa-dev libgl1-mesa-dev libxi-dev +RUN git clone https://github.com/curv3d/curv.git +RUN cd curv +RUN git submodule init && git submodule update +RUN mkdir build +RUN cd build +RUN sed -i 's/set(LEAN_BUILD 0)/set(LEAN_BUILD 1)/' ../CMakeLists.txt +RUN cmake .. +RUN make +RUN make install + +# install node14, see comment at the top of node14source_setup.sh +ADD src/docker/common/node14source_setup.sh /nodesource_setup.sh +RUN ["chmod", "+x", "/nodesource_setup.sh"] +RUN bash nodesource_setup.sh +RUN apt-get install -y nodejs + +# Install aws-lambda-cpp build dependencies, this is for the post install script in aws-lambda-ric (in package.json) +RUN apt-get update && \ + apt-get install -y \ + g++ \ + make \ + cmake \ + unzip \ + automake autoconf libtool \ + libcurl4-openssl-dev + +# Add the lambda emulator for local dev, (see entrypoint.sh for where it's used), +# I have the file locally (gitignored) to speed up build times (as it downloads everytime), +# but you can use the http version of the below ADD command or download it yourself from that url. +ADD src/docker/common/aws-lambda-rie /usr/local/bin/aws-lambda-rie +# ADD https://github.com/aws/aws-lambda-runtime-interface-emulator/releases/download/v1.0/aws-lambda-rie /usr/local/bin/aws-lambda-rie +RUN ["chmod", "+x", "/usr/local/bin/aws-lambda-rie"] + +WORKDIR /var/task/ +COPY package*.json /var/task/ +RUN npm install +RUN npm install aws-lambda-ric@1.0.0 + +RUN echo "cadhub-concat-split" > /var/task/cadhub-concat-split + +# using built javascript from dist +# run `yarn rw build` before bulding this image +COPY dist/docker/openscad/* /var/task/js/ +COPY dist/docker/common/* /var/task/common/ +COPY src/docker/common/entrypoint.sh /entrypoint.sh +RUN ["chmod", "+x", "/entrypoint.sh"] + +ENTRYPOINT ["sh", "/entrypoint.sh"] +CMD [ "js/curv.render" ] diff --git a/app/api/src/docker/curv/curv.ts b/app/api/src/docker/curv/curv.ts new file mode 100644 index 0000000..a81eb0b --- /dev/null +++ b/app/api/src/docker/curv/curv.ts @@ -0,0 +1,46 @@ +import { runCurv, stlExport } from './runCurv' +import middy from 'middy' +import { cors } from 'middy/middlewares' +import { loggerWrap, storeAssetAndReturnUrl } from '../common/utils' + +const preview = async (req, _context, callback) => { + _context.callbackWaitsForEmptyEventLoop = false + const eventBody = Buffer.from(req.body, 'base64').toString('ascii') + console.log('eventBody', eventBody) + + const { file, settings } = JSON.parse(eventBody) + const { error, consoleMessage, fullPath } = await runCurv({ + file, + settings, + }) + await storeAssetAndReturnUrl({ + error, + callback, + fullPath, + consoleMessage, + }) +} + +const stl = async (req, _context, callback) => { + _context.callbackWaitsForEmptyEventLoop = false + const eventBody = Buffer.from(req.body, 'base64').toString('ascii') + + console.log(eventBody, 'eventBody') + + const { file, settings } = JSON.parse(eventBody) + const { error, consoleMessage, fullPath } = await stlExport({ + file, + settings, + }) + await storeAssetAndReturnUrl({ + error, + callback, + fullPath, + consoleMessage, + }) +} + +module.exports = { + stl: middy(loggerWrap(stl)).use(cors()), + preview: middy(loggerWrap(preview)).use(cors()), +} diff --git a/app/api/src/docker/curv/runCurv.ts b/app/api/src/docker/curv/runCurv.ts new file mode 100644 index 0000000..70c7b08 --- /dev/null +++ b/app/api/src/docker/curv/runCurv.ts @@ -0,0 +1,182 @@ +import { writeFiles, runCommand } from '../common/utils' +import { nanoid } from 'nanoid' +const { readFile } = require('fs/promises') +const fs = require('fs'); +const { spawn } = require('child_process'); + +function* getXDisplayNumber() { + const startValue = 99; + let i = startValue; + while(true) { + i -= 1; + + // Never hit zero since 0 is usually used by desktop users. + if (i <= 0) i = startValue; + yield ':' + i; + } +} + + +export const runCurv = async ({ + file, + settings: { + viewAll = false, + size: { x = 500, y = 500 } = {}, + parameters, + camera: { + position = { x: 40, y: 40, z: 40 }, + rotation = { x: 55, y: 0, z: 25 }, + dist = 200, + } = {}, + } = {}, // TODO add view settings +} = {}): Promise<{ + error?: string + consoleMessage?: string + fullPath?: string + customizerPath?: string +}> => { + const tempFile = await writeFiles( + [ + { file, fileName: 'main.curv' }, + { + file: JSON.stringify({ + parameterSets: { default: parameters }, + fileFormatVersion: '1', + }), + fileName: 'params.json', + }, + ], + 'a' + nanoid() // 'a' ensure nothing funny happens if it start with a bad character like "-", maybe I should pick a safer id generator :shrug: + ) + const { x: rx, y: ry, z: rz } = rotation + const { x: px, y: py, z: pz } = position + const cameraArg = `--camera=${px},${py},${pz},${rx},${ry},${rz},${dist}` + const fullPath = `/tmp/${tempFile}/output.gz` + const imPath = `/tmp/${tempFile}/output.png` + const customizerPath = `/tmp/${tempFile}/customizer.param` + const summaryPath = `/tmp/${tempFile}/summary.json` // contains camera info + + const code = file; + const DISPLAY = getXDisplayNumber().next().value; + + const xvfbProcess = spawn( + 'Xvfb', + [DISPLAY, '-ac', '-nocursor', '-screen', '0', '480x500x24'], + ); + + const curvProcess = spawn( + 'curv', + ['-' ], + { env: Object.assign({}, process.env, { DISPLAY, PATH: '/usr/local/bin' }) } + ); + + curvProcess.stdin.write(code); + curvProcess.stdin.end(); + + let statusSet = false; + let contentTypeSet = false; + + curvProcess.stderr.on('data', (buf) => { + const data = buf.toString('utf8'); + if (data.indexOf('shape') >= 0) { + setTimeout(() => { + const screenshotProcess = spawn('maim', ['--hidecursor', imPath], { env: { DISPLAY } }); + screenshotProcess.on('close', () => { + curvProcess.kill(); + xvfbProcess.kill(); + }); + }, 1000); + } + + if (data.indexOf('ERROR') < 0) { return; } + curvProcess.kill(); + xvfbProcess.kill(); + }); + + + try { + const files: string[] = await Promise.all( + [customizerPath, summaryPath].map((path) => + readFile(path, { encoding: 'ascii' }) + ) + ) + const [params, cameraInfo] = files.map((fileStr: string) => + JSON.parse(fileStr) + ) + await writeFiles( + [ + { + file: JSON.stringify({ + cameraInfo: viewAll ? cameraInfo.camera : undefined, + customizerParams: params.parameters, + consoleMessage, + type: 'png', + }), + fileName: 'metadata.json', + }, + ], + tempFile + ) + await runCommand( + `cat ${imPath} /var/task/cadhub-concat-split /tmp/${tempFile}/metadata.json | gzip > ${fullPath}`, + 15000 + ) + return { consoleMessage, fullPath, customizerPath } + } catch (dirtyError) { + return { error: dirtyError } + } +} + +export const stlExport = async ({ file, settings: { parameters } } = {}) => { + const tempFile = await writeFiles( + [ + { file, fileName: 'main.curv' }, + { + file: JSON.stringify({ + parameterSets: { default: parameters }, + fileFormatVersion: '1', + }), + fileName: 'params.json', + }, + ], + 'a' + nanoid() // 'a' ensure nothing funny happens if it start with a bad character like "-", maybe I should pick a safer id generator :shrug: + ) + const fullPath = `/tmp/${tempFile}/output.gz` + const stlPath = `/tmp/${tempFile}/output.stl` + const customizerPath = `/tmp/${tempFile}/customizer.param` + const command = [ + 'curv', + '-o', stlPath, + '-O jit', + '-O vsize=0.4', + `/tmp/${tempFile}/main.curv`, + ].join(' ') + + try { + // 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(command, 60000) + const params = JSON.parse( + await readFile(customizerPath, { encoding: 'ascii' }) + ).parameters + await writeFiles( + [ + { + file: JSON.stringify({ + customizerParams: params, + consoleMessage, + type: 'stl', + }), + fileName: 'metadata.json', + }, + ], + tempFile + ) + await runCommand( + `cat ${stlPath} /var/task/cadhub-concat-split /tmp/${tempFile}/metadata.json | gzip > ${fullPath}`, + 15000 + ) + return { consoleMessage, fullPath, customizerPath } + } catch (error) { + return { error, fullPath } + } +} diff --git a/app/api/src/docker/docker-compose.yml b/app/api/src/docker/docker-compose.yml index d98992a..892d57a 100644 --- a/app/api/src/docker/docker-compose.yml +++ b/app/api/src/docker/docker-compose.yml @@ -45,3 +45,19 @@ services: AWS_ACCESS_KEY_ID: "${DEV_AWS_ACCESS_KEY_ID}" BUCKET: "${DEV_BUCKET}" + curv-preview: + build: + context: ../../ + dockerfile: ./src/docker/curv/Dockerfile + image: curv + command: js/curv.preview + # Adding volumes so that the containers can be restarted for js only changes in local dev + volumes: + - ../../dist/docker/openscad:/var/task/js/ + - ../../dist/docker/common:/var/task/common/ + ports: + - "5052:8080" + environment: + AWS_SECRET_ACCESS_KEY: "${DEV_AWS_SECRET_ACCESS_KEY}" + AWS_ACCESS_KEY_ID: "${DEV_AWS_ACCESS_KEY_ID}" + BUCKET: "${DEV_BUCKET}" diff --git a/app/api/src/docker/serverless.yml b/app/api/src/docker/serverless.yml index a033f88..78e22f3 100644 --- a/app/api/src/docker/serverless.yml +++ b/app/api/src/docker/serverless.yml @@ -23,6 +23,9 @@ provider: cadqueryimage: path: ../../ file: ./src/docker/cadquery/Dockerfile + curvimage: + path: ../../ + file: ./src/docker/curv/Dockerfile apiGateway: metrics: true binaryMediaTypes: @@ -99,6 +102,21 @@ functions: timeout: 30 environment: BUCKET: cad-preview-bucket-prod-001 + curvpreview: + image: + name: curvimage + command: + - js/curv.preview + entryPoint: + - '/entrypoint.sh' + events: + - http: + path: curv/preview + method: post + cors: true + timeout: 25 + environment: + BUCKET: cad-preview-bucket-prod-001 # The following are a few example events you can configure # NOTE: Please make sure to change your handler code to work with those events # Check the event documentation for details diff --git a/app/api/src/graphql/projects.sdl.ts b/app/api/src/graphql/projects.sdl.ts index 32e4890..8669a9d 100644 --- a/app/api/src/graphql/projects.sdl.ts +++ b/app/api/src/graphql/projects.sdl.ts @@ -23,6 +23,7 @@ export const schema = gql` openscad cadquery jscad + curv } type Query { diff --git a/app/web/src/components/CadPackage/CadPackage.tsx b/app/web/src/components/CadPackage/CadPackage.tsx index 76ddb3c..fef563b 100644 --- a/app/web/src/components/CadPackage/CadPackage.tsx +++ b/app/web/src/components/CadPackage/CadPackage.tsx @@ -1,4 +1,4 @@ -export type CadPackageType = 'openscad' | 'cadquery' | 'jscad' | 'INIT' +export type CadPackageType = 'openscad' | 'cadquery' | 'jscad' | 'curv' | 'INIT' interface CadPackageConfig { label: string @@ -23,6 +23,11 @@ export const cadPackageConfigs: { [key in CadPackageType]: CadPackageConfig } = buttonClasses: 'bg-ch-purple-500', dotClasses: 'bg-yellow-300', }, + curv: { + label: 'Curv', + buttonClasses: 'bg-ch-purple-500', + dotClasses: 'bg-yellow-300', + }, INIT: { label: '', buttonClasses: '', diff --git a/app/web/src/components/IdeEditor/IdeEditor.tsx b/app/web/src/components/IdeEditor/IdeEditor.tsx index 2e6ed8f..20a2bee 100644 --- a/app/web/src/components/IdeEditor/IdeEditor.tsx +++ b/app/web/src/components/IdeEditor/IdeEditor.tsx @@ -18,6 +18,7 @@ const IdeEditor = ({ Loading }) => { cadquery: 'python', openscad: 'cpp', jscad: 'javascript', + curv: 'javascript', INIT: '', } const monaco = useMonaco() diff --git a/app/web/src/components/IdeProjectCell/IdeProjectCell.tsx b/app/web/src/components/IdeProjectCell/IdeProjectCell.tsx index 34ee067..0473d30 100644 --- a/app/web/src/components/IdeProjectCell/IdeProjectCell.tsx +++ b/app/web/src/components/IdeProjectCell/IdeProjectCell.tsx @@ -35,7 +35,7 @@ export interface Project { code: string mainImage: string createdAt: string - cadPackage: 'openscad' | 'cadquery' + cadPackage: 'openscad' | 'cadquery' | 'curv' user: { id: string userName: string diff --git a/app/web/src/components/NavPlusButton/NavPlusButton.tsx b/app/web/src/components/NavPlusButton/NavPlusButton.tsx index d36346e..0fbe7a0 100644 --- a/app/web/src/components/NavPlusButton/NavPlusButton.tsx +++ b/app/web/src/components/NavPlusButton/NavPlusButton.tsx @@ -95,6 +95,13 @@ const menuOptions: { dotClasses: 'bg-yellow-300', ideType: 'jscad', }, + { + name: 'Curv', + sub: 'beta', + bgClasses: 'bg-ch-blue-700', + dotClasses: 'bg-blue-800', + ideType: 'curv', + }, ] const NavPlusButton: React.FC = () => { diff --git a/app/web/src/helpers/cadPackages/curv/curvController.ts b/app/web/src/helpers/cadPackages/curv/curvController.ts new file mode 100644 index 0000000..8323a8b --- /dev/null +++ b/app/web/src/helpers/cadPackages/curv/curvController.ts @@ -0,0 +1,143 @@ +import { + lambdaBaseURL, + stlToGeometry, + createHealthyResponse, + createUnhealthyResponse, + timeoutErrorMessage, + RenderArgs, + splitGziped, +} from '../common' +import { CurvToCadhubParams } from './openScadParams' +import type { XYZ, Camera } from 'src/helpers/hooks/useIdeState' + +export const render = async ({ code, settings }: RenderArgs) => { + const pixelRatio = window.devicePixelRatio || 1 + const size = { + x: Math.round(settings.viewerSize?.width * pixelRatio), + y: Math.round(settings.viewerSize?.height * pixelRatio), + } + const round1dec = (number) => Math.round((number + Number.EPSILON) * 10) / 10 + const body = JSON.stringify({ + settings: { + size, + viewAll: settings.viewAll, + parameters: settings.parameters, + camera: { + // rounding to give our caching a chance to sometimes work + ...settings.camera, + dist: round1dec(settings.camera.dist), + position: { + x: round1dec(settings.camera.position.x), + y: round1dec(settings.camera.position.y), + z: round1dec(settings.camera.position.z), + }, + rotation: { + x: round1dec(settings.camera.rotation.x), + y: round1dec(settings.camera.rotation.y), + z: round1dec(settings.camera.rotation.z), + }, + }, + }, + file: code, + }) + if (!settings.camera.position) { + return + } + try { + const response = await fetch(lambdaBaseURL + '/curv/preview', { + 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) + } + if (response.status === 502) { + return createUnhealthyResponse(new Date(), timeoutErrorMessage) + } + const blob = await response.blob() + const text = await new Response(blob).text() + const { consoleMessage, customizerParams, type, cameraInfo } = + splitGziped(text) + const vecArray2Obj = (arr: number[]): XYZ => ({ + x: arr[0], + y: arr[1], + z: arr[2], + }) + const camera: Camera = cameraInfo + ? { + dist: cameraInfo?.distance, + position: vecArray2Obj(cameraInfo?.translation), + rotation: vecArray2Obj(cameraInfo?.rotation), + isScadUpdate: true, + } + : undefined + return createHealthyResponse({ + type: type !== 'stl' ? 'png' : 'geometry', + data: + type !== 'stl' + ? blob + : await stlToGeometry(window.URL.createObjectURL(blob)), + consoleMessage, + camera, + date: new Date(), + customizerParams: curvToCadhubParams(customizerParams || []), + }) + } catch (e) { + return createUnhealthyResponse(new Date()) + } +} + +export const stl = async ({ code /*settings*/ }: RenderArgs) => { + const body = JSON.stringify({ + settings: {}, + file: code, + }) + try { + const response = await fetch(lambdaBaseURL + '/curv/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) + } + if (response.status === 502) { + return createUnhealthyResponse(new Date(), timeoutErrorMessage) + } + const blob = await response.blob() + const text = await new Response(blob).text() + const { consoleMessage, customizerParams, type } = splitGziped(text) + return createHealthyResponse({ + type: type !== 'stl' ? 'png' : 'geometry', + data: + type !== 'stl' + ? blob + : await stlToGeometry(window.URL.createObjectURL(blob)), + consoleMessage, + date: new Date(), + customizerParams: openScadToCadhubParams(customizerParams || []), + }) + } catch (e) { + return createUnhealthyResponse(new Date()) + } +} + +const curv = { + render, + stl, +} + +export default curv + +function cleanError(error) { + return error.replace(/["|']\/tmp\/.+\/main.curv["|']/g, "'main.curv'") +} diff --git a/app/web/src/helpers/cadPackages/curv/curvParams.ts b/app/web/src/helpers/cadPackages/curv/curvParams.ts new file mode 100644 index 0000000..a0f4018 --- /dev/null +++ b/app/web/src/helpers/cadPackages/curv/curvParams.ts @@ -0,0 +1,90 @@ +import { CadhubParams } from 'src/components/Customizer/customizerConverter' + +interface CurvParamsBase { + caption: string + name: string + group: string + initial: number | string | number[] + type: 'string' | 'number' +} + +interface CurvNumberParam extends CurvParamsBase { + type: 'number' + initial: number | number[] + max?: number + min?: number + step?: number + options?: { name: string; value: number }[] +} +interface CurvStringParam extends CurvParamsBase { + type: 'string' + initial: string + maxLength?: number + options?: { name: string; value: string }[] +} + +export type CurvParams = + | CurvNumberParam + | CurvStringParam + +export function openScadToCadhubParams( + input: CurvParams[] +): CadhubParams[] { + return input + .map((param): CadhubParams => { + const common: { caption: string; name: string } = { + caption: param.caption, + name: param.name, + } + switch (param.type) { + case 'string': + if (!Array.isArray(param?.options)) { + return { + type: 'string', + input: 'default-string', + ...common, + initial: param.initial, + maxLength: param.maxLength, + } + } else { + return { + type: 'string', + input: 'choice-string', + ...common, + initial: param.initial, + options: param.options, + } + } + case 'number': + if ( + !Array.isArray(param?.options) && + !Array.isArray(param?.initial) + ) { + return { + type: 'number', + input: 'default-number', + ...common, + initial: param.initial, + min: param.min, + max: param.max, + step: param.step, + } + } else if ( + Array.isArray(param?.options) && + !Array.isArray(param?.initial) + ) { + return { + type: 'number', + input: 'choice-number', + ...common, + initial: param.initial, + options: param.options, + } + } // TODO else vector + break + default: + return + } + }) + .filter((a) => a) +} diff --git a/app/web/src/helpers/cadPackages/curv/initialCode.scad b/app/web/src/helpers/cadPackages/curv/initialCode.scad new file mode 100644 index 0000000..129a2d3 --- /dev/null +++ b/app/web/src/helpers/cadPackages/curv/initialCode.scad @@ -0,0 +1,8 @@ +// sphere box + +// ^ first comment is used for download title (i.e "involute-donut.stl") + +(smooth 1).union [ + box, + sphere +] diff --git a/app/web/src/helpers/cadPackages/curv/userGuide.md b/app/web/src/helpers/cadPackages/curv/userGuide.md new file mode 100644 index 0000000..4b3a835 --- /dev/null +++ b/app/web/src/helpers/cadPackages/curv/userGuide.md @@ -0,0 +1,9 @@ +--- +title: Curv +Written with: [Domain-Specific Language](https://martinfowler.com/dsl.html) +Kernal type: Signed distance functions +Maintained by: [Doug Moen and contributors](https://github.com/curv/curv/graphs/contributors) +Documentation: [curv3d.org](https://curv3d.org) +--- + +Curv is a programming language for creating art using mathematics. It’s a 2D and 3D geometric modelling tool that supports full colour, animation and 3D printing. diff --git a/app/web/src/helpers/cadPackages/index.ts b/app/web/src/helpers/cadPackages/index.ts index ea420c6..28589d9 100644 --- a/app/web/src/helpers/cadPackages/index.ts +++ b/app/web/src/helpers/cadPackages/index.ts @@ -13,16 +13,22 @@ import jscad from './jsCad/jsCadController' import jsCadGuide from 'src/helpers/cadPackages/jsCad/userGuide.md' import jsCadInitialCode from 'src/helpers/cadPackages/jsCad/initialCode.jscad.js' +import curv from './curv/curvController' +import curvGuide from 'src/helpers/cadPackages/curv/userGuide.md' +import curvInitialCode from 'src/helpers/cadPackages/curv/initialCode.py' + export const cadPackages: { [key in CadPackageType]: DefaultKernelExport } = { openscad, cadquery, jscad, + curv, } export const initGuideMap: { [key in CadPackageType]: string } = { openscad: openScadGuide, cadquery: cadQueryGuide, jscad: jsCadGuide, + curv: curvGuide, INIT: '', } @@ -30,5 +36,6 @@ export const initCodeMap: { [key in CadPackageType]: string } = { openscad: openScadInitialCode, cadquery: cadQueryInitialCode, jscad: jsCadInitialCode, + curv: curvInitialCode, INIT: '', }