diff --git a/lib/converters/circuit-to-3d.ts b/lib/converters/circuit-to-3d.ts index fca4300..3d87ea2 100644 --- a/lib/converters/circuit-to-3d.ts +++ b/lib/converters/circuit-to-3d.ts @@ -10,6 +10,7 @@ import type { CircuitTo3DOptions, Camera3D, Light3D, + ExternalGLTFInstance, } from "../types" import { loadSTL } from "../loaders/stl" import { loadOBJ } from "../loaders/obj" @@ -47,6 +48,7 @@ export async function convertCircuitJsonTo3D( const db: any = cju(circuitJson) const boxes: Box3D[] = [] + const externalGLTFs: ExternalGLTFInstance[] = [] // Get PCB board const pcbBoard = db.pcb_board.list()[0] @@ -95,7 +97,12 @@ export async function convertCircuitJsonTo3D( for (const cad of cadComponents) { const { model_stl_url, model_obj_url } = cad - if (!model_stl_url && !model_obj_url) continue + const model_gltf_url = (cad as any).model_gltf_url as string | undefined + const model_glb_url = (cad as any).model_glb_url as string | undefined + + if (!model_stl_url && !model_obj_url && !model_gltf_url && !model_glb_url) { + continue + } pcbComponentIdsWith3D.add(cad.pcb_component_id) @@ -118,6 +125,21 @@ export async function convertCircuitJsonTo3D( z: pcbComponent?.center.y ?? 0, } + const rotation = cad.rotation + ? convertRotationFromCadRotation(cad.rotation) + : undefined + + if (model_gltf_url || model_glb_url) { + externalGLTFs.push({ + url: model_gltf_url ?? model_glb_url!, + format: model_gltf_url ? "gltf" : "glb", + name: cad.pcb_component_id, + translation: center, + rotation, + }) + continue + } + const box: Box3D = { center, size, @@ -127,8 +149,8 @@ export async function convertCircuitJsonTo3D( } // Add rotation if specified - if (cad.rotation) { - box.rotation = convertRotationFromCadRotation(cad.rotation) + if (rotation) { + box.rotation = rotation } // Try to load the mesh with default coordinate transform if none specified @@ -218,5 +240,6 @@ export async function convertCircuitJsonTo3D( boxes, camera, lights, + externalGLTFs: externalGLTFs.length > 0 ? externalGLTFs : undefined, } } diff --git a/lib/index.ts b/lib/index.ts index 788c76f..1993f87 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -2,6 +2,7 @@ import type { CircuitJson } from "circuit-json" import type { ConversionOptions } from "./types" import { convertCircuitJsonTo3D } from "./converters/circuit-to-3d" import { convertSceneToGLTF } from "./converters/scene-to-gltf" +import { combineBaseGLTFWithExternalModels } from "./utils/combine-gltf" export async function convertCircuitJsonToGltf( circuitJson: CircuitJson, @@ -32,6 +33,14 @@ export async function convertCircuitJsonToGltf( const result = await convertSceneToGLTF(scene3D, gltfOptions) + if (scene3D.externalGLTFs?.length) { + return combineBaseGLTFWithExternalModels( + result, + format, + scene3D.externalGLTFs, + ) + } + return result } @@ -54,6 +63,7 @@ export type { CircuitTo3DOptions, BoardRenderOptions, CoordinateTransformConfig, + ExternalGLTFInstance, } from "./types" // Re-export loaders diff --git a/lib/types.ts b/lib/types.ts index 807c8b4..3c9f99e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -95,10 +95,20 @@ export interface Box3D { labelColor?: Color } +export interface ExternalGLTFInstance { + url: string + format: "gltf" | "glb" + name?: string + translation: Point3 + rotation?: Point3 + scale?: Point3 +} + export interface Scene3D { boxes: Box3D[] camera?: Camera3D lights?: Light3D[] + externalGLTFs?: ExternalGLTFInstance[] } export interface Camera3D { diff --git a/lib/utils/combine-gltf.ts b/lib/utils/combine-gltf.ts new file mode 100644 index 0000000..bff66c6 --- /dev/null +++ b/lib/utils/combine-gltf.ts @@ -0,0 +1,343 @@ +import { + NodeIO, + type Document, + type JSONDocument, + type Node, + type Scene, +} from "@gltf-transform/core" +import { mergeDocuments, unpartition } from "@gltf-transform/functions" +import type { ExternalGLTFInstance, Point3 } from "../types" + +const DEFAULT_ROTATION: [number, number, number, number] = [0, 0, 0, 1] + +export async function combineBaseGLTFWithExternalModels( + baseResult: ArrayBuffer | object, + format: "gltf" | "glb", + externals: ExternalGLTFInstance[], +): Promise { + if (externals.length === 0) { + return baseResult + } + + const io = new NodeIO() + const document = await loadBaseDocument(io, baseResult, format) + + let baseScene = document.getRoot().getDefaultScene() + if (!baseScene) { + baseScene = document.createScene("Scene") + document.getRoot().setDefaultScene(baseScene) + } + + for (const [index, external] of externals.entries()) { + const externalDocument = await loadExternalDocument(io, external) + const map = mergeDocuments(document, externalDocument) + + const externalScene = + externalDocument.getRoot().getDefaultScene() ?? + externalDocument.getRoot().listScenes()[0] + if (!externalScene) { + continue + } + + const wrapperName = external.name ?? `ExternalGLTF_${index + 1}` + const wrapperNode = document + .createNode(wrapperName) + .setTranslation(pointToArray(external.translation)) + + if (external.rotation) { + wrapperNode.setRotation(eulerToQuaternion(external.rotation)) + } + + if (external.scale) { + wrapperNode.setScale(pointToArray(external.scale)) + } + + baseScene.addChild(wrapperNode) + + const mappedScene = map.get(externalScene) as Scene | undefined + + for (const child of externalScene.listChildren()) { + const mappedNode = map.get(child) as Node | undefined + if (!mappedNode) continue + wrapperNode.addChild(mappedNode) + } + + if (mappedScene) { + mappedScene.dispose() + } + } + + const root = document.getRoot() + if (!root.getDefaultScene()) { + const scene = document.createScene("Scene") + root.setDefaultScene(scene) + } + + if (format === "glb") { + await document.transform(unpartition()) + const binary = await io.writeBinary(document) + return binary.buffer.slice( + binary.byteOffset, + binary.byteOffset + binary.byteLength, + ) + } + + const jsonDoc = await io.writeJSON(document) + return embedResourcesAsDataURIs(jsonDoc) +} + +async function loadBaseDocument( + io: NodeIO, + baseResult: ArrayBuffer | object, + format: "gltf" | "glb", +): Promise { + if (format === "glb") { + if (!(baseResult instanceof ArrayBuffer)) { + throw new Error("Expected ArrayBuffer for GLB base result") + } + return io.readBinary(new Uint8Array(baseResult)) + } + + return io.readJSON({ json: baseResult as any, resources: {} }) +} + +async function loadExternalDocument( + io: NodeIO, + external: ExternalGLTFInstance, +): Promise { + if (external.format === "glb") { + const data = await fetchUriAsUint8Array(external.url) + return io.readBinary(data) + } + + if (isDataUri(external.url)) { + const jsonText = decodeText(decodeDataUri(external.url)) + const json = JSON.parse(jsonText) + return io.readJSON({ json, resources: {} }) + } + + const response = await fetch(external.url) + if (!response.ok) { + throw new Error(`Failed to fetch GLTF model from ${external.url}`) + } + const jsonText = await response.text() + const json = JSON.parse(jsonText) + const resources = await loadExternalResources(json, external.url) + return io.readJSON({ json, resources }) +} + +async function loadExternalResources( + json: any, + baseUrl: string, +): Promise> { + const resources: Record = {} + + const buffers = Array.isArray(json.buffers) ? json.buffers : [] + const images = Array.isArray(json.images) ? json.images : [] + + const base = new URL(baseUrl) + + await Promise.all( + buffers.map(async (buffer: { uri?: string }) => { + if (!buffer.uri || isDataUri(buffer.uri)) return + const resolved = new URL(buffer.uri, base).toString() + resources[buffer.uri] = await fetchUriAsUint8Array(resolved) + }), + ) + + await Promise.all( + images.map(async (image: { uri?: string }) => { + if (!image.uri || isDataUri(image.uri)) return + const resolved = new URL(image.uri, base).toString() + resources[image.uri] = await fetchUriAsUint8Array(resolved) + }), + ) + + return resources +} + +function embedResourcesAsDataURIs(jsonDoc: JSONDocument): object { + const { json, resources } = jsonDoc + + if (Array.isArray(json.buffers)) { + for (const buffer of json.buffers) { + if (!buffer.uri) continue + const data = resources[buffer.uri] + if (!data) continue + buffer.uri = buildDataUri("application/octet-stream", data) + } + } + + if (Array.isArray(json.images)) { + for (const image of json.images) { + if (!image.uri) continue + const data = resources[image.uri] + if (!data) continue + image.uri = buildDataUri(guessImageMimeType(image.uri), data) + } + } + + return json +} + +function pointToArray(point: Point3): [number, number, number] { + return [point.x ?? 0, point.y ?? 0, point.z ?? 0] +} + +function eulerToQuaternion(rotation: Point3): [number, number, number, number] { + const rx = rotation.x ?? 0 + const ry = rotation.y ?? 0 + const rz = rotation.z ?? 0 + + const cx = Math.cos(rx / 2) + const sx = Math.sin(rx / 2) + const cy = Math.cos(ry / 2) + const sy = Math.sin(ry / 2) + const cz = Math.cos(rz / 2) + const sz = Math.sin(rz / 2) + + const qx: [number, number, number, number] = [sx, 0, 0, cx] + const qy: [number, number, number, number] = [0, sy, 0, cy] + const qz: [number, number, number, number] = [0, 0, sz, cz] + + const qyx = multiplyQuaternions(qx, qy) + const combined = multiplyQuaternions(qz, qyx) + return normalizeQuaternion(combined) +} + +function multiplyQuaternions( + a: [number, number, number, number], + b: [number, number, number, number], +): [number, number, number, number] { + const [ax, ay, az, aw] = a + const [bx, by, bz, bw] = b + + return [ + aw * bx + ax * bw + ay * bz - az * by, + aw * by - ax * bz + ay * bw + az * bx, + aw * bz + ax * by - ay * bx + az * bw, + aw * bw - ax * bx - ay * by - az * bz, + ] +} + +function normalizeQuaternion( + quaternion: [number, number, number, number], +): [number, number, number, number] { + const [x, y, z, w] = quaternion + const length = Math.sqrt(x * x + y * y + z * z + w * w) + if (length === 0) { + return DEFAULT_ROTATION + } + return [x / length, y / length, z / length, w / length] +} + +function guessImageMimeType(uri: string): string { + const extension = uri.split(".").pop()?.toLowerCase() + switch (extension) { + case "jpg": + case "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "webp": + return "image/webp" + case "ktx": + case "ktx2": + return "image/ktx2" + default: + return "application/octet-stream" + } +} + +async function fetchUriAsUint8Array(uri: string): Promise { + if (isDataUri(uri)) { + return decodeDataUri(uri) + } + + const response = await fetch(uri) + if (!response.ok) { + throw new Error(`Failed to fetch resource ${uri}`) + } + const arrayBuffer = await response.arrayBuffer() + return new Uint8Array(arrayBuffer) +} + +function isDataUri(uri: string): boolean { + return /^data:/i.test(uri) +} + +function decodeDataUri(uri: string): Uint8Array { + const commaIndex = uri.indexOf(",") + if (commaIndex === -1) { + throw new Error("Invalid data URI") + } + const metadata = uri.slice(0, commaIndex) + const data = uri.slice(commaIndex + 1) + + if (metadata.includes(";base64")) { + return fromBase64(data) + } + + const decoded = decodeURIComponent(data) + return new TextEncoder().encode(decoded) +} + +function decodeText(data: Uint8Array): string { + return new TextDecoder().decode(data) +} + +function buildDataUri(mimeType: string, data: Uint8Array): string { + return `data:${mimeType};base64,${toBase64(data)}` +} + +function toBase64(data: Uint8Array): string { + if (typeof globalThis.btoa === "function") { + let binary = "" + for (const byte of data) { + binary += String.fromCharCode(byte) + } + return globalThis.btoa(binary) + } + + const BufferCtor = (globalThis as any).Buffer as + | { + from( + data: Uint8Array, + encoding?: string, + ): { toString(encoding: string): string } + } + | undefined + + if (BufferCtor) { + return BufferCtor.from(data).toString("base64") + } + + throw new Error("No base64 encoder available in this environment") +} + +function fromBase64(data: string): Uint8Array { + if (typeof globalThis.atob === "function") { + const binary = globalThis.atob(data) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + return bytes + } + + const BufferCtor = (globalThis as any).Buffer as + | { + from( + data: string, + encoding: string, + ): { buffer: ArrayBuffer; byteOffset: number; byteLength: number } + } + | undefined + + if (BufferCtor) { + const buffer = BufferCtor.from(data, "base64") + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + } + + throw new Error("No base64 decoder available in this environment") +} diff --git a/package.json b/package.json index 654487c..a5ae067 100644 --- a/package.json +++ b/package.json @@ -47,5 +47,8 @@ "optional": true } }, - "dependencies": {} + "dependencies": { + "@gltf-transform/core": "^4.2.1", + "@gltf-transform/functions": "^4.2.1" + } } diff --git a/tests/fixtures/simple-triangle.gltf b/tests/fixtures/simple-triangle.gltf new file mode 100644 index 0000000..5f36847 --- /dev/null +++ b/tests/fixtures/simple-triangle.gltf @@ -0,0 +1,48 @@ +{ + "asset": { "version": "2.0" }, + "buffers": [ + { + "byteLength": 36, + "uri": "data:application/octet-stream;base64,AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAA" + } + ], + "bufferViews": [ + { + "buffer": 0, + "byteOffset": 0, + "byteLength": 36, + "target": 34962 + } + ], + "accessors": [ + { + "bufferView": 0, + "componentType": 5126, + "count": 3, + "type": "VEC3", + "max": [1, 1, 0], + "min": [0, 0, 0] + } + ], + "meshes": [ + { + "primitives": [ + { + "attributes": { "POSITION": 0 }, + "mode": 4 + } + ] + } + ], + "nodes": [ + { + "mesh": 0 + } + ], + "scenes": [ + { + "nodes": [0] + } + ], + "scene": 0 +} diff --git a/tests/integration/external-gltf.test.ts b/tests/integration/external-gltf.test.ts new file mode 100644 index 0000000..618787e --- /dev/null +++ b/tests/integration/external-gltf.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test" +import { NodeIO } from "@gltf-transform/core" +import { convertCircuitJsonToGltf } from "../../lib" + +type CircuitElement = Record + +const SIMPLE_TRIANGLE_GTLF_BASE64 = + "ewogICJhc3NldCI6IHsgInZlcnNpb24iOiAiMi4wIiB9LAogICJidWZmZXJzIjogWwogICAgewogICAgICAiYnl0ZUxlbmd0aCI6IDM2LAogICAgICAidXJpIjogImRh" + + "dGE6YXBwbGljYXRpb24vb2N0ZXQtc3RyZWFtO2Jhc2U2NCxBQUFBQUFBQUFBQUFBQUFBQUFDQVB3QUFBQUFBQUFBQUFBQUFBQUFBZ0Q4QUFBQUEiCiAgICB9CiAgXSwK" + + "ICAiYnVmZmVyVmlld3MiOiBbCiAgICB7CiAgICAgICJidWZmZXIiOiAwLAogICAgICAiYnl0ZU9mZnNldCI6IDAsCiAgICAgICJieXRlTGVuZ3RoIjogMzYsCiAgICAg" + + "ICJ0YXJnZXQiOiAzNDk2MgogICAgfQogIF0sCiAgImFjY2Vzc29ycyI6IFsKICAgIHsKICAgICAgImJ1ZmZlclZpZXciOiAwLAogICAgICAiY29tcG9uZW50VHlwZSI6" + + "IDUxMjYsCiAgICAgICJjb3VudCI6IDMsCiAgICAgICJ0eXBlIjogIlZFQzMiLAogICAgICAibWF4IjogWzEsIDEsIDBdLAogICAgICAibWluIjogWzAsIDAsIDBdCiAg" + + "ICB9CiAgXSwKICAibWVzaGVzIjogWwogICAgewogICAgICAicHJpbWl0aXZlcyI6IFsKICAgICAgICB7CiAgICAgICAgICAiYXR0cmlidXRlcyI6IHsgIlBPU0lUSU9O" + + "IjogMCB9LAogICAgICAgICAgIm1vZGUiOiA0CiAgICAgICAgfQogICAgICBdCiAgICB9CiAgXSwKICAibm9kZXMiOiBbCiAgICB7CiAgICAgICJtZXNoIjogMAogICAg" + + "fQogIF0sCiAgInNjZW5lcyI6IFsKICAgIHsKICAgICAgIm5vZGVzIjogWzBdCiAgICB9CiAgXSwKICAic2NlbmUiOiAwCn0K" + +const SIMPLE_TRIANGLE_GTLF_DATA_URI = `data:application/json;base64,${SIMPLE_TRIANGLE_GTLF_BASE64}` + +function createCircuit(modelUrl: string): CircuitElement[] { + return [ + { + type: "pcb_board", + pcb_board_id: "board-gltf", + center: { x: 0, y: 0 }, + width: 40, + height: 20, + thickness: 1.6, + }, + { + type: "pcb_component", + pcb_component_id: "comp-gltf", + source_component_id: "src-gltf", + center: { x: 5, y: 3 }, + width: 5, + height: 5, + layer: "top", + }, + { + type: "source_component", + source_component_id: "src-gltf", + name: "GLTF", // used for fallback labels if needed + }, + { + type: "cad_component", + cad_component_id: "cad-gltf", + pcb_component_id: "comp-gltf", + source_component_id: "src-gltf", + size: { x: 4, y: 2, z: 4 }, + model_gltf_url: modelUrl, + }, + ] +} + +const EXPECTED_TRANSLATION = { x: 5, y: 1.8, z: 3 } + +function expectTranslation(node: any, precision = 5) { + expect(node.translation).toBeDefined() + expect(node.translation[0]).toBeCloseTo(EXPECTED_TRANSLATION.x, precision) + expect(node.translation[1]).toBeCloseTo(EXPECTED_TRANSLATION.y, precision) + expect(node.translation[2]).toBeCloseTo(EXPECTED_TRANSLATION.z, precision) +} + +test("combines external GLTF into JSON result", async () => { + const circuit = createCircuit(SIMPLE_TRIANGLE_GTLF_DATA_URI) + const result = await convertCircuitJsonToGltf(circuit as any, { + boardTextureResolution: 0, + }) + + expect(result).toBeDefined() + const gltf = result as any + + const wrapperNodeIndex = gltf.nodes.findIndex( + (node: any) => node.name === "comp-gltf", + ) + expect(wrapperNodeIndex).toBeGreaterThanOrEqual(0) + + const wrapperNode = gltf.nodes[wrapperNodeIndex] + expect(wrapperNode.children?.length ?? 0).toBeGreaterThan(0) + expectTranslation(wrapperNode) + + const defaultSceneIndex = gltf.scene ?? 0 + const defaultScene = gltf.scenes[defaultSceneIndex] + expect(defaultScene.nodes).toContain(wrapperNodeIndex) +}) + +test("combines external GLTF into GLB output", async () => { + const circuit = createCircuit(SIMPLE_TRIANGLE_GTLF_DATA_URI) + const result = await convertCircuitJsonToGltf(circuit as any, { + format: "glb", + boardTextureResolution: 0, + }) + + expect(result).toBeInstanceOf(ArrayBuffer) + const arrayBuffer = result as ArrayBuffer + + const io = new NodeIO() + const document = await io.readBinary(new Uint8Array(arrayBuffer)) + const scene = document.getRoot().getDefaultScene() + expect(scene).toBeDefined() + + const wrapperNode = scene + ?.listChildren() + .find((node) => node.getName() === "comp-gltf") + expect(wrapperNode).toBeDefined() + + const translation = wrapperNode?.getTranslation() ?? [0, 0, 0] + expect(translation[0]).toBeCloseTo(EXPECTED_TRANSLATION.x, 5) + expect(translation[1]).toBeCloseTo(EXPECTED_TRANSLATION.y, 5) + expect(translation[2]).toBeCloseTo(EXPECTED_TRANSLATION.z, 5) +})