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');