diff --git a/axes.scad b/axes.scad new file mode 100644 index 0000000..9dbd839 --- /dev/null +++ b/axes.scad @@ -0,0 +1,46 @@ +module arrow(length=30, shaft_radius=1, head_radius=2, head_length=5) { + cylinder(h=length-head_length, r=shaft_radius, center=false); + + translate([0, 0, length-head_length]) + cylinder(h=head_length, r1=head_radius, r2=0, center=false); +} + +color("red") + rotate([0, 90, 0]) + arrow(); + +color("green") + rotate([-90, 0, 0]) + arrow(); + +color("blue") + rotate([0, 0, 90]) + arrow(); + +module letter(text) + linear_extrude(1) text(text, halign="center", valign="center"); + +letter_dist = 38; +union() { + color("red") + translate([letter_dist, 0, 0]) + rotate([45, 0, 45]) + letter("X"); + color("green") + rotate([0, 0, 90]) + translate([letter_dist, 0, 0]) + rotate([45, 0, -45]) + letter("Y"); + color("blue") + rotate([0, -90, 0]) + translate([letter_dist, 0, 0]) + rotate([90+45, 0, 0]) + rotate([0, 0, -90]) + letter("Z"); +} + +color("grey") + cube(10, center=true); + +color([0, 0, 0, $preview ? 0.05 : 0]) + sphere(r=43); diff --git a/package.json b/package.json index e15b926..00ba125 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,7 @@ "primeicons": "^6.0.1", "primereact": "9.3.x", "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-stl-viewer": "^2.2.5" + "react-dom": "^18.2.0" }, "scripts": { "test:e2e": "jest", @@ -70,7 +69,7 @@ "rollup-plugin-css": "^1.0.0", "rollup-plugin-postcss": "^4.0.2", "rollup-plugin-typescript": "^1.0.1", - "rollup-plugin-typescript2": "^0.34.1", + "rollup-plugin-typescript2": "^0.36.0", "rollup-watch": "^3.2.2", "serve": "^14.2.0", "style-loader": "^3.3.3", diff --git a/public/axes.glb b/public/axes.glb new file mode 100644 index 0000000..821f204 Binary files /dev/null and b/public/axes.glb differ diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 105b166..61a34ed 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -10,6 +10,7 @@ import { Menu } from 'primereact/menu'; import { Toast } from 'primereact/toast'; import HelpMenu from './HelpMenu'; import ExportButton from './ExportButton'; +import SettingsMenu from './SettingsMenu'; export default function Footer({style}: {style?: CSSProperties}) { @@ -76,6 +77,8 @@ export default function Footer({style}: {style?: CSSProperties}) {
+ + model.mutate(s => s.view.showAxes = !s.view.showAxes) }, - { - label: state.view.showShadows ? 'Hide shadows' : 'Add shadows', - icon: 'pi pi-box', - // disabled: true, - command: () => model.mutate(s => s.view.showShadows = !s.view.showShadows) - }, { label: state.view.lineNumbers ? 'Hide line numbers' : 'Show line numbers', icon: 'pi pi-list', diff --git a/src/components/ViewerPanel.tsx b/src/components/ViewerPanel.tsx index 08823c9..ad153cb 100644 --- a/src/components/ViewerPanel.tsx +++ b/src/components/ViewerPanel.tsx @@ -1,10 +1,7 @@ // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. -import { CSSProperties, useContext, useRef } from 'react'; +import { CSSProperties, useContext, useEffect, useRef, useState } from 'react'; import { ModelContext } from './contexts'; -import { StlViewer} from "react-stl-viewer"; -import { ColorPicker } from 'primereact/colorpicker'; -import { defaultModelColor } from '../state/initial-state'; declare global { namespace JSX { @@ -19,62 +16,80 @@ export default function ViewerPanel({className, style}: {className?: string, sty if (!model) throw new Error('No model'); const state = model.state; - const modelRef = useRef(); + const modelViewerRef = useRef(); + const axesViewerRef = useRef(); + for (const ref of [modelViewerRef, axesViewerRef]) { + const otherRef = ref === modelViewerRef ? axesViewerRef : modelViewerRef; + useEffect(() => { + function handleCameraChange(e: any) { + if (e.detail.source === 'user-interaction') { + const cameraOrbit = ref.current.getCameraOrbit(); + cameraOrbit.radius = otherRef.current.getCameraOrbit().radius; + + otherRef.current.cameraOrbit = cameraOrbit.toString(); + } + } + ref.current.addEventListener('camera-change', handleCameraChange); + return () => ref.current.removeEventListener('camera-change', handleCameraChange); + }, [ref.current, otherRef.current]); + } + return (
- {(state.output?.displayFileURL || state.output?.outFile && state.output.outFile.name.endsWith('.glb') && state.output?.outFileURL) && ( - { - modelRef.current = ref; - }} - /> + + + + {state.view.showAxes && ( + + + )} - {state.output?.outFile && state.output.outFile.name.endsWith('.stl') && state.output?.outFileURL && ( - <> - - model.mutate(s => s.view.color = `#${e.value ?? defaultModelColor}`)} - /> - - )}
) } diff --git a/src/language/openscad-completions.ts b/src/language/openscad-completions.ts index da8e2a2..2951b63 100644 --- a/src/language/openscad-completions.ts +++ b/src/language/openscad-completions.ts @@ -216,15 +216,15 @@ export async function buildOpenSCADCompletionItemProvider(fs: FS, workingDir: st const isFolder = !file.endsWith('.scad'); const completion = file + (isFolder ? '' : '>\n'); // don't append '/' as it's a useful trigger char - console.log(JSON.stringify({ - prefix, - folder, - filePrefix, - folderPrefix, - // files, - completion, - file, - }, null, 2)); + // console.log(JSON.stringify({ + // prefix, + // folder, + // filePrefix, + // folderPrefix, + // // files, + // completion, + // file, + // }, null, 2)); suggestions.push({ label: file, diff --git a/src/multimaterial/off2glb.ts b/src/multimaterial/off2glb.ts index 2f3a684..ee98923 100644 --- a/src/multimaterial/off2glb.ts +++ b/src/multimaterial/off2glb.ts @@ -7,11 +7,11 @@ type Vertex = { z: number; } -type SolidColor = [number, number, number]; +type Color = [number, number, number, number]; type Face = { vertices: number[]; - color?: SolidColor; + color?: Color; } type IndexedPolyhedron = { @@ -19,10 +19,41 @@ type IndexedPolyhedron = { faces: Face[]; } -const DEFAULT_FACE_COLOR: SolidColor = [0xf9 / 255, 0xd7 / 255, 0x2c / 255]; +type Geom = { + positions: Float32Array; + indices: Uint32Array; + colors?: Float32Array; +}; + +const DEFAULT_FACE_COLOR: Color = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1]; + +function createPrimitive(doc: Document, baseColorFactor: Color, {positions, indices, colors}: Geom): Primitive { + const prim = doc.createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) + .setMaterial( + doc.createMaterial() + .setDoubleSided(true) + .setAlphaMode(baseColorFactor[3] < 1 ? 'BLEND' : 'OPAQUE') + .setBaseColorFactor(baseColorFactor)) + .setAttribute('POSITION', + doc.createAccessor() + .setType(Accessor.Type.VEC3) + .setArray(positions)) + .setIndices( + doc.createAccessor() + .setType(Accessor.Type.SCALAR) + .setArray(indices)); + if (colors) { + prim.setAttribute('COLOR_0', + doc.createAccessor() + .setType(Accessor.Type.VEC3) + .setArray(colors)); + } + return prim; +} -export async function convertOffToGlb(data: IndexedPolyhedron, defaultColor: SolidColor = DEFAULT_FACE_COLOR): Promise { - // Note: GLTF doesn't seem to support per-face colors, so we duplicate vertices +function getColoredGeom(data: IndexedPolyhedron, defaultColor: Color = DEFAULT_FACE_COLOR): Geom { + // Note: GLTF doesn't support per-face colors, so we duplicate vertices // and provide per-vertex colors (all the same for each face). const numVertices = data.faces.reduce((acc, face) => acc + face.vertices.length, 0); @@ -31,7 +62,7 @@ export async function convertOffToGlb(data: IndexedPolyhedron, defaultColor: Sol const indices = new Uint32Array(numVertices); let verticesAdded = 0; - const addVertex = (vertex: Vertex, color: [number, number, number]) => { + const addVertex = (vertex: Vertex, color: Color) => { const offset = verticesAdded * 3; positions[offset] = vertex.x; positions[offset + 1] = vertex.y; @@ -53,34 +84,49 @@ export async function convertOffToGlb(data: IndexedPolyhedron, defaultColor: Sol indices[offset + 1] = addVertex(data.vertices[vertices[1]], faceColor); indices[offset + 2] = addVertex(data.vertices[vertices[2]], faceColor); }); + return { positions, indices, colors }; +} +function getGeom(data: IndexedPolyhedron): Geom { + let positions = new Float32Array(data.vertices.length * 3); + const indices = new Uint32Array(data.faces.length * 3); + + const addedVertices = new Map(); + let verticesAdded = 0; + const addVertex = (i: number) => { + let index = addedVertices.get(i); + if (index === undefined) { + const offset = verticesAdded * 3; + const vertex = data.vertices[i]; + positions[offset] = vertex.x; + positions[offset + 1] = vertex.y; + positions[offset + 2] = vertex.z; + index = verticesAdded++; + addedVertices.set(i, index); + } + return index; + }; + + data.faces.forEach((face, i) => { + const { vertices, color } = face; + if (vertices.length < 3) throw new Error('Face must have at least 3 vertices'); + + const offset = i * 3; + indices[offset] = addVertex(vertices[0]); + indices[offset + 1] = addVertex(vertices[1]); + indices[offset + 2] = addVertex(vertices[2]); + }); + return { + positions: positions.slice(0, verticesAdded * 3), + indices + }; +} +export async function convertOffToGlb(data: IndexedPolyhedron, defaultColor: Color = DEFAULT_FACE_COLOR): Promise { const doc = new Document(); const lightExt = doc.createExtension(KHRLightsPunctual); const buffer = doc.createBuffer(); - doc.createScene() - .addChild(doc.createNode().setMesh( - doc.createMesh().addPrimitive( - doc.createPrimitive() - .setMode(Primitive.Mode.TRIANGLES) - .setMaterial( - doc.createMaterial() - .setDoubleSided(true) - .setBaseColorFactor([1,1,1,1])) - .setAttribute('POSITION', - doc.createAccessor() - .setType(Accessor.Type.VEC3) - .setArray(positions) - .setBuffer(buffer)) - .setIndices( - doc.createAccessor() - .setType(Accessor.Type.SCALAR) - .setArray(indices) - .setBuffer(buffer)) - .setAttribute('COLOR_0', - doc.createAccessor() - .setType(Accessor.Type.VEC3) - .setArray(colors) - .setBuffer(buffer))))) + + const scene = doc.createScene() .addChild(doc.createNode() .setExtension('KHR_lights_punctual', lightExt .createLight() @@ -96,6 +142,45 @@ export async function convertOffToGlb(data: IndexedPolyhedron, defaultColor: Sol .setColor([1.0, 1.0, 1.0])) .setRotation([0.6279631, 0.6279631, 0, 0.4597009])); + const mesh = doc.createMesh(); + + if (true) { + const facesByColor = new Map(); + const getRGBA = (color?: Color) => color ? color.join(',') : ''; + data.faces.forEach(face => { + const color = getRGBA(face.color); + let faces = facesByColor.get(color); + if (!faces) facesByColor.set(color, faces = []); + faces.push(face); + }); + for (let [rgba, faces] of facesByColor.entries()) { + let color; + if (rgba === '') { + color = defaultColor; + } else { + color = rgba.split(',').map(Number) as Color; + } + const [r, g, b, a] = color; + mesh.addPrimitive( + createPrimitive(doc, [r, g, b, a ?? 1], getGeom({ vertices: data.vertices, faces }))); + } + } else if (true) { + const facesByAlpha = new Map(); + data.faces.forEach(face => { + const alpha = face.color ? face.color[3] : 1; + const faces = facesByAlpha.get(alpha) ?? []; + faces.push(face); + facesByAlpha.set(alpha, faces); + }); + for (const [alpha, faces] of facesByAlpha.entries()) { + mesh.addPrimitive( + createPrimitive(doc, [1, 1, 1, alpha], getColoredGeom({ vertices: data.vertices, faces }, defaultColor))); + } + } else { + mesh.addPrimitive(createPrimitive(doc, [1, 1, 1, 1], getColoredGeom(data, defaultColor))); + } + scene.addChild(doc.createNode().setMesh(mesh)); + const glb = await new NodeIO().registerExtensions([KHRLightsPunctual]).writeBinary(doc); return new Blob([glb], { type: 'model/gltf-binary' }); } @@ -136,7 +221,7 @@ export function parseOff(content: string): IndexedPolyhedron { const numVerts = parts[0]; const vertices = parts.slice(1, numVerts + 1); const color = parts.length >= numVerts + 4 - ? parts.slice(numVerts + 1, numVerts + 4).map(c => c / 255) as [number, number, number] + ? parts.slice(numVerts + 1, numVerts + 5).map(c => c / 255) as [number, number, number, number] : undefined; if (vertices.length < 3) throw new Error(`Invalid OFF file: face at line ${currentLine + i + 1} must have at least 3 vertices`); else if (vertices.length == 3) { diff --git a/src/state/app-state.ts b/src/state/app-state.ts index 6a2b128..e7ac7d9 100644 --- a/src/state/app-state.ts +++ b/src/state/app-state.ts @@ -48,7 +48,6 @@ export interface State { color: string, showAxes?: boolean, - showShadows?: boolean, lineNumbers?: boolean, } diff --git a/src/state/fragment-state.ts b/src/state/fragment-state.ts index b613d5d..e6841ed 100644 --- a/src/state/fragment-state.ts +++ b/src/state/fragment-state.ts @@ -67,7 +67,7 @@ export async function readStateFromFragment(): Promise { // Source deserialization also handles legacy links (source + sourcePath) sources: params?.sources ?? (params?.source ? [{path: params?.sourcePath, content: params?.source}] : undefined), // TODO: validate! exportFormat2D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_2D), s => 'svg'), - exportFormat3D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'off'), + exportFormat3D: validateStringEnum(params?.exportFormat, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'), }, view: { logs: validateBoolean(view?.logs), @@ -82,7 +82,6 @@ export async function readStateFromFragment(): Promise { collapsedCustomizerTabs: validateArray(view?.collapsedCustomizerTabs, validateString), color: validateString(view?.color, () => defaultModelColor), showAxes: validateBoolean(view?.layout?.showAxis, () => true), - showShadows: validateBoolean(view?.layout?.showShadow, () => true), lineNumbers: validateBoolean(view?.layout?.lineNumbers, () => false) } }; diff --git a/src/state/initial-state.ts b/src/state/initial-state.ts index 696b9de..66d7170 100644 --- a/src/state/initial-state.ts +++ b/src/state/initial-state.ts @@ -52,7 +52,6 @@ export function createInitialState(state: State | null, content: string = defaul } initialState.view.showAxes ??= true - initialState.view.showShadows ??= true // fs.writeFile(initialState.params.sourcePath, initialState.params.source); // if (initialState.params.sourcePath !== defaultSourcePath) { diff --git a/tests/e2e.test.js b/tests/e2e.test.js index f51f092..d3152b2 100644 --- a/tests/e2e.test.js +++ b/tests/e2e.test.js @@ -10,6 +10,11 @@ describe('e2e', () => { page.goto(url); await page.waitForSelector('model-viewer'); + await page.waitForFunction(() => { + const viewer = document.querySelector('model-viewer.main-viewer'); + return viewer && viewer.src !== ''; + }); + console.log('Messages:', JSON.stringify(messages, null, 2)); const errors = messages.filter(msg => msg.type === 'error');