From 22fd82e3f2efc0b224b8954fbc6edcea824eafeb Mon Sep 17 00:00:00 2001 From: ochafik Date: Mon, 23 Dec 2024 23:58:54 +0000 Subject: [PATCH] Revamp off2glb --- package.json | 7 +- src/io/common.ts | 20 +++ src/io/export_glb.ts | 109 ++++++++++++++++ src/io/import_off.ts | 71 +++++++++++ src/multimaterial/off2glb.ts | 238 ----------------------------------- src/state/model.ts | 3 +- 6 files changed, 208 insertions(+), 240 deletions(-) create mode 100644 src/io/common.ts create mode 100644 src/io/export_glb.ts create mode 100644 src/io/import_off.ts delete mode 100644 src/multimaterial/off2glb.ts diff --git a/package.json b/package.json index 0035256..24b4d02 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@types/jest": "^27.5.2", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "chroma-js": "^3.1.2", "debug": "^4.4.0", "jszip": "^3.10.1", "monaco-editor": "^0.36.1", @@ -22,7 +23,9 @@ "primeicons": "^7.0.0", "primereact": "^10.8.5", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "uuid": "^11.0.3", + "uzip": "^0.20201231.0" }, "scripts": { "test:e2e": "jest", @@ -54,8 +57,10 @@ "@rollup/plugin-node-resolve": "^15.3.1", "@rollup/plugin-replace": "^5.0.7", "@rollup/plugin-typescript": "^11.1.6", + "@types/chroma-js": "^2.4.5", "@types/filesystem": "^0.0.32", "@types/node": "^18.19.68", + "@types/uzip": "^0.20201231.2", "@web/rollup-plugin-html": "^1.11.1", "concurrently": "^7.6.0", "copy-webpack-plugin": "^11.0.0", diff --git a/src/io/common.ts b/src/io/common.ts new file mode 100644 index 0000000..5bd7c73 --- /dev/null +++ b/src/io/common.ts @@ -0,0 +1,20 @@ +export type Vertex = { + x: number; + y: number; + z: number; +} + +export type Color = [number, number, number, number]; + +export type Face = { + vertices: [number, number, number]; + colorIndex: number; +} + +export type IndexedPolyhedron = { + vertices: Vertex[]; + faces: Face[]; + colors: Color[]; +} + +export const DEFAULT_FACE_COLOR: Color = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1]; diff --git a/src/io/export_glb.ts b/src/io/export_glb.ts new file mode 100644 index 0000000..c16c01f --- /dev/null +++ b/src/io/export_glb.ts @@ -0,0 +1,109 @@ +import { Document, NodeIO, Accessor, Primitive } from '@gltf-transform/core'; +import { Light as LightDef, KHRLightsPunctual } from '@gltf-transform/extensions'; +import { Vertex, Color, Face, IndexedPolyhedron, DEFAULT_FACE_COLOR } from './common'; + +type Geom = { + positions: Float32Array; + indices: Uint32Array; + colors?: Float32Array; +}; + +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; +} + +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 } = 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(); + + const scene = doc.createScene() + .addChild(doc.createNode() + .setExtension('KHR_lights_punctual', lightExt + .createLight() + .setType(LightDef.Type.DIRECTIONAL) + .setIntensity(8.0) + .setColor([1.0, 1.0, 1.0])) + .setRotation([-0.3250576, -0.3250576, 0, 0.8880739])) + .addChild(doc.createNode() + .setExtension('KHR_lights_punctual', lightExt + .createLight() + .setType(LightDef.Type.DIRECTIONAL) + .setIntensity(8.0) + .setColor([1.0, 1.0, 1.0])) + .setRotation([0.6279631, 0.6279631, 0, 0.4597009])); + + const mesh = doc.createMesh(); + + const facesByColor = new Map(); + data.faces.forEach(face => { + let faces = facesByColor.get(face.colorIndex); + if (!faces) facesByColor.set(face.colorIndex, faces = []); + faces.push(face); + }); + for (let [colorIndex, faces] of facesByColor.entries()) { + let color = data.colors[colorIndex]; + mesh.addPrimitive( + createPrimitive(doc, color, getGeom({ vertices: data.vertices, faces, colors: data.colors }))); + } + scene.addChild(doc.createNode().setMesh(mesh)); + + const glb = await new NodeIO().registerExtensions([KHRLightsPunctual]).writeBinary(doc); + return new Blob([glb], { type: 'model/gltf-binary' }); +} diff --git a/src/io/import_off.ts b/src/io/import_off.ts new file mode 100644 index 0000000..7c4e412 --- /dev/null +++ b/src/io/import_off.ts @@ -0,0 +1,71 @@ +import { Color, DEFAULT_FACE_COLOR, Face, IndexedPolyhedron, Vertex } from './common'; + +export function parseOff(content: string): IndexedPolyhedron { + const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0 && !line.startsWith('#')); + + if (lines.length === 0) throw new Error('Empty OFF file'); + + let counts: string; + let currentLine = 0; + if (lines[0].match(/^OFF(\s|$)/)) { + counts = lines[0].substring(3).trim(); + currentLine = 1; + } else if (lines[currentLine] === 'OFF' && lines.length > 1) { + counts = lines[1]; + currentLine = 2; + } else { + throw new Error('Invalid OFF file: missing OFF header'); + } + + const [numVertices, numFaces] = counts.split(/\s+/).map(Number); + if (isNaN(numVertices) || isNaN(numFaces)) throw new Error('Invalid OFF file: invalid vertex or face counts'); + + if (currentLine + numVertices + numFaces > lines.length) throw new Error('Invalid OFF file: not enough lines'); + + const vertices: Vertex[] = []; + for (let i = 0; i < numVertices; i++) { + const parts = lines[currentLine + i].split(/\s+/).map(Number); + if (parts.length < 3 || parts.some(isNaN)) throw new Error(`Invalid OFF file: invalid vertex at line ${currentLine + i + 1}`); + vertices.push({ x: parts[0], y: parts[1], z: parts[2] }); + } + currentLine += numVertices; + + const colors: Color[] = []; + const colorMap = new Map(); + + const faces: Face[] = []; + for (let i = 0; i < numFaces; i++) { + const parts = lines[currentLine + i].split(/\s+/).map(Number); + const numVerts = parts[0]; + const vertices = parts.slice(1, numVerts + 1); + const color = parts.length >= numVerts + 4 + ? parts.slice(numVerts + 1, numVerts + 5).map(c => c / 255) as [number, number, number, number] + : DEFAULT_FACE_COLOR; + if (vertices.length < 3) throw new Error(`Invalid OFF file: face at line ${currentLine + i + 1} must have at least 3 vertices`); + + const colorKey = color ? color.join(',') : ''; + let colorIndex = colorMap.get(colorKey); + if (colorIndex == null) { + colorIndex = colors.length; + colors.push(color); + colorMap.set(colorKey, colorIndex); + } + + if (vertices.length == 3) { + faces.push({ + vertices: vertices as [number, number, number], + colorIndex + }); + } else { + // Triangulate the face + for (let j = 1; j < vertices.length - 1; j++) { + faces.push({ + vertices: [vertices[0], vertices[j], vertices[j + 1]], + colorIndex + }); + } + } + } + + return { vertices, faces, colors }; +} diff --git a/src/multimaterial/off2glb.ts b/src/multimaterial/off2glb.ts deleted file mode 100644 index ee98923..0000000 --- a/src/multimaterial/off2glb.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { Document, NodeIO, Accessor, Primitive } from '@gltf-transform/core'; -import { Light as LightDef, KHRLightsPunctual } from '@gltf-transform/extensions'; - -type Vertex = { - x: number; - y: number; - z: number; -} - -type Color = [number, number, number, number]; - -type Face = { - vertices: number[]; - color?: Color; -} - -type IndexedPolyhedron = { - vertices: Vertex[]; - faces: Face[]; -} - -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; -} - -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); - const positions = new Float32Array(numVertices * 3); - const colors = new Float32Array(numVertices * 3); - const indices = new Uint32Array(numVertices); - - let verticesAdded = 0; - const addVertex = (vertex: Vertex, color: Color) => { - const offset = verticesAdded * 3; - positions[offset] = vertex.x; - positions[offset + 1] = vertex.y; - positions[offset + 2] = vertex.z; - colors[offset] = color[0]; - colors[offset + 1] = color[1]; - colors[offset + 2] = color[2]; - return verticesAdded++; - }; - - data.faces.forEach((face, i) => { - const { vertices, color } = face; - if (vertices.length < 3) throw new Error('Face must have at least 3 vertices'); - - const faceColor = color ?? defaultColor; - - const offset = i * 3; - indices[offset] = addVertex(data.vertices[vertices[0]], faceColor); - 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(); - - const scene = doc.createScene() - .addChild(doc.createNode() - .setExtension('KHR_lights_punctual', lightExt - .createLight() - .setType(LightDef.Type.DIRECTIONAL) - .setIntensity(8.0) - .setColor([1.0, 1.0, 1.0])) - .setRotation([-0.3250576, -0.3250576, 0, 0.8880739])) - .addChild(doc.createNode() - .setExtension('KHR_lights_punctual', lightExt - .createLight() - .setType(LightDef.Type.DIRECTIONAL) - .setIntensity(8.0) - .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' }); -} - -export function parseOff(content: string): IndexedPolyhedron { - const lines = content.split('\n').map(line => line.trim()).filter(line => line.length > 0 && !line.startsWith('#')); - - if (lines.length === 0) throw new Error('Empty OFF file'); - - let counts: string; - let currentLine = 0; - if (lines[0].match(/^OFF(\s|$)/)) { - counts = lines[0].substring(3).trim(); - currentLine = 1; - } else if (lines[currentLine] === 'OFF' && lines.length > 1) { - counts = lines[1]; - currentLine = 2; - } else { - throw new Error('Invalid OFF file: missing OFF header'); - } - - const [numVertices, numFaces] = counts.split(/\s+/).map(Number); - if (isNaN(numVertices) || isNaN(numFaces)) throw new Error('Invalid OFF file: invalid vertex or face counts'); - - if (currentLine + numVertices + numFaces > lines.length) throw new Error('Invalid OFF file: not enough lines'); - - const vertices: Vertex[] = []; - for (let i = 0; i < numVertices; i++) { - const parts = lines[currentLine + i].split(/\s+/).map(Number); - if (parts.length < 3 || parts.some(isNaN)) throw new Error(`Invalid OFF file: invalid vertex at line ${currentLine + i + 1}`); - vertices.push({ x: parts[0], y: parts[1], z: parts[2] }); - } - currentLine += numVertices; - - const faces: Face[] = []; - for (let i = 0; i < numFaces; i++) { - const parts = lines[currentLine + i].split(/\s+/).map(Number); - const numVerts = parts[0]; - const vertices = parts.slice(1, numVerts + 1); - const color = parts.length >= numVerts + 4 - ? 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) { - faces.push({ vertices, color }); - } else { - // Triangulate the face - for (let j = 1; j < vertices.length - 1; j++) { - faces.push({ vertices: [vertices[0], vertices[j], vertices[j + 1]], color }); - } - } - } - - return { vertices, faces }; -} diff --git a/src/state/model.ts b/src/state/model.ts index 2a32017..bef56a4 100644 --- a/src/state/model.ts +++ b/src/state/model.ts @@ -9,7 +9,8 @@ import { downloadUrl, fetchSource, formatBytes, formatMillis, readFileAsDataURL import JSZip from 'jszip'; import { ProcessStreams } from "../runner/openscad-runner"; import { is2DFormatExtension } from "./formats"; -import { convertOffToGlb, parseOff } from "../multimaterial/off2glb"; +import { parseOff } from "../io/import_off"; +import { convertOffToGlb } from "../io/export_glb"; export class Model { constructor(private fs: FS, public state: State, private setStateCallback?: (state: State) => void,