first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# cadjs
`cadjs` is the shared JavaScript runtime for CAD Viewer, documentation previews,
and generated snapshot browser assets. It owns reusable parsing, scene-building,
explicit-CAD rendering, STEP topology, selector, mesh, robot-description, and
artifact helpers without depending on React or application chrome.
Implicit CAD model loading, shader rendering, snapshots, SDF sampling, and
mesh exports live in the sibling `implicitjs` package. Consumers that need
implicit CAD should depend on `implicitjs` directly alongside `cadjs`.
## Install
In this workbench, consumers link the package directly:
```json
{
"dependencies": {
"cadjs": "file:../packages/cadjs"
}
}
```
The package exports source files so local consumers can alias or install it and
pick up edits without copying generated bundles.
## Layout
- `src/index.js`: public package entrypoint.
- `src/common/`: browser-safe render pipeline used by interactive viewer,
docs previews, and snapshot browser runtime.
- `src/lib/`: lower-level first-party helpers for assets, formats, mesh
decoding, selector runtimes, STEP sidecars, and URDF/SRDF/SDF.
- `src/lib/viewer/`: shared non-React 3D viewer runtime helpers such as picking,
clipping, drawing geometry, reference geometry, stage theme, scene scale, and
visual state.
- `scripts/run-tests.mjs`: package test discovery and Node test runner wrapper.
- `docs/`: reference docs for package-owned APIs.
Tests live beside the modules they cover as `*.test.js` or `*.test.mjs`.
## Boundaries
Keep this package UI-framework agnostic. Do not add React components, Tailwind
helpers, browser workbench state, or CAD Viewer chrome utilities here. Those
belong in consumer apps such as `viewer/`.
Prefer moving logic into `cadjs` when it is reusable across:
- interactive CAD Viewer rendering,
- docs or marketing previews,
- generated snapshot browser runtime,
- testable CAD parsing or sidecar preparation.
Prefer moving implicit model, shader, snapshot, SDF, or mesh-export behavior
into `implicitjs` instead.
Keep app-specific workflows in the app. A useful rule of thumb: `cadjs` should
understand CAD data and rendering state, while `viewer/` should understand user
interface and product workflow.
## Commands
From this package directory:
```bash
npm test
```
From the workbench repository root:
```bash
npm --prefix packages/cadjs test
```
The test command discovers all `*.test.js` and `*.test.mjs` files under
`src/`. To run one file:
```bash
node scripts/run-tests.mjs src/lib/themeSettings.test.js
```
## Reference
- [Render pipeline](./docs/render-pipeline.md): `loadSource`, `buildModel`,
`renderModel`, and `captureModel` stages shared by viewer, docs, and
snapshots.
@@ -0,0 +1,362 @@
# Render Pipeline
`cadjs` exposes a staged render pipeline for shared viewer, docs, and generated
snapshot browser-runtime work:
```js
const source = await loadSource(input, sourceOptions);
const model = buildModel(THREE, source, modelOptions);
const viewport = renderModel(THREE, model, viewportOptions);
const result = await captureModel(viewport, captureOptions);
```
The stages keep ownership narrow:
- `loadSource` owns source and sidecar loading plus file-kind validation.
- `buildModel` owns the CAD object graph, records, selection, clipping,
materials, topology/display edges, and STEP parameter effects.
- `renderModel` owns renderer, scene, camera, lighting, background, floor,
framing, resizing, and render loop concerns.
- `captureModel` owns deterministic snapshot outputs without filesystem writes.
The CAD skill's Python snapshot CLI remains responsible for job parsing, path
resolution, Playwright routing, and writing returned outputs to disk.
## Modules
### `common/source.js`
```js
import {
loadSource,
stepParameterFrameRuntime
} from "cadjs/common/source.js";
```
`loadSource(input, options)` returns a normalized render source:
```js
{
kind,
meshData,
selectorRuntime,
displayEdgeRuntime,
stepParameterSource,
resolved,
url,
glbUrl,
cadPath
}
```
Accepted input fields:
- `kind`: `step`, `stp`, `glb`, `stl`, `3mf`, or inferred from a URL.
- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.
- `url`: source URL for non-STEP GLB loading.
- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.
- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.
- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller
already owns sidecar loading.
- `stepParameters`: raw STEP render values or animation envelope.
- `stepParameterUrl` or `resolved.stepParameterUrl`: `.step.js`/`.stp.js`
parameter sidecar URL.
STEP-only options are rejected for non-STEP sources. The old shared `params`
field is rejected; use `stepParameters`.
Use `stepParameterFrameRuntime(stepParameterSource, frameIndex)` to turn the
loaded STEP parameter source into the runtime object accepted by `buildModel`
for a still or animation frame.
### `common/cadScene.js`
```js
import {
buildModel,
fitCameraToModel
} from "cadjs/common/cadScene.js";
```
`buildModel(THREE, source, settings)` returns a model API:
```js
{
source,
meshData,
root,
modelGroup,
edgesGroup,
displayRecords,
records,
bounds,
radius,
runtime,
update(nextSettings),
dispose()
}
```
`source` can be a `loadSource()` result or raw mesh data. The model owns the
Three.js object graph and its mutable state.
Common settings:
- `theme`: normalized or raw theme settings.
- `displayMode`: `solid`, `rendered`, `transparent`, `hidden_edges`,
`hidden_lines_removed`, `unshaded`, or `wireframe`.
- `scale`/`sceneScale`: CAD or robot scene scale.
- `selection`: internal selection/filtering state. `focus`, `refs`, and `hide`
filter rendered parts before records are built. Viewer-only fields such as
`selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.
- `clip`: normalized clip-plane settings.
- `stepParameters`: STEP parameter runtime object, usually from
`stepParameterFrameRuntime()`.
- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.
- `renderPartsIndividually`: build per-part records instead of a whole mesh.
- `edgeRendering`: declarative edge rendering configuration.
Declarative screen-space edge rendering:
```js
buildModel(THREE, source, {
edgeRendering: {
mode: "screen-space",
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
wireframeEdgeColor: "#111827"
}
});
```
The model keeps screen-space line material bookkeeping internal through
`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.
Callers should not provide callbacks that create edge objects.
`model.update(nextSettings)` merges mutable settings, rebuilds geometry only
when needed, reapplies material/selection/clip/STEP parameter state, and returns
the same model API. `model.dispose()` releases model-owned scene objects and
STEP parameter cleanup hooks.
`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic
camera framing helper used by interactive rendering.
### `common/renderModel.js`
Use this module for interactive browser canvases, including the docs hero.
```js
import { renderModel } from "cadjs/common/renderModel.js";
```
`renderModel(THREE, model, options)` returns an interactive viewport API:
```js
{
THREE,
model,
renderer,
scene,
camera,
ready,
resize(),
render(),
start(),
stop(),
capturePng(),
dispose()
}
```
Common options:
- `canvas`: existing canvas for the renderer.
- `hostElement`/`container`: element used for responsive sizing.
- `renderer`: caller-owned renderer. If omitted, one is created.
- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are
created.
- `theme`/`themeSettings`: background and lighting settings.
- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,
`logarithmicDepthBuffer`, `shadows`: renderer controls.
- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.
- `pixelRatio`, `maxPixelRatio`: output density controls.
- `autoResize`: set `false` to disable `ResizeObserver`.
- `autoStart`: set `true` to start an animation loop.
- `autoRender`: set `false` to prevent the initial render.
- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.
- `disposeModel`: set `false` when the caller will dispose the model.
`dispose()` stops animation, disconnects resize observation, removes the model
root from the scene, and disposes the created renderer/model unless ownership
was explicitly retained by options.
### `common/renderMeshScene.js`
Use this module for deterministic headless snapshot rendering.
```js
import {
renderJobContext,
modelOptionsForRenderJob,
renderModel,
captureModel,
renderMeshJob
} from "cadjs/common/renderMeshScene.js";
```
`renderJobContext(meshData, job)` normalizes snapshot-owned render policy:
appearance, display, scene scale, outputs, STEP topology edge visibility, and
warnings.
`modelOptionsForRenderJob(context, job)` converts that policy into
`buildModel()` settings.
Snapshot `renderModel(THREE, model, { job, context })` returns a headless
viewport:
```js
{
THREE,
model,
scene,
renderer,
orthographicCamera,
perspectiveCamera,
context,
sceneBuildStarted,
ready,
dispose()
}
```
This `renderModel` is intentionally separate from `common/renderModel.js`.
It uses snapshot sizing, theme environment, floor, orthographic/perspective
camera presets, and deterministic renderer settings.
`captureModel(viewport, { job })` returns data only:
- `mode: "view"`: PNG data URLs in `outputs`.
- `mode: "orbit"`: the caller composes frames into GIF output.
- `mode: "section"`: PNG data URLs or SVG text in `outputs`.
- `mode: "list"`: part list and bounds.
It does not write files. The CAD skill snapshot CLI writes the returned data to
disk. Source checkouts use `packages/cadjs`; generated snapshot browser assets
bundle this entrypoint into `skills/cad/scripts/snapshot/runtime`.
`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,
builds a model, renders/captures it, and disposes owned resources.
## STEP Parameters
Shared render APIs use the name `stepParameters`.
Raw render values can be direct parameter values:
```json
{
"drive": 180,
"ringVisible": false
}
```
Or an animation envelope:
```json
{
"values": {
"ringVisible": true
},
"animate": {
"drive": { "from": 0, "to": 1260 }
},
"durationSeconds": 6,
"fps": 18,
"loop": true
}
```
`common/stepParameters.js` validates these values against the loaded STEP
parameter sidecar schema, normalizes defaults, and computes per-frame values.
`loadSource()` uses it to populate `source.stepParameterSource`; callers can
then use `stepParameterFrameRuntime()` when passing parameters into
`buildModel()`.
## Examples
Interactive viewer/docs usage:
```js
import * as THREE from "three";
import { loadSource, stepParameterFrameRuntime } from "cadjs/common/source.js";
import { buildModel } from "cadjs/common/cadScene.js";
import { renderModel } from "cadjs/common/renderModel.js";
const source = await loadSource({
kind: "step",
glbUrl: "/models/.part.step.glb",
stepParameterUrl: "/models/.part.step.js",
cadPath: "models/part.step",
stepParameters: { drive: 180 }
});
const model = buildModel(THREE, source, {
theme,
displayMode: "solid",
stepParameters: stepParameterFrameRuntime(source.stepParameterSource, 0)
});
const viewport = renderModel(THREE, model, {
canvas,
hostElement: canvas.parentElement,
theme,
autoStart: true
});
```
Headless snapshot usage:
```js
import * as THREE from "three";
import { loadSource } from "cadjs/common/source.js";
import { buildModel } from "cadjs/common/cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "cadjs/common/renderMeshScene.js";
const source = await loadSource(job);
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
const viewport = renderModel(THREE, model, { job, context });
try {
const result = await captureModel(viewport, { job });
// Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.
} finally {
viewport.dispose();
}
```
Orbit GIF jobs default to 6 fps over 12 seconds for a calmer review spin
without increasing default render frame count.
Override with `orbit.fps` and `orbit.durationSeconds` when a shorter or denser
render is needed.
## Ownership Rules
- Do not write files from shared render APIs. Return data to the owning CLI or
application layer.
- Do not expose object-construction callbacks for edges. Use declarative
`edgeRendering`.
- Keep STEP-only options explicitly STEP-named and reject them for non-STEP
sources.
- Dispose viewports and models that you create.
- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new
shared render code instead of loading assets or constructing render scenes
inline.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "cadjs",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cadjs",
"version": "0.3.9",
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
}
}
}
@@ -0,0 +1,23 @@
{
"name": "cadjs",
"version": "0.3.9",
"private": true,
"type": "module",
"description": "Reusable CAD render/viewer runtime for docs, CAD Viewer, and generated snapshot browser assets.",
"exports": {
".": "./src/index.js",
"./common/*": "./src/common/*",
"./lib/*": "./src/lib/*"
},
"scripts": {
"test": "node scripts/run-tests.mjs"
},
"sideEffects": [
"./src/common/headlessRenderEntry.js",
"./src/common/render.html"
],
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(packageRoot, "..", "..");
const sourceRoot = path.join(packageRoot, "src");
function collectTests(dir, tests = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectTests(entryPath, tests);
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
tests.push(entryPath);
}
}
return tests;
}
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
const tests = (requestedTests.length ? requestedTests : collectTests(sourceRoot)).sort();
if (!tests.length) {
console.error("No cadjs tests found.");
process.exit(1);
}
const result = spawnSync(process.execPath, [
"--test",
"--experimental-default-type=module",
...tests,
], {
cwd: packageRoot,
env: {
...process.env,
...(fs.existsSync(path.join(repoRoot, ".venv", "bin", "python"))
? { CAD_PYTHON: path.join(repoRoot, ".venv", "bin", "python") }
: {}),
...(fs.existsSync(path.join(repoRoot, "packages", "cadpy", "src"))
? { CAD_PYTHONPATH: path.join(repoRoot, "packages", "cadpy", "src") }
: {}),
},
stdio: "inherit",
});
process.exit(result.status ?? 1);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,730 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import {
applyPartVisualState,
buildModel,
CAD_DISPLAY_MODE,
normalizeDisplayMode
} from "./cadScene.js";
import {
DEFAULT_DISPLAY_EDGE_SETTINGS
} from "./displaySettings.js";
import {
cloneThemeSettings
} from "./themeSettings.js";
function sampleMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [3, 1, 0]
},
parts: [
{
id: "left",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "right",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
}
]
};
}
function nestedAssemblyMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0,
10, 0, 0,
11, 0, 0,
10, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [11, 1, 0]
},
parts: [
{
id: "o1.2.1",
occurrenceId: "o1.2.1",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "o1.2.2",
occurrenceId: "o1.2.2",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
},
{
id: "o1.3",
occurrenceId: "o1.3",
vertexOffset: 6,
vertexCount: 3,
triangleOffset: 2,
triangleCount: 1,
bounds: { min: [10, 0, 0], max: [11, 1, 0] }
}
]
};
}
function createDisplayRecord(partId, {
baseOpacity = 1
} = {}) {
const material = new THREE.MeshStandardMaterial({
color: "#aaaaaa",
emissive: "#000000",
transparent: false,
opacity: 1
});
const edgeMaterial = new THREE.LineBasicMaterial({
color: "#222222",
transparent: true,
opacity: 1
});
return {
partId,
mesh: { visible: true, renderOrder: 2 },
edges: { visible: true, renderOrder: 3 },
material,
edgeMaterial,
baseOpacity,
baseColor: new THREE.Color("#aaaaaa"),
baseEmissiveColor: new THREE.Color("#000000"),
baseEmissiveIntensity: 0
};
}
function squareMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
1, 1, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: []
};
}
function edgeSegmentCount(record) {
return Math.floor((record?.edges?.geometry?.getAttribute("position")?.count || 0) / 2);
}
test("applyPartVisualState keeps dimmed context from depth-occluding highlights", () => {
const dimmed = createDisplayRecord("dimmed");
const selected = createDisplayRecord("selected");
applyPartVisualState(THREE, [dimmed, selected], {
baseTheme: {
edge: "#111111",
edgeOpacity: 0.5
},
edgeSettings: {
opacity: 0.5
},
hiddenPartIds: [],
hoveredPartId: "",
focusedPartId: ["selected"],
selectedPartIds: ["selected"],
showEdges: true
});
assert.equal(dimmed.material.transparent, true);
assert.equal(dimmed.material.depthWrite, false);
assert.equal(dimmed.mesh.renderOrder, 2);
assert.equal(dimmed.edges.renderOrder, 3);
assert.equal(selected.material.transparent, true);
assert.equal(selected.material.depthWrite, true);
assert.equal(selected.mesh.renderOrder, 23);
assert.equal(selected.edges.renderOrder, 26);
applyPartVisualState(THREE, [selected], {
baseTheme: {},
edgeSettings: {},
hiddenPartIds: [],
hoveredPartId: "",
focusedPartId: [],
selectedPartIds: [],
showEdges: true
});
assert.equal(selected.material.transparent, false);
assert.equal(selected.material.depthWrite, true);
assert.equal(selected.mesh.renderOrder, 2);
assert.equal(selected.edges.renderOrder, 3);
});
test("buildModel renders solid part records and updates theme without rebuilding geometry", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme,
renderPartsIndividually: true
});
const firstMesh = scene.displayRecords[0].mesh;
const firstGeometry = firstMesh.geometry;
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].partId, "left");
assert.equal(scene.displayRecords[0].edges.visible, true);
assert.equal(scene.displayRecords[0].mesh.castShadow, true);
assert.equal(scene.displayRecords[0].mesh.receiveShadow, false);
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
defaultColor: "#ff0000",
fillColors: ["#ff0000"]
}
}
});
assert.equal(scene.displayRecords[0].mesh, firstMesh);
assert.equal(scene.displayRecords[0].mesh.geometry, firstGeometry);
assert.equal(scene.displayRecords[0].material.color.getHexString(), "ff0000");
scene.dispose();
});
test("buildModel keeps source-mesh color buffers immutable across material refreshes", () => {
const sourceColors = new Float32Array([
0.2, 0.4, 0.6,
0.8, 0.45, 0.2,
0.55, 0.3, 0.75
]);
const originalColors = Array.from(sourceColors);
const sourceMesh = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
colors: sourceColors
};
const meshData = {
vertices: new Float32Array(0),
indices: new Uint32Array(0),
normals: new Float32Array(0),
bounds: { min: [0, 0, 0], max: [1, 1, 0] },
parts: [
{
id: "camera-source",
sourceMeshKey: "camera-source",
sourceMesh,
hasSourceColors: true,
vertexCount: 3,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
}
]
};
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, meshData, {
theme,
renderPartsIndividually: true
});
const record = scene.displayRecords[0];
const colorAttribute = record.geometry.getAttribute("color");
assert.notEqual(record.rawColors, sourceColors);
assert.notEqual(colorAttribute.array, sourceColors);
assert.deepEqual(Array.from(sourceColors), originalColors);
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
brightness: 0.72,
saturation: 1.8
}
}
});
assert.deepEqual(Array.from(sourceColors), originalColors);
assert.deepEqual(Array.from(record.rawColors), originalColors);
scene.dispose();
});
test("buildModel selection can focus and hide subassembly occurrence descendants", () => {
const focused = buildModel(THREE, nestedAssemblyMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
focus: ["#o1.2"]
}
});
assert.deepEqual(focused.displayRecords.map((record) => record.partId), ["o1.2.1", "o1.2.2"]);
assert.deepEqual(focused.bounds, {
min: [0, 0, 0],
max: [3, 1, 0]
});
focused.dispose();
const hidden = buildModel(THREE, nestedAssemblyMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
hide: ["o1.2"]
}
});
assert.deepEqual(hidden.displayRecords.map((record) => record.partId), ["o1.3"]);
assert.deepEqual(hidden.bounds, {
min: [10, 0, 0],
max: [11, 1, 0]
});
hidden.dispose();
});
test("buildModel renders STEP surface-owned edges from mesh attributes without line objects", () => {
const meshData = sampleMeshData();
meshData.surfaceEdgeBarycentric = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
meshData.surfaceEdgeClass = new Uint8Array([
1, 1, 0,
1, 1, 0,
1, 1, 0,
2, 3, 0,
2, 3, 0,
2, 3, 0
]);
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
renderPartsIndividually: true
});
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.edgesGroup.children.length, 0);
assert.equal(scene.displayRecords[0].edges, null);
assert.equal(scene.displayRecords[0].material.userData.cadSurfaceEdges, true);
assert.equal(scene.displayRecords[0].material.polygonOffset, false);
assert.equal(scene.displayRecords[0].geometry.getAttribute("_cad_edge_barycentric").count, 3);
assert.equal(scene.displayRecords[0].geometry.getAttribute("_cad_edge_class").count, 3);
scene.dispose();
});
test("buildModel rebuilds surface edge shader materials when edge theme settings change", () => {
const meshData = sampleMeshData();
meshData.surfaceEdgeBarycentric = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
meshData.surfaceEdgeClass = new Uint8Array([
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0
]);
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, meshData, {
theme,
displayMode: CAD_DISPLAY_MODE.SOLID,
renderPartsIndividually: true
});
const originalMaterial = scene.displayRecords[0].material;
scene.update({
displayMode: CAD_DISPLAY_MODE.SOLID,
theme: {
...theme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
color: "#0055ff",
classes: {
...DEFAULT_DISPLAY_EDGE_SETTINGS.classes,
tangent: {
...DEFAULT_DISPLAY_EDGE_SETTINGS.classes.tangent,
thickness: 2.5
}
}
}
}
});
assert.notEqual(scene.displayRecords[0].material, originalMaterial);
assert.equal(scene.displayRecords[0].material.userData.cadSurfaceEdges, true);
assert.equal(scene.edgesGroup.children.length, 0);
scene.dispose();
});
test("buildModel reuses cached geometry for posed wrappers with the same geometry source", () => {
const geometrySource = sampleMeshData();
const posedMeshData = {
...geometrySource,
geometrySource,
parts: geometrySource.parts.map((part) => ({
...part,
transform: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]
}))
};
const movedMeshData = {
...geometrySource,
geometrySource,
parts: geometrySource.parts.map((part, index) => ({
...part,
bounds: {
min: [part.bounds.min[0] + index, part.bounds.min[1], part.bounds.min[2]],
max: [part.bounds.max[0] + index, part.bounds.max[1], part.bounds.max[2]]
},
transform: [
1, 0, 0, index,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]
}))
};
const scene = buildModel(THREE, posedMeshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
const firstGeometry = scene.displayRecords[0].mesh.geometry;
const movedScene = buildModel(THREE, movedMeshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
assert.equal(movedScene.displayRecords[0].mesh.geometry, firstGeometry);
scene.dispose();
movedScene.dispose();
});
test("buildModel wireframe mode keeps a translucent surface and wire edges", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.WIREFRAME,
renderPartsIndividually: true
});
assert.equal(normalizeDisplayMode("wireframe"), CAD_DISPLAY_MODE.WIREFRAME);
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].material.type, "MeshBasicMaterial");
assert.equal(scene.displayRecords[0].material.opacity, 0.035);
assert.equal(scene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
scene.dispose();
});
test("buildModel display modes control edges, transparency, and flat surfaces", () => {
const theme = cloneThemeSettings("workbench");
const renderedScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.RENDERED,
renderPartsIndividually: true
});
assert.equal(renderedScene.displayRecords[0].edges, null);
assert.equal(renderedScene.displayRecords[0].material.opacity, 1);
const transparentScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.TRANSPARENT,
renderPartsIndividually: true
});
assert.equal(transparentScene.displayRecords[0].material.opacity, 0.22);
assert.equal(transparentScene.displayRecords[0].material.transparent, true);
assert.equal(transparentScene.displayRecords[0].material.depthWrite, false);
assert.equal(transparentScene.displayRecords[0].edgeMaterial.depthTest, false);
const hiddenEdgeScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.HIDDEN_EDGES,
renderPartsIndividually: true
});
assert.equal(hiddenEdgeScene.displayRecords[0].material.opacity, 1);
assert.equal(hiddenEdgeScene.displayRecords[0].edgeMaterial.depthTest, false);
const unshadedScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.UNSHADED,
renderPartsIndividually: true
});
assert.equal(unshadedScene.displayRecords[0].material.type, "MeshBasicMaterial");
assert.equal(unshadedScene.displayRecords[0].edges, null);
renderedScene.dispose();
transparentScene.dispose();
hiddenEdgeScene.dispose();
unshadedScene.dispose();
});
test("buildModel applies source part opacity from GLB material metadata", () => {
const meshData = sampleMeshData();
meshData.parts = meshData.parts.map((part, index) => index === 0
? { ...part, color: "#ff0000", opacity: 0.2, hasSourceColors: true }
: part
);
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
const left = scene.displayRecords.find((record) => record.partId === "left");
assert.equal(left.baseOpacity, 0.2);
assert.equal(left.material.opacity, 0.2);
assert.equal(left.material.transparent, true);
assert.equal(left.material.depthWrite, false);
scene.dispose();
});
test("buildModel uses part records when only source opacity differs", () => {
const meshData = sampleMeshData();
meshData.sourceColor = "#ff0000";
meshData.has_source_colors = true;
meshData.parts = meshData.parts.map((part) => ({
...part,
color: "#ff0000",
opacity: 0.2,
hasSourceColors: true
}));
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: false
});
assert.equal(scene.displayRecords.length, 2);
for (const record of scene.displayRecords) {
assert.equal(record.baseOpacity, 0.2);
assert.equal(record.material.opacity, 0.2);
assert.equal(record.material.transparent, true);
assert.equal(record.material.depthWrite, false);
}
scene.dispose();
});
test("buildModel ignores deprecated mesh edge detail and keeps wireframe all-edge mode", () => {
const baseTheme = cloneThemeSettings("workbench");
const deprecatedDetailScene = buildModel(THREE, squareMeshData(), {
theme: {
...baseTheme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
topologyFilter: "all"
}
},
displayMode: CAD_DISPLAY_MODE.SOLID
});
const wireScene = buildModel(THREE, squareMeshData(), {
theme: baseTheme,
displayMode: CAD_DISPLAY_MODE.WIREFRAME
});
assert.equal(edgeSegmentCount(deprecatedDetailScene.displayRecords[0]), 4);
assert.notEqual(deprecatedDetailScene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
assert.equal(edgeSegmentCount(wireScene.displayRecords[0]), 5);
assert.equal(wireScene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
deprecatedDetailScene.dispose();
wireScene.dispose();
});
test("buildModel creates screen-space edges from declarative edge rendering options", () => {
const scene = buildModel(THREE, squareMeshData(), {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
edgeRendering: {
mode: "screen-space",
LineSegments2,
LineSegmentsGeometry,
LineMaterial
}
});
assert.equal(scene.runtime.edgeRendering.mode, "screen-space");
assert.equal(scene.displayRecords[0].edges instanceof LineSegments2, true);
assert.equal(scene.runtime.screenSpaceLineMaterials.size, 1);
scene.dispose();
assert.equal(scene.runtime.screenSpaceLineMaterials.size, 0);
});
test("buildModel can render silhouette contours without derived mesh edges", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme: {
...theme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false,
silhouette: true,
silhouetteScale: 0.004
}
},
displayMode: CAD_DISPLAY_MODE.RENDERED,
silhouette: true,
renderPartsIndividually: true
});
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].edges, null);
assert.equal(scene.displayRecords[0].silhouette?.isMesh, true);
scene.dispose();
});
test("buildModel applies selection, clipping, and STEP parameter effects", () => {
const scene = buildModel(THREE, sampleMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
selectedPartIds: ["left"],
hiddenPartIds: ["right"]
},
clip: {
enabled: true,
axis: "x",
offset: 0.5
},
stepParameters: {
definition: {
module: {
render(ctx) {
if (ctx.params.hideLeft) {
ctx.effects.visible("left", false);
}
}
},
manifest: {},
cadPath: "part.step"
},
parameterValues: {
hideLeft: true
}
}
});
const left = scene.displayRecords.find((record) => record.partId === "left");
const right = scene.displayRecords.find((record) => record.partId === "right");
assert.equal(left.mesh.visible, false);
assert.equal(right.mesh.visible, true);
assert.equal(right.material.transparent, true);
assert.equal(right.material.depthWrite, false);
assert.equal(right.material.opacity, 0.035);
assert.equal(left.material.clippingPlanes.length, 1);
assert.equal(scene.bounds.min[0], 2);
assert.equal(scene.bounds.max[0], 3);
scene.dispose();
});
test("buildModel can apply STEP parameter effects while deferring setup lifecycle", () => {
let setupCalls = 0;
const scene = buildModel(THREE, sampleMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
parameterSetup: false,
stepParameters: {
definition: {
module: {
setup() {
setupCalls += 1;
},
render(ctx) {
ctx.effects.transform("left", { translate: [5, 0, 0] });
}
},
manifest: {},
cadPath: "part.step"
}
}
});
const left = scene.displayRecords.find((record) => record.partId === "left");
assert.equal(setupCalls, 0);
assert.equal(left.mesh.matrix.elements[12], 5);
assert.equal(scene.bounds.min[0], 2);
assert.equal(scene.bounds.max[0], 6);
scene.dispose();
});
@@ -0,0 +1,337 @@
import * as THREE from "three";
export const WORLD_UP = Object.freeze([0, 0, 1]);
export const TOP_VIEW_UP = Object.freeze([0, 1, 0]);
export const RENDER_CAMERA_PRESETS = Object.freeze({
front: Object.freeze({ name: "front", direction: Object.freeze([0, -1, 0]), up: WORLD_UP }),
back: Object.freeze({ name: "back", direction: Object.freeze([0, 1, 0]), up: WORLD_UP }),
right: Object.freeze({ name: "right", direction: Object.freeze([1, 0, 0]), up: WORLD_UP }),
left: Object.freeze({ name: "left", direction: Object.freeze([-1, 0, 0]), up: WORLD_UP }),
top: Object.freeze({ name: "top", direction: Object.freeze([0, 0, 1]), up: TOP_VIEW_UP }),
bottom: Object.freeze({ name: "bottom", direction: Object.freeze([0, 0, -1]), up: TOP_VIEW_UP }),
iso: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
isometric: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
side: Object.freeze({ name: "side", direction: Object.freeze([1, 0, 0]), up: WORLD_UP })
});
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
const DEFAULT_CAMERA_PRESET = "iso";
const CAMERA_SPEC_KEYS = Object.freeze(["preset", "position", "target", "up", "zoom", "direction", "name"]);
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function normalizeVector(value, {
fieldName = "camera vector",
required = false,
nonZero = false,
strict = true
} = {}) {
if (value === undefined || value === null) {
if (required && strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
if (!Array.isArray(value) || value.length < 3) {
if (strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
const vector = [
Number(value[0]),
Number(value[1]),
Number(value[2])
];
if (!vector.every(Number.isFinite)) {
if (strict) {
throw new Error(`${fieldName} must contain only finite numbers`);
}
return null;
}
if (nonZero && vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 <= 1e-12) {
if (strict) {
throw new Error(`${fieldName} must not be the zero vector`);
}
return null;
}
return vector;
}
export function cloneCameraVector(vector) {
return normalizeVector(vector, { strict: false });
}
export function normalizeCameraZoom(value, fallback = 1, {
fieldName = "camera.zoom",
strict = false
} = {}) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue;
}
if (strict) {
throw new Error(`${fieldName} must be a positive finite number`);
}
return fallback;
}
function clonePreset(preset) {
return {
name: preset.name,
direction: [...preset.direction],
up: [...preset.up]
};
}
function cameraPresetByName(name, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const raw = String(name || DEFAULT_CAMERA_PRESET).trim().toLowerCase();
if (presets[raw]) {
return clonePreset(presets[raw]);
}
const parts = raw.split(":").map((entry) => Number(entry));
if (parts.length >= 2 && parts.every(Number.isFinite)) {
const azimuth = (parts[0] * Math.PI) / 180;
const elevation = (parts[1] * Math.PI) / 180;
const cosElevation = Math.cos(elevation);
return {
name: `${parts[0]}:${parts[1]}`,
direction: [
Math.sin(azimuth) * cosElevation,
-Math.cos(azimuth) * cosElevation,
Math.sin(elevation)
],
up: [...WORLD_UP]
};
}
if (strict) {
throw new Error(`Unknown camera preset: ${name}`);
}
return clonePreset(presets[DEFAULT_CAMERA_PRESET]);
}
function parseCameraJsonString(raw, { strict = true } = {}) {
const text = String(raw || "").trim();
if (!text.startsWith("{")) {
return null;
}
try {
const parsed = JSON.parse(text);
if (!isPlainObject(parsed)) {
throw new Error("camera JSON must be an object");
}
return parsed;
} catch (error) {
if (strict) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid camera JSON: ${message}`);
}
return null;
}
}
function validateCameraSpecKeys(spec) {
const unknownKeys = Object.keys(spec).filter((key) => !CAMERA_SPEC_KEYS.includes(key));
if (unknownKeys.length) {
throw new Error(`Unsupported camera fields: ${unknownKeys.join(", ")}`);
}
}
export function normalizeCameraSpec(rawCamera = DEFAULT_CAMERA_PRESET, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const parsedJson = typeof rawCamera === "string"
? parseCameraJsonString(rawCamera, { strict })
: null;
const source = parsedJson || rawCamera;
if (!isPlainObject(source)) {
const preset = cameraPresetByName(source || DEFAULT_CAMERA_PRESET, { presets, strict });
return {
sourceKind: "string",
name: preset.name,
preset: preset.name,
direction: preset.direction,
position: null,
target: null,
up: preset.up,
zoom: 1,
hasExplicitPosition: false,
hasExplicitTarget: false,
hasExplicitUp: false,
hasExplicitZoom: false
};
}
validateCameraSpecKeys(source);
const rawPreset = source.preset ?? DEFAULT_CAMERA_PRESET;
const preset = cameraPresetByName(rawPreset, { presets, strict });
const direction = normalizeVector(source.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || preset.direction;
const position = normalizeVector(source.position, {
fieldName: "camera.position",
strict
});
const target = normalizeVector(source.target, {
fieldName: "camera.target",
strict
});
const up = normalizeVector(source.up, {
fieldName: "camera.up",
nonZero: true,
strict
}) || preset.up || [...WORLD_UP];
const hasExplicitZoom = Object.prototype.hasOwnProperty.call(source, "zoom");
return {
sourceKind: "object",
name: String(source.name || preset.name || "custom"),
preset: preset.name,
direction,
position,
target,
up,
zoom: normalizeCameraZoom(source.zoom, 1, { strict, fieldName: "camera.zoom" }),
hasExplicitPosition: Object.prototype.hasOwnProperty.call(source, "position"),
hasExplicitTarget: Object.prototype.hasOwnProperty.call(source, "target"),
hasExplicitUp: Object.prototype.hasOwnProperty.call(source, "up"),
hasExplicitZoom
};
}
function settingsForCamera(sceneScale, settingsByScale) {
if (settingsByScale?.[sceneScale]) {
return settingsByScale[sceneScale];
}
if (settingsByScale?.cad) {
return settingsByScale.cad;
}
return {
minBoundsSpan: 1,
minModelRadius: 1,
minCameraDistance: 10,
minCameraFar: 1000
};
}
function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = settingsForCamera(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
function vectorFromArray(vector) {
return new THREE.Vector3(vector[0], vector[1], vector[2]);
}
export function resolveCameraView(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
const target = spec.target ? vectorFromArray(spec.target) : null;
const position = spec.position ? vectorFromArray(spec.position) : null;
const direction = position && target
? position.clone().sub(target).normalize().toArray()
: normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict: false
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
return {
name: spec.name,
direction,
up: [...(spec.up || WORLD_UP)],
zoom: spec.zoom
};
}
export function resolveCameraSnapshot(rawCamera = DEFAULT_CAMERA_PRESET, bounds = null, {
sceneScale = "cad",
settingsByScale = null,
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const spec = normalizeCameraSpec(rawCamera, { presets, strict });
const settings = settingsForCamera(sceneScale, settingsByScale);
const { center, radius, size } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const target = spec.target ? vectorFromArray(spec.target) : center.clone();
const direction = normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
const position = spec.position
? vectorFromArray(spec.position)
: target.clone().add(vectorFromArray(direction).normalize().multiplyScalar(
Math.max(radius * 3.2, settings.minCameraDistance || settings.minModelRadius * 10 || 10)
));
const resolvedDirection = position.clone().sub(target);
if (resolvedDirection.lengthSq() <= 1e-12) {
throw new Error("camera.position must differ from camera.target");
}
const up = vectorFromArray(spec.up || WORLD_UP);
if (up.lengthSq() <= 1e-12) {
throw new Error("camera.up must not be the zero vector");
}
return {
name: spec.name,
preset: spec.preset,
position: position.toArray(),
target: target.toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom,
direction: resolvedDirection.normalize().toArray(),
view: {
name: spec.name,
direction: resolvedDirection.normalize().toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom
},
sourceKind: spec.sourceKind,
hasExplicitPosition: spec.hasExplicitPosition,
hasExplicitTarget: spec.hasExplicitTarget,
hasExplicitUp: spec.hasExplicitUp,
hasExplicitZoom: spec.hasExplicitZoom,
radius,
size: size.toArray()
};
}
export function cameraSpecUsesPerspectiveProjection(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
return spec.sourceKind === "object" && (
spec.hasExplicitPosition ||
spec.hasExplicitTarget ||
spec.hasExplicitUp
);
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cameraSpecUsesPerspectiveProjection,
normalizeCameraSpec,
resolveCameraSnapshot
} from "./camera.js";
import {
RENDER_SCENE_SCALE
} from "./renderOptions.js";
const SCALE_SETTINGS = Object.freeze({
[RENDER_SCENE_SCALE.CAD]: Object.freeze({
minBoundsSpan: 1,
minModelRadius: 1,
minFloorSize: 100,
minCameraDistance: 10,
minCameraFar: 1000
}),
[RENDER_SCENE_SCALE.URDF]: Object.freeze({
minBoundsSpan: 0.05,
minModelRadius: 0.05,
minFloorSize: 0.05,
minCameraDistance: 0.5,
minCameraFar: 10
})
});
function assertClose(actual, expected, epsilon = 1e-6) {
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} !== ${expected}`);
}
test("camera preset strings expand like JSON preset specs", () => {
const stringSpec = normalizeCameraSpec("iso");
const jsonSpec = normalizeCameraSpec({ preset: "iso" });
assert.equal(stringSpec.name, jsonSpec.name);
assert.equal(stringSpec.preset, jsonSpec.preset);
assert.deepEqual(stringSpec.direction, jsonSpec.direction);
assert.deepEqual(stringSpec.up, jsonSpec.up);
assert.equal(stringSpec.zoom, jsonSpec.zoom);
});
test("camera JSON preset overrides merge onto the preset defaults", () => {
const spec = normalizeCameraSpec({
preset: "top",
up: [0, 0, 1],
zoom: 1.4
});
assert.equal(spec.name, "top");
assert.deepEqual(spec.direction, [0, 0, 1]);
assert.deepEqual(spec.up, [0, 0, 1]);
assert.equal(spec.zoom, 1.4);
assert.equal(spec.hasExplicitZoom, true);
});
test("explicit camera state passes through resolved snapshots", () => {
const snapshot = resolveCameraSnapshot({
position: [10, 20, 30],
target: [1, 2, 3],
up: [0, 0, 1],
zoom: 1.4
}, { min: [0, 0, 0], max: [2, 4, 6] }, {
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assert.deepEqual(snapshot.position, [10, 20, 30]);
assert.deepEqual(snapshot.target, [1, 2, 3]);
assert.deepEqual(snapshot.up, [0, 0, 1]);
assert.equal(snapshot.zoom, 1.4);
assert.equal(cameraSpecUsesPerspectiveProjection({ position: [10, 20, 30] }), true);
});
test("omitted camera fields derive from model bounds", () => {
const snapshot = resolveCameraSnapshot({ preset: "top" }, { min: [0, 0, 0], max: [2, 4, 6] }, {
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assert.deepEqual(snapshot.target, [1, 2, 3]);
assertClose(snapshot.position[0], 1);
assertClose(snapshot.position[1], 2);
assert.ok(snapshot.position[2] > 3);
assert.deepEqual(snapshot.up, [0, 1, 0]);
assert.equal(snapshot.zoom, 1);
assert.equal(cameraSpecUsesPerspectiveProjection({ preset: "top" }), false);
});
test("invalid camera specs fail clearly", () => {
assert.throws(() => normalizeCameraSpec({ preset: "wat" }), /Unknown camera preset/);
assert.throws(() => normalizeCameraSpec({ position: [1, 2, "x"] }), /camera.position/);
assert.throws(() => normalizeCameraSpec({ up: [0, 0, 0] }), /camera.up/);
assert.throws(() => normalizeCameraSpec({ zoom: 0 }), /camera.zoom/);
assert.throws(() => normalizeCameraSpec({ preset: "iso", extra: true }), /Unsupported camera fields/);
});
@@ -0,0 +1,48 @@
import {
buildPartTransformMatrix
} from "./stepModuleEffects.js";
function applyObjectMatrix(THREE, object3d, matrix) {
if (!object3d || !(matrix instanceof THREE.Matrix4)) {
return;
}
object3d.matrixAutoUpdate = false;
const targetMatrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
targetMatrix.copy(matrix);
object3d.matrix = targetMatrix;
object3d.matrixWorldNeedsUpdate = true;
}
export function composeDisplayRecordEffectMatrix(THREE, record) {
if (!record || !THREE?.Matrix4) {
return null;
}
const matrices = [
record.effectMatrix,
record.explodedViewMatrix
].filter((matrix) => matrix instanceof THREE.Matrix4);
if (!matrices.length) {
return null;
}
const combined = new THREE.Matrix4();
for (const matrix of matrices) {
combined.premultiply(matrix);
}
return combined;
}
export function composeDisplayRecordObjectMatrix(THREE, record) {
const baseMatrix = buildPartTransformMatrix(THREE, record?.baseTransform);
const effectMatrix = composeDisplayRecordEffectMatrix(THREE, record);
return effectMatrix ? effectMatrix.multiply(baseMatrix) : baseMatrix;
}
export function applyDisplayRecordTransform(THREE, record) {
if (!record) {
return;
}
const combinedMatrix = composeDisplayRecordObjectMatrix(THREE, record);
applyObjectMatrix(THREE, record.mesh, combinedMatrix);
applyObjectMatrix(THREE, record.edges, combinedMatrix);
applyObjectMatrix(THREE, record.silhouette, combinedMatrix);
}
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
composeDisplayRecordEffectMatrix,
composeDisplayRecordObjectMatrix
} from "./displayRecordTransform.js";
test("display record transforms compose module effects and exploded view effects", () => {
const record = {
baseTransform: [
1, 0, 0, 3,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
],
effectMatrix: new THREE.Matrix4().makeTranslation(5, 0, 0),
explodedViewMatrix: new THREE.Matrix4().makeTranslation(0, 7, 0)
};
const effectPoint = new THREE.Vector3(0, 0, 0).applyMatrix4(
composeDisplayRecordEffectMatrix(THREE, record)
);
assert.deepEqual(effectPoint.toArray(), [5, 7, 0]);
const objectPoint = new THREE.Vector3(0, 0, 0).applyMatrix4(
composeDisplayRecordObjectMatrix(THREE, record)
);
assert.deepEqual(objectPoint.toArray(), [8, 7, 0]);
});
@@ -0,0 +1,381 @@
import {
DEFAULT_STEP_CLIP_SETTINGS,
normalizeStepClipSettings,
stepClipSettingsEqual
} from "../lib/viewer/clipPlane.js";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "../lib/perspective.js";
export { CAMERA_PROJECTION, normalizeCameraProjection };
export const CAD_DISPLAY_MODE = Object.freeze({
HIDDEN_EDGES: "hidden_edges",
HIDDEN_LINES_REMOVED: "hidden_lines_removed",
RENDERED: "rendered",
SOLID: "solid",
TRANSPARENT: "transparent",
UNSHADED: "unshaded",
WIREFRAME: "wireframe"
});
export const CAD_DISPLAY_MODE_VALUES = Object.freeze(Object.values(CAD_DISPLAY_MODE));
export const EXPLODED_VIEW_AXES = Object.freeze(["x", "y", "z", "radial"]);
export const EXPLODED_VIEW_DIRECTIONS = Object.freeze(["positive", "negative"]);
export const MAX_EXPLODED_VIEW_DEPTH = 8;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const CAD_EDGE_COLOR = "#132232";
export const CAD_EDGE_HIGHLIGHT_COLOR = "#8dc5ff";
export const CAD_EDGE_CLASS_IDS = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
export const DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 1.15
}),
tangent: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.5,
thickness: 1.15
}),
seam: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.85,
thickness: 1.15
}),
degenerate: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 0
})
});
export const DEFAULT_DISPLAY_EDGE_SETTINGS = Object.freeze({
enabled: true,
contrastMode: "manual",
color: CAD_EDGE_COLOR,
thickness: 1,
classes: DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
highlightColor: CAD_EDGE_HIGHLIGHT_COLOR,
highlightOpacity: 1,
highlightThickness: 3,
silhouette: false,
silhouetteScale: 0
});
export const DISABLED_DISPLAY_EDGE_SETTINGS = Object.freeze({
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false
});
export const DEFAULT_EXPLODED_VIEW_SETTINGS = Object.freeze({
enabled: false,
axis: "z",
direction: "positive",
spacing: 1.45,
depth: 1,
keepBaseGrounded: true,
mergeCoplanar: false,
autoFrame: true
});
export const DEFAULT_DISPLAY_SETTINGS = Object.freeze({
projection: CAMERA_PROJECTION.ORTHOGRAPHIC,
mode: CAD_DISPLAY_MODE.SOLID,
clip: DEFAULT_STEP_CLIP_SETTINGS,
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeNumber(value, fallback, min = -Infinity, max = Infinity) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return fallback;
}
return clamp(numericValue, min, max);
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDisplayEdgeContrastMode(value, fallback = "manual") {
const normalized = String(value || "").trim().toLowerCase();
return ["auto", "manual"].includes(normalized)
? normalized
: fallback;
}
export function normalizeDisplayEdgeClassSettings(
value = {},
fallback = DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
colorFallback = CAD_EDGE_COLOR
) {
const source = isObject(value) ? value : {};
const fallbackColor = normalizeColor(colorFallback, CAD_EDGE_COLOR);
return Object.fromEntries(CAD_EDGE_CLASS_IDS.map((classId) => {
const classSource = isObject(source[classId]) ? source[classId] : {};
const classFallback = fallback?.[classId] || DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS[classId];
const legacyDisabled = classSource.enabled === false;
return [classId, {
color: normalizeColor(classSource.color, fallbackColor),
opacity: normalizeNumber(classSource.opacity, classFallback.opacity, 0, 1),
thickness: legacyDisabled
? 0
: normalizeNumber(classSource.thickness, classFallback.thickness, 0, 6)
}];
}));
}
export function normalizeDisplayEdgeSettings(value = null, fallback = DEFAULT_DISPLAY_EDGE_SETTINGS) {
const source = isObject(value) ? value : {};
const color = normalizeColor(source.color, fallback.color);
const normalized = {
enabled: normalizeBoolean(source.enabled, fallback.enabled),
contrastMode: normalizeDisplayEdgeContrastMode(source.contrastMode, fallback.contrastMode),
color,
thickness: normalizeNumber(source.thickness, fallback.thickness, 0.5, 6),
classes: normalizeDisplayEdgeClassSettings(source.classes, fallback.classes, color),
highlightColor: normalizeColor(source.highlightColor, fallback.highlightColor || CAD_EDGE_HIGHLIGHT_COLOR),
highlightOpacity: normalizeNumber(source.highlightOpacity, fallback.highlightOpacity || 1, 0, 1),
highlightThickness: normalizeNumber(source.highlightThickness, fallback.highlightThickness || 3, 0.5, 6),
silhouette: normalizeBoolean(source.silhouette, fallback.silhouette || false),
silhouetteScale: normalizeNumber(source.silhouetteScale, fallback.silhouetteScale || 0, 0, 0.04)
};
if (typeof source.depthTest === "boolean") {
normalized.depthTest = source.depthTest;
}
return normalized;
}
function normalizeModeText(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_");
}
export function normalizeDisplayMode(value) {
const normalized = normalizeModeText(value);
if (!normalized) {
return CAD_DISPLAY_MODE.SOLID;
}
if (normalized === "wire" || normalized === "wire_frame") {
return CAD_DISPLAY_MODE.WIREFRAME;
}
if (
normalized === "edges" ||
normalized === "edge" ||
normalized === "shaded_edges" ||
normalized === "shaded_with_edges" ||
normalized === "with_edges"
) {
return CAD_DISPLAY_MODE.SOLID;
}
if (
normalized === "shaded" ||
normalized === "shaded_without_edges" ||
normalized === "without_edges"
) {
return CAD_DISPLAY_MODE.RENDERED;
}
if (
normalized === "translucent" ||
normalized === "xray" ||
normalized === "x_ray" ||
normalized === "see_through"
) {
return CAD_DISPLAY_MODE.TRANSPARENT;
}
if (
normalized === "hidden_edge" ||
normalized === "hidden_edges_visible" ||
normalized === "hidden_edge_display" ||
normalized === "shaded_hidden_edges"
) {
return CAD_DISPLAY_MODE.HIDDEN_EDGES;
}
if (
normalized === "visible_edges" ||
normalized === "visible_edges_only" ||
normalized === "hidden_lines" ||
normalized === "hidden_line_removed" ||
normalized === "hidden_lines_removed" ||
normalized === "hidden_edges_removed"
) {
return CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED;
}
if (normalized === "flat") {
return CAD_DISPLAY_MODE.UNSHADED;
}
if (normalized === "appearance" || normalized === "material" || normalized === "materials") {
return CAD_DISPLAY_MODE.RENDERED;
}
return CAD_DISPLAY_MODE_VALUES.includes(normalized)
? normalized
: CAD_DISPLAY_MODE.SOLID;
}
export function normalizeExplodedViewAxis(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.axis) {
const normalized = String(value || "").trim().toLowerCase();
const axis = normalized.startsWith("-") ? normalized.slice(1) : normalized;
return EXPLODED_VIEW_AXES.includes(axis) ? axis : fallback;
}
export function normalizeExplodedViewDirection(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.direction) {
const normalized = String(value || "").trim().toLowerCase();
if (["negative", "reverse", "down", "backward", "-", "-1"].includes(normalized)) {
return "negative";
}
if (["positive", "forward", "up", "+", "+1", "1"].includes(normalized)) {
return "positive";
}
return fallback;
}
export function normalizeExplodedViewDepth(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.depth) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["all", "full", "parts", "leaves", "leaf"].includes(normalized)) {
return MAX_EXPLODED_VIEW_DEPTH;
}
const numericValue = Math.round(Number(value));
return Number.isFinite(numericValue)
? clamp(numericValue, 1, MAX_EXPLODED_VIEW_DEPTH)
: fallback;
}
export function normalizeExplodedViewSettings(value = null, overrides = {}) {
const source = isObject(value) ? value : {};
const merged = isObject(overrides) ? { ...source, ...overrides } : source;
const axisText = String(merged.axis || "").trim().toLowerCase();
return {
enabled: normalizeBoolean(merged.enabled, DEFAULT_EXPLODED_VIEW_SETTINGS.enabled),
axis: normalizeExplodedViewAxis(merged.axis),
direction: normalizeExplodedViewDirection(merged.direction || (axisText.startsWith("-") ? "negative" : "positive")),
spacing: normalizeNumber(merged.spacing ?? merged.distance ?? merged.distanceScale, DEFAULT_EXPLODED_VIEW_SETTINGS.spacing, 0.25, 4),
depth: normalizeExplodedViewDepth(merged.depth ?? merged.levels ?? merged.scopeDepth),
keepBaseGrounded: normalizeBoolean(merged.keepBaseGrounded ?? merged.groundBase, DEFAULT_EXPLODED_VIEW_SETTINGS.keepBaseGrounded),
mergeCoplanar: normalizeBoolean(merged.mergeCoplanar ?? merged.mergeLayers ?? merged.coalesceLayers, DEFAULT_EXPLODED_VIEW_SETTINGS.mergeCoplanar),
autoFrame: normalizeBoolean(merged.autoFrame, DEFAULT_EXPLODED_VIEW_SETTINGS.autoFrame)
};
}
export function normalizeDisplaySettings(value = null) {
const source = isObject(value) ? value : {};
const explodedOverrides = {};
if (isObject(source.exploded) && source.exploded.enabled !== undefined) {
explodedOverrides.enabled = source.exploded.enabled;
}
return {
projection: normalizeCameraProjection(source.projection, DEFAULT_DISPLAY_SETTINGS.projection),
mode: normalizeDisplayMode(source.mode),
clip: normalizeStepClipSettings(source.clip),
exploded: normalizeExplodedViewSettings(source.exploded, explodedOverrides),
edges: normalizeDisplayEdgeSettings(source.edges)
};
}
export function cloneDisplaySettings(value = DEFAULT_DISPLAY_SETTINGS) {
return normalizeDisplaySettings(value);
}
export function displaySettingsEqual(left, right) {
const a = normalizeDisplaySettings(left);
const b = normalizeDisplaySettings(right);
return a.projection === b.projection &&
a.mode === b.mode &&
stepClipSettingsEqual(a.clip, b.clip) &&
JSON.stringify(a.exploded) === JSON.stringify(b.exploded) &&
JSON.stringify(a.edges) === JSON.stringify(b.edges);
}
export function resolveDisplayMode(displaySettings) {
return normalizeDisplaySettings(displaySettings).mode;
}
export function resolveDisplayEdgeSettings(displaySettings) {
return normalizeDisplaySettings(displaySettings).edges;
}
export function displayModeIsWireframe(value) {
return normalizeDisplayMode(value) === CAD_DISPLAY_MODE.WIREFRAME;
}
export function displayModeForcesEdges(value) {
return [
CAD_DISPLAY_MODE.SOLID,
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED
].includes(normalizeDisplayMode(value));
}
export function displayModeAllowsEdges(value) {
return ![
CAD_DISPLAY_MODE.RENDERED,
CAD_DISPLAY_MODE.UNSHADED
].includes(normalizeDisplayMode(value));
}
export function displayModeShowsEdges(value, edgeSettings = null) {
const mode = normalizeDisplayMode(value);
return mode === CAD_DISPLAY_MODE.WIREFRAME ||
displayModeForcesEdges(mode);
}
export function displayModeShowsThroughEdges(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesTransparentSurfaces(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesUnlitSurfaces(value) {
return [
CAD_DISPLAY_MODE.UNSHADED,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeSurfaceOpacity(value, fallback = 1) {
const mode = normalizeDisplayMode(value);
if (mode === CAD_DISPLAY_MODE.WIREFRAME) {
return 0.035;
}
if (mode === CAD_DISPLAY_MODE.TRANSPARENT) {
return 0.22;
}
if (mode === CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED) {
return 0.045;
}
const numericFallback = Number(fallback);
return Number.isFinite(numericFallback) ? numericFallback : 1;
}
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CAD_DISPLAY_MODE,
CAMERA_PROJECTION,
DEFAULT_DISPLAY_EDGE_SETTINGS,
DEFAULT_DISPLAY_SETTINGS,
DEFAULT_EXPLODED_VIEW_SETTINGS,
displayModeForcesEdges,
displayModeShowsEdges,
displayModeShowsThroughEdges,
displayModeSurfaceOpacity,
displayModeUsesUnlitSurfaces,
displaySettingsEqual,
normalizeDisplayEdgeSettings,
normalizeDisplaySettings,
normalizeExplodedViewSettings,
resolveDisplayMode
} from "./displaySettings.js";
test("display settings normalize mode and clip independently from appearance settings", () => {
assert.deepEqual(normalizeDisplaySettings(), DEFAULT_DISPLAY_SETTINGS);
assert.equal(resolveDisplayMode({ mode: "wireframe" }), CAD_DISPLAY_MODE.WIREFRAME);
assert.equal(normalizeDisplaySettings().projection, CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.equal(normalizeDisplaySettings({ projection: "perspective" }).projection, CAMERA_PROJECTION.PERSPECTIVE);
assert.equal(normalizeDisplaySettings({ projection: "fisheye" }).projection, CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.deepEqual(normalizeDisplaySettings({
projection: "perspective",
mode: "wireframe",
clip: {
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}
}), {
projection: CAMERA_PROJECTION.PERSPECTIVE,
mode: CAD_DISPLAY_MODE.WIREFRAME,
clip: {
enabled: true,
axis: "z",
offset: 0.4,
offsets: {
x: 0,
y: 0,
z: 0.4
},
invert: true
},
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
});
test("display settings normalize edge styling independently from appearance settings", () => {
assert.deepEqual(normalizeDisplayEdgeSettings({
enabled: false,
contrastMode: "auto",
color: "#ABC",
thickness: 2,
classes: {
tangent: {
enabled: false,
color: "#456",
opacity: 0.25,
thickness: 4
}
},
highlightColor: "#123456",
highlightOpacity: 0.4,
highlightThickness: 4,
silhouette: true,
silhouetteScale: 0.01
}), {
enabled: false,
contrastMode: "auto",
color: "#aabbcc",
thickness: 2,
classes: {
feature: { color: "#aabbcc", opacity: 1, thickness: 1.15 },
tangent: { color: "#445566", opacity: 0.25, thickness: 0 },
seam: { color: "#aabbcc", opacity: 0.85, thickness: 1.15 },
degenerate: { color: "#aabbcc", opacity: 1, thickness: 0 }
},
highlightColor: "#123456",
highlightOpacity: 0.4,
highlightThickness: 4,
silhouette: true,
silhouetteScale: 0.01
});
assert.deepEqual(normalizeDisplaySettings({
mode: "solid",
edges: {
enabled: false,
color: "#456"
}
}).edges, {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false,
color: "#445566",
classes: {
feature: { color: "#445566", opacity: 1, thickness: 1.15 },
tangent: { color: "#445566", opacity: 0.5, thickness: 1.15 },
seam: { color: "#445566", opacity: 0.85, thickness: 1.15 },
degenerate: { color: "#445566", opacity: 1, thickness: 0 }
}
});
});
test("display settings normalize exploded-view controls independently from mode", () => {
assert.deepEqual(normalizeExplodedViewSettings({
enabled: true,
axis: "-x",
spacing: 2,
levels: "all",
groundBase: false,
mergeLayers: true,
autoFrame: false
}), {
enabled: true,
axis: "x",
direction: "negative",
spacing: 2,
depth: 8,
keepBaseGrounded: false,
mergeCoplanar: true,
autoFrame: false
});
assert.equal(normalizeExplodedViewSettings({ axis: "diagonal" }).axis, "z");
assert.equal(normalizeExplodedViewSettings({ axis: "radial" }).axis, "radial");
assert.equal(normalizeDisplaySettings({ exploded: true }).exploded.enabled, false);
assert.equal(normalizeDisplaySettings({ mode: "exploded", exploded: { enabled: true } }).exploded.enabled, true);
assert.deepEqual(normalizeDisplaySettings({ mode: "exploded view" }), DEFAULT_DISPLAY_SETTINGS);
});
test("display modes normalize common CAD aliases", () => {
assert.equal(resolveDisplayMode({ mode: "edges" }), CAD_DISPLAY_MODE.SOLID);
assert.equal(resolveDisplayMode({ mode: "shaded-with-edges" }), CAD_DISPLAY_MODE.SOLID);
assert.equal(resolveDisplayMode({ mode: "shaded without edges" }), CAD_DISPLAY_MODE.RENDERED);
assert.equal(resolveDisplayMode({ mode: "x-ray" }), CAD_DISPLAY_MODE.TRANSPARENT);
assert.equal(resolveDisplayMode({ mode: "hidden edges visible" }), CAD_DISPLAY_MODE.HIDDEN_EDGES);
assert.equal(resolveDisplayMode({ mode: "hidden-lines-removed" }), CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED);
assert.equal(resolveDisplayMode({ mode: "flat" }), CAD_DISPLAY_MODE.UNSHADED);
assert.equal(resolveDisplayMode({ mode: "appearance" }), CAD_DISPLAY_MODE.RENDERED);
assert.equal(resolveDisplayMode({ mode: "wire" }), CAD_DISPLAY_MODE.WIREFRAME);
});
test("display mode policies describe edge and surface behavior", () => {
assert.equal(displayModeShowsEdges(CAD_DISPLAY_MODE.SOLID, { enabled: false }), true);
assert.equal(displayModeForcesEdges(CAD_DISPLAY_MODE.SOLID), true);
assert.equal(displayModeShowsEdges(CAD_DISPLAY_MODE.RENDERED, { enabled: true }), false);
assert.equal(displayModeShowsThroughEdges(CAD_DISPLAY_MODE.HIDDEN_EDGES), true);
assert.equal(displayModeShowsThroughEdges(CAD_DISPLAY_MODE.TRANSPARENT), true);
assert.equal(displayModeUsesUnlitSurfaces(CAD_DISPLAY_MODE.UNSHADED), true);
assert.equal(displayModeSurfaceOpacity(CAD_DISPLAY_MODE.TRANSPARENT, 1), 0.22);
assert.equal(displayModeSurfaceOpacity(CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED, 1), 0.045);
});
test("display settings compare after normalization", () => {
assert.equal(displaySettingsEqual(
{ mode: "wireframe", clip: { enabled: true, axis: "x", offset: 0.5 } },
{ mode: CAD_DISPLAY_MODE.WIREFRAME, clip: { enabled: true, axis: "x", offsets: { x: 0.5 } } }
), true);
assert.equal(displaySettingsEqual(
{ mode: "solid", clip: { enabled: true } },
{ mode: "wireframe", clip: { enabled: true } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", exploded: { enabled: true } },
{ mode: "solid", exploded: { enabled: false } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", edges: { color: "#111111" } },
{ mode: "solid", edges: { color: "#222222" } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", projection: "orthographic" },
{ mode: "solid", projection: "perspective" }
), false);
});
@@ -0,0 +1,29 @@
export const DEFAULT_ORBIT_FPS = 6;
export const DEFAULT_ORBIT_DURATION_SECONDS = 12;
export function orbitFrameOutputs(job) {
const orbit = job.orbit && typeof job.orbit === "object" ? job.orbit : {};
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || orbit.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || orbit.height || 480)));
const fps = Math.max(1, Math.min(Number(orbit.fps || DEFAULT_ORBIT_FPS), 60));
const durationSeconds = Math.max(0.1, Math.min(Number(orbit.durationSeconds || DEFAULT_ORBIT_DURATION_SECONDS), 60));
const frameCount = Math.max(2, Math.min(Math.round(fps * durationSeconds), 720));
const startAzimuth = Number.isFinite(Number(orbit.startAzimuth)) ? Number(orbit.startAzimuth) : -45;
const elevation = Number.isFinite(Number(orbit.elevation)) ? Number(orbit.elevation) : 30;
const turns = Number.isFinite(Number(orbit.turns)) ? Number(orbit.turns) : 1;
return {
path: String(output.path || job.output || ""),
width,
height,
fps,
durationSeconds,
frameCount,
outputs: Array.from({ length: frameCount }, (_, index) => ({
path: "",
width,
height,
camera: `${startAzimuth + ((360 * turns * index) / frameCount)}:${elevation}`
}))
};
}
@@ -0,0 +1,232 @@
import gifencDefault, {
GIFEncoder as exportedGifEncoder,
applyPalette as exportedApplyPalette,
quantize as exportedQuantize
} from "gifenc";
import * as THREE from "three";
import {
buildModel
} from "./cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "./renderMeshScene.js";
import {
hasStepParameterRenderValues
} from "./stepParameters.js";
import {
loadSource,
stepParameterFrameRuntime
} from "./source.js";
import {
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
const GIFEncoder = exportedGifEncoder || gifencDefault?.GIFEncoder || gifencDefault;
const quantize = exportedQuantize || gifencDefault?.quantize;
const applyPalette = exportedApplyPalette || gifencDefault?.applyPalette;
async function dataUrlToImageData(dataUrl, width, height) {
const image = new Image();
image.decoding = "async";
const loaded = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error("Failed to load rendered orbit frame"));
});
image.src = dataUrl;
await loaded;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
}
function shouldEncodeTransparentGif(job = {}) {
const backgroundType = String(
job.appearance?.background?.type || ""
).toLowerCase();
return Boolean(job.render?.transparent) || backgroundType === "transparent";
}
function encodeGifFrameImageData(imageData, { transparent = false } = {}) {
if (!transparent) {
const palette = quantize(imageData.data, 256);
return {
indexed: applyPalette(imageData.data, palette),
palette,
transparent: false,
transparentIndex: 0
};
}
const palette = quantize(imageData.data, 256, {
format: "rgba4444",
oneBitAlpha: true
});
const transparentIndex = palette.findIndex((color) => Number(color?.[3]) <= 127);
return {
indexed: applyPalette(imageData.data, palette, "rgba4444"),
palette,
transparent: transparentIndex >= 0,
transparentIndex: Math.max(transparentIndex, 0)
};
}
async function capturePreparedSource(source, job) {
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
if (context.mode === "list" || context.mode === "section") {
try {
return await captureModel({ model, context }, { job });
} finally {
model.dispose();
}
}
const viewport = renderModel(THREE, model, { job, context });
try {
return await captureModel(viewport, { job });
} finally {
viewport.dispose();
}
}
async function renderOrbit(source, job) {
const orbit = orbitFrameOutputs(job);
const frameResult = await capturePreparedSource(source, {
...job,
mode: "view",
outputs: orbit.outputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, orbit.width, orbit.height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, orbit.width, orbit.height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / orbit.fps,
repeat: 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: "orbit",
outputs: [{
path: orbit.path,
width: orbit.width,
height: orbit.height,
frameCount: orbit.frameCount,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
async function renderParamAnimation(source, job, stepParameterSource) {
const params = stepParameterSource.renderParameters;
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || 480)));
const frameOutputs = Array.from({ length: params.frameCount }, (_, index) => ({
...output,
path: "",
width,
height,
stepParameters: stepParameterFrameRuntime(stepParameterSource, index)
}));
const frameResult = await capturePreparedSource(source, {
...job,
outputs: frameOutputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, width, height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, width, height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / params.fps,
repeat: params.loop === false ? -1 : 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: String(job.mode || "view").toLowerCase(),
outputs: [{
path: String(output.path || job.output || ""),
width,
height,
frameCount: params.frameCount,
fps: params.fps,
durationSeconds: params.durationSeconds,
loop: params.loop !== false,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
export async function runHeadlessRenderJob(job) {
const source = await loadSource(job);
const stepParameterSource = source.stepParameterSource;
const explicitParams = hasStepParameterRenderValues(job.stepParameters);
const renderJob = {
...job,
selectorRuntime: source.selectorRuntime,
displayEdgeRuntime: source.displayEdgeRuntime
};
if (stepParameterSource && explicitParams && String(job.mode || "view").toLowerCase() !== "view") {
throw new Error("stepParameters support only view mode; set display.mode for display-style changes");
}
const renderJobWithStepParameters = stepParameterSource
? {
...renderJob,
stepParameters: stepParameterFrameRuntime(stepParameterSource, 0)
}
: renderJob;
if (stepParameterSource?.renderParameters?.animated) {
return renderParamAnimation(source, renderJob, stepParameterSource);
}
if (String(job.mode || "view").toLowerCase() === "orbit") {
return renderOrbit(source, renderJobWithStepParameters);
}
return capturePreparedSource(source, renderJobWithStepParameters);
}
if (typeof window !== "undefined") {
window.__snapshotRender = runHeadlessRenderJob;
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_ORBIT_DURATION_SECONDS,
DEFAULT_ORBIT_FPS,
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
test("orbitFrameOutputs uses calm default orbit timing", () => {
const orbit = orbitFrameOutputs({
mode: "orbit",
outputs: [{ path: "orbit.gif" }]
});
assert.equal(orbit.fps, DEFAULT_ORBIT_FPS);
assert.equal(orbit.durationSeconds, DEFAULT_ORBIT_DURATION_SECONDS);
assert.equal(orbit.frameCount, 72);
assert.equal(orbit.outputs.length, 72);
assert.equal(orbit.outputs[0].camera, "-45:30");
assert.equal(orbit.outputs.at(-1).camera, "310:30");
});
test("orbitFrameOutputs honors explicit orbit timing", () => {
const orbit = orbitFrameOutputs({
mode: "orbit",
orbit: {
fps: 18,
durationSeconds: 4,
turns: 0.5,
startAzimuth: 10,
elevation: 20
},
outputs: [{ path: "orbit.gif" }]
});
assert.equal(orbit.fps, 18);
assert.equal(orbit.durationSeconds, 4);
assert.equal(orbit.frameCount, 72);
assert.equal(orbit.outputs[0].camera, "10:20");
assert.equal(orbit.outputs.at(-1).camera, "187.5:20");
});
@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>CAD Viewer headless render</title>
<style>
html,
body {
margin: 0;
min-width: 100%;
min-height: 100%;
overflow: hidden;
background: transparent;
}
</style>
<script type="importmap">
{
"imports": {
"three": "/node_modules/three/build/three.module.js",
"three/examples/": "/node_modules/three/examples/",
"gifenc": "/node_modules/gifenc/dist/gifenc.esm.js"
}
}
</script>
</head>
<body>
<script type="module" src="/common/headlessRenderEntry.js"></script>
</body>
</html>
@@ -0,0 +1,661 @@
import {
buildTopologyDisplayEdgePolylines,
buildTopologyDisplayEdgePositions
} from "./topologyDisplayEdges.js";
import {
displayModeIsWireframe
} from "./displaySettings.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
const DEFAULT_LINE_DEPTH_BIAS = 0;
// Keep topology edges slightly above coplanar faces without letting nearby solids lose the depth test.
export const TOPOLOGY_LINE_DEPTH_BIAS = 0.0045;
const TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025;
const MAX_TOPOLOGY_LINE_DEPTH_BIAS = 0.006;
const COPLANAR_TOPOLOGY_EDGE_CLASSES = new Set(["tangent", "seam"]);
const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.006;
const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025;
const MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.008;
const DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES = Object.freeze(["feature"]);
const TOPOLOGY_EDGE_CLASS_ORDER = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
const MAX_TOPOLOGY_LINE_STRIP_POLYLINES = 1200;
const MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES = 180000;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({ color: "#132232", opacity: 1, thickness: 1.15 }),
tangent: Object.freeze({ color: "#132232", opacity: 0.5, thickness: 1.15 }),
seam: Object.freeze({ color: "#132232", opacity: 0.85, thickness: 1.15 }),
degenerate: Object.freeze({ color: "#132232", opacity: 1, thickness: 0 })
});
export function topologyLineDepthBiasForWidth(lineWidth = 1, { visibilityClass = "" } = {}) {
const width = Number.isFinite(Number(lineWidth))
? Math.max(1, Number(lineWidth))
: 1;
const normalizedClass = String(visibilityClass || "").trim().toLowerCase();
const baseBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS
: TOPOLOGY_LINE_DEPTH_BIAS;
const biasPerExtraPixel = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL
: TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL;
const maxBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS
: MAX_TOPOLOGY_LINE_DEPTH_BIAS;
return clamp(
baseBias + Math.max(0, width - 1) * biasPerExtraPixel,
baseBias,
maxBias
);
}
export function lineSegmentPositionsFromGeometry(geometry) {
const positionAttribute = geometry?.getAttribute?.("position");
const rawPositions = positionAttribute?.array;
if (!positionAttribute?.count || !rawPositions?.length) {
return null;
}
return rawPositions;
}
export function syncLineMaterialOpacity(material, opacity) {
if (!material) {
return;
}
const nextOpacity = clamp(Number(opacity) || 0, 0, 1);
const nextTransparent = nextOpacity < 0.999;
material.opacity = nextOpacity;
material.depthWrite = false;
if (material.transparent !== nextTransparent) {
material.transparent = nextTransparent;
material.needsUpdate = true;
}
}
export function syncScreenSpaceLineMaterialResolution(materials, width, height) {
const nextWidth = Math.max(1, Math.floor(Number(width) || 1));
const nextHeight = Math.max(1, Math.floor(Number(height) || 1));
for (const material of materials || []) {
material?.resolution?.set?.(nextWidth, nextHeight);
}
}
function registerLineMaterial(context = {}, material, materials = null) {
materials?.add?.(material);
context.registerScreenSpaceLineMaterial?.(material);
}
function unregisterLineMaterial(context = {}, material, materials = null) {
materials?.delete?.(material);
context.unregisterScreenSpaceLineMaterial?.(material);
}
function normalizePartIds(value) {
return (Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean);
}
function occurrenceMatchesPartIds(occurrenceId, partIds = []) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
if (!normalizedOccurrenceId || !partIds.length) {
return false;
}
return partIds.some((partId) => (
normalizedOccurrenceId === partId ||
normalizedOccurrenceId.startsWith(`${partId}.`)
));
}
function topologyEdgeRowMatchesPartIds(row, partIds = []) {
return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds);
}
function topologyEdgeRowMatchesLineStripFilters(row, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = []
} = {}) {
if (includePartIds.length && !topologyEdgeRowMatchesPartIds(row, includePartIds)) {
return false;
}
if (excludePartIds.length && topologyEdgeRowMatchesPartIds(row, excludePartIds)) {
return false;
}
if (visibilityClasses.length) {
const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature";
if (!visibilityClasses.includes(visibilityClass)) {
return false;
}
}
return true;
}
function shouldBuildTopologyLineStrips(selectorRuntime, filters = {}) {
const edgeRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : [];
if (!edgeRows.length) {
return false;
}
let polylineCount = 0;
let positionValueCount = 0;
for (const row of edgeRows) {
if (!topologyEdgeRowMatchesLineStripFilters(row, filters)) {
continue;
}
const segmentCount = Number.isFinite(Number(row?.segmentCount))
? Math.max(1, Number(row.segmentCount))
: 1;
polylineCount += 1;
positionValueCount += (segmentCount + 1) * 3;
if (
polylineCount > MAX_TOPOLOGY_LINE_STRIP_POLYLINES ||
positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES
) {
return false;
}
}
return polylineCount > 0;
}
function normalizeVisibilityClasses(value, fallback = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) {
const values = (Array.isArray(value) ? value : [value])
.map((item) => String(item || "").trim().toLowerCase())
.filter(Boolean);
return values.length ? values : fallback;
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeTopologyEdgeClassSettings(edgeSettings = {}, baseTheme = {}, fallbackClasses = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) {
const classSettings = edgeSettings?.classes && typeof edgeSettings.classes === "object"
? edgeSettings.classes
: null;
const explicitVisibilityClasses = classSettings
? []
: normalizeVisibilityClasses(edgeSettings?.visibilityClasses, fallbackClasses);
const baseOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const baseThickness = Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: (baseTheme?.edgeThickness ?? 1);
const baseColor = normalizeColor(edgeSettings?.color, baseTheme?.edge || "#18181b");
if (!classSettings) {
return explicitVisibilityClasses.map((classId) => ({
classId,
color: baseColor,
opacity: baseOpacity,
thickness: baseThickness
}));
}
return TOPOLOGY_EDGE_CLASS_ORDER
.map((classId) => {
const classValue = classSettings[classId] || DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId];
const classOpacity = Number.isFinite(Number(classValue.opacity))
? clamp(Number(classValue.opacity), 0, 1)
: DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].opacity;
const classThickness = Number.isFinite(Number(classValue.thickness))
? clamp(Number(classValue.thickness), 0, 6)
: DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].thickness;
const classColor = normalizeColor(classValue.color, baseColor);
if (classThickness <= 0 || classOpacity <= 0) {
return null;
}
return {
classId,
color: classColor,
opacity: classOpacity,
thickness: classThickness
};
})
.filter(Boolean);
}
function runtimeHasVisibilityClassRows(selectorRuntime) {
return Array.isArray(selectorRuntime?.edges) &&
selectorRuntime.edges.some((row) => String(row?.visibilityClass || "").trim());
}
export function createScreenSpaceLineSegments(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}, materials = null) {
const LineSegments2 = context.LineSegments2;
const LineSegmentsGeometry = context.LineSegmentsGeometry;
const LineMaterial = context.LineMaterial;
if (
!LineSegments2 ||
!LineSegmentsGeometry ||
!LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
!positions.length
) {
return null;
}
const lineGeometry = new LineSegmentsGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity,
lineWidth,
depthTest,
depthWrite,
depthBias
});
registerLineMaterial(context, lineMaterial, materials);
const line = new LineSegments2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
unregisterLineMaterial(context, lineMaterial, materials);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
function createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity = 1,
lineWidth = 1,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const lineMaterial = new LineMaterial({
color,
linewidth: lineWidth,
opacity,
depthTest,
depthWrite,
toneMapped: false,
worldUnits: false
});
syncLineMaterialOpacity(lineMaterial, opacity);
applyLineDepthBias(lineMaterial, depthBias);
return lineMaterial;
}
export function applyLineDepthBias(material, depthBias = DEFAULT_LINE_DEPTH_BIAS) {
const bias = clamp(Number(depthBias) || 0, 0, 0.01);
if (!material || bias <= 0) {
return material;
}
material.polygonOffset = true;
material.polygonOffsetFactor = 0;
material.polygonOffsetUnits = -clamp(Math.round(bias * 1000), 1, 10);
material.needsUpdate = true;
return material;
}
export function createScreenSpaceLineStrip(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}, materials = null) {
const Line2 = context.Line2;
const LineGeometry = context.LineGeometry;
const LineMaterial = context.LineMaterial;
if (
!Line2 ||
!LineGeometry ||
!LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const lineGeometry = new LineGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity,
lineWidth,
depthTest,
depthWrite,
depthBias
});
registerLineMaterial(context, lineMaterial, materials);
const line = new Line2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
unregisterLineMaterial(context, lineMaterial, materials);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createBasicLineStrip(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const THREE = context.THREE;
if (
!THREE ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const geometry = new THREE.BufferGeometry();
const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions);
geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3));
const material = new THREE.LineBasicMaterial({
color,
linewidth: Number.isFinite(Number(lineWidth)) ? Number(lineWidth) : 1,
transparent: Number(opacity) < 0.999,
opacity: clamp(Number(opacity) || 0, 0, 1),
depthTest,
depthWrite,
toneMapped: false
});
applyLineDepthBias(material, depthBias);
const line = new THREE.Line(geometry, material);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createBasicLineSegments(context = {}, positions, {
color,
opacity = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const THREE = context.THREE;
if (
!THREE ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const geometry = new THREE.BufferGeometry();
const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions);
geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3));
const material = new THREE.LineBasicMaterial({
color,
transparent: Number(opacity) < 0.999,
opacity: clamp(Number(opacity) || 0, 0, 1),
depthTest,
depthWrite,
toneMapped: false
});
applyLineDepthBias(material, depthBias);
const line = new THREE.LineSegments(geometry, material);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createScreenSpaceLineSegmentsFromGeometry(context, geometry, options, materials = null) {
const positions = lineSegmentPositionsFromGeometry(geometry);
return positions ? createScreenSpaceLineSegments(context, positions, options, materials) : null;
}
export function createDisplayEdgeObject(context = {}, {
geometry,
edgeSettings,
baseTheme,
partId,
displayMode,
thickness,
wireframeEdgeColor = ""
}, materials = null) {
const THREE = context.THREE;
const wireframeMode = displayModeIsWireframe(displayMode);
const depthTest = edgeSettings?.depthTest === false ? false : true;
const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const color = wireframeMode
? (wireframeEdgeColor || edgeSettings?.color || baseTheme?.edge || "#18181b")
: (edgeSettings?.color || baseTheme?.edge || "#18181b");
if (wireframeMode && THREE) {
const opacity = Math.max(edgeOpacity, 0.9);
const material = new THREE.LineBasicMaterial({
color,
transparent: opacity < 0.999,
opacity,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const line = new THREE.LineSegments(geometry, material);
line.renderOrder = 4;
line.frustumCulled = false;
line.userData.partId = partId;
return { edgeMesh: line, edgeMaterial: material };
}
const line = createScreenSpaceLineSegmentsFromGeometry(context, geometry, {
color,
opacity: edgeOpacity,
lineWidth: Number.isFinite(Number(thickness)) ? Number(thickness) : 1,
renderOrder: 3,
depthTest,
depthWrite: false,
depthBias: topologyLineDepthBiasForWidth(thickness)
}, materials);
if (!line) {
return { edgeMesh: null, edgeMaterial: null };
}
line.userData.partId = partId;
return { edgeMesh: line, edgeMaterial: line.material };
}
export function createTopologyDisplayEdgeObject(context = {}, selectorRuntime, edgeSettings, baseTheme, materials = null) {
const includePartIds = normalizePartIds(edgeSettings?.includePartIds);
const excludePartIds = normalizePartIds(edgeSettings?.excludePartIds);
const focusedPartIds = normalizePartIds(edgeSettings?.focusedPartIds);
const highlightPartIds = normalizePartIds(edgeSettings?.highlightPartIds);
const edgeClassSettings = normalizeTopologyEdgeClassSettings(edgeSettings, baseTheme)
.filter((classSetting) => (
classSetting.classId === "feature" ||
runtimeHasVisibilityClassRows(selectorRuntime)
));
const visibilityClasses = edgeClassSettings.map((setting) => setting.classId);
const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const highlightOpacity = Number.isFinite(Number(edgeSettings?.highlightOpacity))
? clamp(Number(edgeSettings.highlightOpacity), 0, 1)
: edgeOpacity;
const dimmedOpacity = Number.isFinite(Number(edgeSettings?.dimmedOpacity))
? clamp(Number(edgeSettings.dimmedOpacity), 0, 1)
: edgeOpacity;
const thickness = Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: (baseTheme?.edgeThickness ?? 1);
const baseOptions = {
color: edgeSettings?.color || baseTheme?.edge || "#18181b",
lineWidth: thickness,
renderOrder: 3,
depthTest: edgeSettings?.depthTest === false ? false : true,
depthWrite: false,
depthBias: topologyLineDepthBiasForWidth(thickness)
};
const createLine = (positions, options) => (
createScreenSpaceLineSegments(context, positions, options, materials) ||
createBasicLineSegments(context, positions, options)
);
const createLineStrip = (positions, options = {}) => {
const lineWidth = Number.isFinite(Number(options.lineWidth)) ? Number(options.lineWidth) : 1;
if (lineWidth <= 1.05) {
return (
createBasicLineStrip(context, positions, options) ||
createScreenSpaceLineStrip(context, positions, options, materials)
);
}
return (
createScreenSpaceLineStrip(context, positions, options, materials) ||
createBasicLineStrip(context, positions, options)
);
};
const createClassGroup = (children) => {
const lines = children.filter(Boolean);
if (!lines.length) {
return null;
}
if (lines.length === 1) {
return lines[0];
}
const group = context.THREE ? new context.THREE.Group() : null;
if (!group) {
return lines[0];
}
for (const line of lines) {
group.add(line);
}
return group;
};
const createLineStripGroup = (polylines, options) => {
if (!context.THREE || !Array.isArray(polylines) || !polylines.length) {
return null;
}
const positionValueCount = polylines.reduce((sum, positions) => sum + (positions?.length || 0), 0);
if (
polylines.length > MAX_TOPOLOGY_LINE_STRIP_POLYLINES ||
positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES
) {
return null;
}
return createClassGroup(polylines.map((positions) => createLineStrip(positions, options)));
};
const createClassLine = (classSetting, options = {}) => {
const lineWidth = Number.isFinite(Number(options.lineWidth))
? Number(options.lineWidth)
: classSetting.thickness;
const filters = {
includePartIds: options.includePartIds || includePartIds,
excludePartIds: options.excludePartIds || excludePartIds,
visibilityClasses: [classSetting.classId]
};
const lineOptions = {
...baseOptions,
...options,
color: options.color || classSetting.color || baseOptions.color,
lineWidth,
opacity: Number.isFinite(Number(options.opacity)) ? options.opacity : classSetting.opacity,
depthBias: Number.isFinite(Number(options.depthBias))
? options.depthBias
: topologyLineDepthBiasForWidth(lineWidth, { visibilityClass: classSetting.classId })
};
const polylines = shouldBuildTopologyLineStrips(selectorRuntime, filters)
? buildTopologyDisplayEdgePolylines(selectorRuntime, filters)
: [];
const stripGroup = createLineStripGroup(polylines, lineOptions);
if (stripGroup) {
return stripGroup;
}
const positions = buildTopologyDisplayEdgePositions(selectorRuntime, {
...filters
});
if (!positions?.length) {
return null;
}
return createLine(positions, lineOptions);
};
if (highlightPartIds.length) {
const positions = buildTopologyDisplayEdgePositions(selectorRuntime, {
includePartIds: highlightPartIds,
visibilityClasses
});
if (!positions?.length) {
return null;
}
const line = createLine(positions, {
...baseOptions,
color: edgeSettings?.highlightColor || baseOptions.color,
opacity: highlightOpacity,
depthBias: Number.isFinite(Number(edgeSettings?.highlightDepthBias))
? clamp(Number(edgeSettings.highlightDepthBias), 0, 0.01)
: 0,
renderOrder: Number.isFinite(Number(edgeSettings?.highlightRenderOrder))
? Number(edgeSettings.highlightRenderOrder)
: 26
});
if (line) {
line.name = "TopologyDisplayEdgeHighlights";
line.userData.partId = "__topology_highlight__";
}
return line;
}
if (focusedPartIds.length && context.THREE) {
const group = new context.THREE.Group();
for (const classSetting of edgeClassSettings) {
const dimmedLine = createClassLine(classSetting, {
excludePartIds: focusedPartIds,
opacity: Math.min(dimmedOpacity, classSetting.opacity),
renderOrder: 3
});
const focusedLine = createClassLine(classSetting, {
includePartIds: focusedPartIds,
opacity: classSetting.opacity,
renderOrder: 4
});
if (dimmedLine) {
group.add(dimmedLine);
}
if (focusedLine) {
group.add(focusedLine);
}
}
if (!group.children.length) {
return null;
}
group.name = "TopologyDisplayEdges";
group.userData.partId = "__topology__";
return group;
}
const classLines = edgeClassSettings.map((classSetting) => createClassLine(classSetting));
const line = createClassGroup(classLines);
if (!line) {
return null;
}
line.name = "TopologyDisplayEdges";
line.userData.partId = "__topology__";
return line;
}
@@ -0,0 +1,522 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import {
TOPOLOGY_LINE_DEPTH_BIAS,
applyLineDepthBias,
createDisplayEdgeObject,
createTopologyDisplayEdgeObject,
lineSegmentPositionsFromGeometry,
syncLineMaterialOpacity,
syncScreenSpaceLineMaterialResolution,
topologyLineDepthBiasForWidth
} from "./renderEdges.js";
function edgeContext(materials = new Set()) {
return {
THREE,
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
registerScreenSpaceLineMaterial: (material) => materials.add(material),
unregisterScreenSpaceLineMaterial: (material) => materials.delete(material)
};
}
function twoPointGeometry() {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
0, 0, 0,
1, 0, 0
]), 3));
return geometry;
}
test("line segment extraction uses the geometry position buffer unchanged", () => {
const geometry = twoPointGeometry();
const positions = geometry.getAttribute("position").array;
assert.equal(lineSegmentPositionsFromGeometry(geometry), positions);
});
test("screen-space display edge creation registers material settings", () => {
const materials = new Set();
const { edgeMesh, edgeMaterial } = createDisplayEdgeObject(edgeContext(materials), {
geometry: twoPointGeometry(),
edgeSettings: {
color: "#123456",
opacity: 0.42,
thickness: 2
},
baseTheme: {
edge: "#000000",
edgeOpacity: 0.84
},
partId: "part-a",
displayMode: "solid",
thickness: 2
}, materials);
assert.equal(edgeMesh.userData.partId, "part-a");
assert.equal(edgeMaterial.opacity, 0.42);
assert.equal(edgeMaterial.linewidth, 2);
assert.equal(edgeMaterial.polygonOffset, true);
assert.equal(edgeMaterial.polygonOffsetFactor, 0);
assert.equal(edgeMaterial.polygonOffsetUnits, -5);
assert.equal(materials.has(edgeMaterial), true);
syncScreenSpaceLineMaterialResolution(materials, 640, 480);
assert.equal(edgeMaterial.resolution.x, 640);
assert.equal(edgeMaterial.resolution.y, 480);
});
test("screen-space display edges can render through surfaces", () => {
const { edgeMaterial } = createDisplayEdgeObject(edgeContext(), {
geometry: twoPointGeometry(),
edgeSettings: {
color: "#123456",
opacity: 0.42,
thickness: 2,
depthTest: false
},
baseTheme: {
edge: "#000000",
edgeOpacity: 0.84
},
partId: "part-a",
displayMode: "hidden_edges",
thickness: 2
});
assert.equal(edgeMaterial.depthTest, false);
});
test("wireframe display edges preserve high opacity and basic line material", () => {
const { edgeMesh, edgeMaterial } = createDisplayEdgeObject(edgeContext(), {
geometry: twoPointGeometry(),
edgeSettings: {
color: "#123456",
opacity: 0.2
},
baseTheme: {
edge: "#000000",
edgeOpacity: 0.84
},
partId: "wire-a",
displayMode: "wireframe",
wireframeEdgeColor: "#abcdef"
});
assert.equal(edgeMesh.userData.partId, "wire-a");
assert.equal(edgeMaterial.isLineBasicMaterial, true);
assert.equal(edgeMaterial.opacity, 0.9);
assert.equal(edgeMaterial.depthTest, false);
});
test("topology display edge helper builds filtered screen-space edges", () => {
const materials = new Set();
const line = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
proxy: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1])
}
},
{
color: "#654321",
opacity: 0.66,
thickness: 1.5
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(line.name, "TopologyDisplayEdges");
assert.equal(line.userData.partId, "__topology__");
assert.equal(line.material.opacity, 0.66);
assert.equal(line.material.linewidth, 1.5);
assert.equal(line.material.polygonOffset, true);
assert.equal(line.material.polygonOffsetUnits, -5);
});
test("topology display edge helper renders feature-class edges by default", () => {
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges: [
{ visibilityClass: "feature", segmentStart: 0, segmentCount: 1 },
{ visibilityClass: "tangent", segmentStart: 1, segmentCount: 1 },
{ visibilityClass: "seam", segmentStart: 2, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5]),
edgeIds: new Uint32Array([0, 1, 2])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.isLine, true);
assert.equal(line.geometry.getAttribute("position").count, 2);
});
test("topology display edge helper renders row-backed CAD edges as continuous strips", () => {
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges: [
{ visibilityClass: "feature", segmentStart: 0, segmentCount: 2 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2]),
edgeIds: new Uint32Array([0, 0])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.isLine, true);
assert.equal(line.geometry.getAttribute("position").count, 3);
});
test("topology display edge helper keeps large edge sets batched", () => {
const edgeCount = 1201;
const points = new Float32Array((edgeCount + 1) * 3);
const edgeIndices = new Uint32Array(edgeCount * 2);
const edgeIds = new Uint32Array(edgeCount);
const edges = [];
for (let index = 0; index < edgeCount; index += 1) {
points[index * 3] = index;
points[(index + 1) * 3] = index + 1;
edgeIndices[index * 2] = index;
edgeIndices[(index * 2) + 1] = index + 1;
edgeIds[index] = index;
edges.push({ visibilityClass: "feature", segmentStart: index, segmentCount: 1 });
}
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges,
proxy: {
edgePositions: points,
edgeIndices,
edgeIds
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.type, "LineSegments2");
assert.notEqual(line.isLine2, true);
assert.equal(line.geometry.attributes.instanceStart.count, edgeCount);
});
test("topology display edge helper renders enabled classified edge styles", () => {
const group = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges: [
{ visibilityClass: "feature", segmentStart: 0, segmentCount: 1 },
{ visibilityClass: "tangent", segmentStart: 1, segmentCount: 1 },
{ visibilityClass: "seam", segmentStart: 2, segmentCount: 1 },
{ visibilityClass: "degenerate", segmentStart: 3, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0,
6, 0, 0,
7, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7]),
edgeIds: new Uint32Array([0, 1, 2, 3])
}
},
{
color: "#132232",
opacity: 0.5,
classes: {
feature: { color: "#101010", opacity: 1, thickness: 1 },
tangent: { color: "#224466", opacity: 0.32, thickness: 0.75 },
seam: { color: "#6688aa", opacity: 0.7, thickness: 1 },
degenerate: { color: "#aa8844", opacity: 1, thickness: 0 }
}
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(group.children.length, 3);
assert.equal(group.children[0].material.linewidth, 1);
assert.equal(group.children[0].material.color.getHexString(), "101010");
assert.equal(group.children[0].material.opacity, 1);
assert.equal(group.children[1].material.linewidth, 0.75);
assert.equal(group.children[1].material.color.getHexString(), "224466");
assert.equal(group.children[1].material.opacity, 0.32);
assert.equal(group.children[1].material.polygonOffsetUnits, -6);
assert.equal(group.children[2].material.linewidth, 1);
assert.equal(group.children[2].material.color.getHexString(), "6688aa");
assert.equal(group.children[2].material.opacity, 0.7);
assert.equal(group.children[2].material.polygonOffsetUnits, -6);
});
test("thick topology display edges get stronger depth separation", () => {
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
proxy: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1])
}
},
{
thickness: 6
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(topologyLineDepthBiasForWidth(1), TOPOLOGY_LINE_DEPTH_BIAS);
assert.equal(topologyLineDepthBiasForWidth(6), 0.00575);
assert.equal(topologyLineDepthBiasForWidth(1, { visibilityClass: "tangent" }), 0.006);
assert.equal(topologyLineDepthBiasForWidth(6, { visibilityClass: "seam" }), 0.00725);
assert.equal(line.material.polygonOffsetUnits, -6);
});
test("topology display edge helper splits dimmed and focused inspection edges", () => {
const materials = new Set();
const group = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
edges: [
{ occurrenceId: "part-a", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", segmentStart: 1, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3]),
edgeIds: new Uint32Array([0, 1])
}
},
{
color: "#654321",
opacity: 0.66,
dimmedOpacity: 0.035,
focusedPartIds: ["part-a"]
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(group.name, "TopologyDisplayEdges");
assert.equal(group.children.length, 2);
assert.equal(group.children[0].material.opacity, 0.035);
assert.equal(group.children[1].material.opacity, 0.66);
});
test("topology display edge helper renders highlighted node edges with theme settings", () => {
const materials = new Set();
const line = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
edges: [
{ occurrenceId: "part-a", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", segmentStart: 1, segmentCount: 1 },
{ occurrenceId: "part-a.child", segmentStart: 2, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5]),
edgeIds: new Uint32Array([0, 1, 2])
}
},
{
color: "#222222",
opacity: 0.48,
thickness: 2.25,
highlightPartIds: ["part-a"],
highlightColor: "#4f9dff"
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(line.name, "TopologyDisplayEdgeHighlights");
assert.equal(line.userData.partId, "__topology_highlight__");
assert.equal(line.material.color.getHexString(), "4f9dff");
assert.equal(line.material.opacity, 0.48);
assert.equal(line.material.linewidth, 2.25);
assert.equal(line.renderOrder, 26);
assert.doesNotMatch(line.material.customProgramCacheKey(), /lineDepthBias/);
assert.equal(line.geometry.attributes.instanceStart.count, 2);
assert.equal(line.geometry.attributes.instanceEnd.count, 2);
});
test("line depth bias uses fixed-function polygon offset", () => {
const material = new THREE.LineBasicMaterial();
applyLineDepthBias(material, 0.0003);
const shader = {
uniforms: {},
vertexShader: "void main() {\n#include <logdepthbuf_vertex>\n}"
};
material.onBeforeCompile(shader, {});
assert.equal(material.polygonOffset, true);
assert.equal(material.polygonOffsetFactor, 0);
assert.equal(material.polygonOffsetUnits, -1);
assert.deepEqual(shader.uniforms, {});
assert.doesNotMatch(shader.vertexShader, /lineDepthBias/);
});
test("topology display edge helper renders raw CAD edge segments as one object", () => {
const materials = new Set();
const line = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(line.name, "TopologyDisplayEdges");
assert.equal(line.userData.partId, "__topology__");
assert.equal(materials.size, 1);
});
test("topology display edge helper falls back to basic line strips", () => {
const context = edgeContext();
delete context.Line2;
delete context.LineGeometry;
delete context.LineSegments2;
delete context.LineSegmentsGeometry;
const line = createTopologyDisplayEdgeObject(
context,
{
edges: [
{
flags: 0,
segmentStart: 0,
segmentCount: 2
}
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2]),
edgeIds: new Uint32Array([0, 0])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.isLine, true);
assert.equal(line.geometry.getAttribute("position").count, 3);
});
test("line material opacity helper clamps transparency consistently", () => {
const material = new THREE.LineBasicMaterial({ opacity: 1, transparent: false });
syncLineMaterialOpacity(material, 0.25);
assert.equal(material.opacity, 0.25);
assert.equal(material.transparent, true);
syncLineMaterialOpacity(material, 5);
assert.equal(material.opacity, 1);
assert.equal(material.transparent, false);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
buildModel
} from "./cadScene.js";
import {
modelOptionsForRenderJob,
projectedVisibleGeometryFrame,
renderJobContext,
renderMeshJob
} from "./renderMeshScene.js";
function twoPartMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [3, 1, 0]
},
parts: [
{
id: "left",
name: "Left",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "right",
name: "Right",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
}
]
};
}
test("renderMeshJob list capture uses buildModel selection", async () => {
const result = await renderMeshJob(twoPartMeshData(), {
mode: "list",
selection: {
focus: ["right"]
}
});
assert.equal(result.ok, true);
assert.equal(result.mode, "list");
assert.deepEqual(result.parts.map((part) => part.id), ["right"]);
assert.deepEqual(result.bounds, {
min: [2, 0, 0],
max: [3, 1, 0]
});
});
test("render view focus preserves full assembly while hide still filters", () => {
const focusedContext = renderJobContext(twoPartMeshData(), {
mode: "view",
selection: {
focus: ["right"]
}
});
const focused = buildModel(
THREE,
twoPartMeshData(),
modelOptionsForRenderJob(focusedContext, {
mode: "view",
selection: {
focus: ["right"]
}
})
);
assert.deepEqual(focused.displayRecords.map((record) => record.partId), ["left", "right"]);
assert.deepEqual(focused.bounds, {
min: [0, 0, 0],
max: [3, 1, 0]
});
focused.dispose();
const hiddenContext = renderJobContext(twoPartMeshData(), {
mode: "view",
selection: {
hide: ["left"]
}
});
const hidden = buildModel(
THREE,
twoPartMeshData(),
modelOptionsForRenderJob(hiddenContext, {
mode: "view",
selection: {
hide: ["left"]
}
})
);
assert.deepEqual(hidden.displayRecords.map((record) => record.partId), ["right"]);
assert.deepEqual(hidden.bounds, {
min: [2, 0, 0],
max: [3, 1, 0]
});
hidden.dispose();
});
test("projectedVisibleGeometryFrame fits actual vertices instead of sparse bounds", () => {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
-1, -1, 0,
1, -1, 0,
-1, 1, 0,
1, 1, 0
]), 3));
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial());
mesh.updateWorldMatrix(true, false);
const camera = new THREE.OrthographicCamera(-10, 10, 10, -10, 0.01, 100);
camera.position.set(0, 0, 10);
camera.up.set(0, 1, 0);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld(true);
const frame = projectedVisibleGeometryFrame([{ mesh }], camera);
assert.equal(frame.count, 4);
assert.equal(frame.centerX, 0);
assert.equal(frame.centerY, 0);
assert.equal(frame.spanX, 2);
assert.equal(frame.spanY, 2);
});
@@ -0,0 +1,223 @@
import {
fitCameraToModel
} from "./cadScene.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function addLight(scene, light, enabled = true) {
light.visible = enabled;
scene.add(light);
return light;
}
function applyViewportLighting(THREE, scene, theme = {}) {
const lighting = theme.lighting || {};
addLight(
scene,
new THREE.HemisphereLight(
lighting.hemisphere?.skyColor || "#ffffff",
lighting.hemisphere?.groundColor || "#101014",
toFiniteNumber(lighting.hemisphere?.intensity, 0)
),
lighting.hemisphere?.enabled === true
);
addLight(
scene,
new THREE.AmbientLight(
lighting.ambient?.color || "#ffffff",
toFiniteNumber(lighting.ambient?.intensity, 0.2)
),
lighting.ambient?.enabled !== false
);
const directionalLights = Array.isArray(lighting.directionalLights)
? lighting.directionalLights
: [lighting.directional || {}];
for (const config of directionalLights) {
const light = new THREE.DirectionalLight(
config.color || "#ffffff",
toFiniteNumber(config.intensity, 1)
);
const position = config.position || {};
light.position.set(
toFiniteNumber(position.x, 160),
toFiniteNumber(position.y, -140),
toFiniteNumber(position.z, 240)
);
light.castShadow = config.castShadow === true;
addLight(scene, light, config.enabled !== false);
}
}
function resolveViewportSize(hostElement, canvas, fallbackWidth = 1400, fallbackHeight = 900) {
const rect = hostElement?.getBoundingClientRect?.();
const width = Math.max(1, Math.floor(rect?.width || hostElement?.clientWidth || canvas?.clientWidth || fallbackWidth));
const height = Math.max(1, Math.floor(rect?.height || hostElement?.clientHeight || canvas?.clientHeight || fallbackHeight));
return { width, height };
}
function applyBackground(THREE, renderer, scene, theme = {}, { alpha = false } = {}) {
const background = theme.background || {};
if (alpha || background.type === "transparent") {
scene.background = null;
renderer.setClearColor(new THREE.Color("#000000"), 0);
return;
}
const color = background.solidColor || background.color || "#ffffff";
scene.background = new THREE.Color(color);
renderer.setClearColor(new THREE.Color(color), 1);
}
export function renderModel(THREE, model, options = {}) {
if (!THREE) {
throw new Error("renderModel requires THREE");
}
if (!model?.root) {
throw new Error("renderModel requires a model returned by buildModel");
}
const canvas = options.canvas || undefined;
const hostElement = options.hostElement || options.container || canvas?.parentElement || null;
const theme = options.theme || options.themeSettings || {};
const alpha = options.alpha === true;
const renderer = options.renderer || new THREE.WebGLRenderer({
canvas,
alpha,
antialias: options.antialias !== false,
powerPreference: options.powerPreference || "high-performance",
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
logarithmicDepthBuffer: options.logarithmicDepthBuffer !== false
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = options.shadows !== false;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = options.scene || new THREE.Scene();
applyBackground(THREE, renderer, scene, theme, { alpha });
applyViewportLighting(THREE, scene, theme);
scene.add(model.root);
const camera = options.camera || new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 10000);
const frame = {
direction: options.direction || options.view?.direction || [1, -1, 0.8],
up: options.up || options.view?.up || [0, 0, 1],
padding: toFiniteNumber(options.padding, 0.12),
scale: options.scale || options.sceneScale || "cad",
lockedHalfHeightScale: options.lockedHalfHeightScale || null
};
let disposed = false;
let rafId = 0;
let lastRenderTime = typeof performance !== "undefined" ? performance.now() : 0;
const resize = () => {
const { width, height } = resolveViewportSize(hostElement, renderer.domElement, options.width, options.height);
renderer.setPixelRatio(clamp(toFiniteNumber(options.pixelRatio, globalThis.devicePixelRatio || 1), 1, options.maxPixelRatio || 2));
renderer.setSize(width, height, false);
model.runtime?.syncScreenSpaceLineMaterials?.(width, height);
const fit = fitCameraToModel(THREE, camera, model.bounds, {
direction: frame.direction,
up: frame.up,
width,
height,
padding: frame.padding,
scale: frame.scale
});
if (frame.lockedHalfHeightScale) {
fitCameraToModel(THREE, camera, model.bounds, {
direction: frame.direction,
lockedHalfHeight: fit.halfHeight * toFiniteNumber(frame.lockedHalfHeightScale, 1),
up: frame.up,
width,
height,
padding: frame.padding,
scale: frame.scale
});
}
return { width, height, fit };
};
const render = () => {
renderer.render(scene, camera);
};
const step = () => {
if (disposed) {
return;
}
const now = typeof performance !== "undefined" ? performance.now() : lastRenderTime;
const deltaSeconds = Math.min(Math.max((now - lastRenderTime) / 1000, 0), 0.1);
lastRenderTime = now;
options.beforeRender?.({ deltaSeconds, viewport: api });
render();
rafId = globalThis.requestAnimationFrame?.(step) || 0;
};
const start = () => {
if (!rafId) {
lastRenderTime = typeof performance !== "undefined" ? performance.now() : 0;
rafId = globalThis.requestAnimationFrame?.(step) || 0;
}
};
const stop = () => {
if (rafId) {
globalThis.cancelAnimationFrame?.(rafId);
rafId = 0;
}
};
const resizeObserver = options.autoResize === false || typeof ResizeObserver !== "function" || !hostElement
? null
: new ResizeObserver(() => {
resize();
if (options.autoRender === false) {
render();
}
});
resizeObserver?.observe(hostElement);
const api = {
THREE,
model,
renderer,
scene,
camera,
ready: Promise.resolve(),
resize,
render,
start,
stop,
capturePng() {
render();
return renderer.domElement.toDataURL("image/png");
},
dispose() {
if (disposed) {
return;
}
disposed = true;
stop();
resizeObserver?.disconnect();
scene.remove(model.root);
if (options.disposeModel !== false) {
model.dispose?.();
}
if (!options.renderer) {
renderer.dispose();
}
}
};
resize();
if (options.autoStart === true) {
start();
} else if (options.autoRender !== false) {
render();
}
return api;
}
@@ -0,0 +1,573 @@
import * as THREE from "three";
import {
RENDER_CAMERA_PRESETS,
TOP_VIEW_UP,
WORLD_UP,
normalizeCameraZoom,
resolveCameraSnapshot,
resolveCameraView
} from "./camera.js";
import {
cloneThemeSettings,
DEFAULT_FLOOR_GRID_SETTINGS,
getEnvironmentPresetById,
MAX_FLOOR_GRID_DENSITY,
MIN_FLOOR_GRID_DENSITY,
THEME_FLOOR_MODES,
normalizeThemeSettings,
resolveThemeSettingsForColorMode
} from "./themeSettings.js";
import {
createCadWebGlRenderer
} from "./webglRenderer.js";
export const RENDER_SCENE_SCALE = Object.freeze({
CAD: "cad",
URDF: "urdf"
});
export { TOP_VIEW_UP, WORLD_UP };
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
export const SHARED_RENDER_OPTION_KEYS = Object.freeze([
"themeSettings",
"display",
"camera",
"framing",
"sceneScale",
"clip",
"selection",
"floor",
"background",
"lighting",
"outputSize",
"renderScale"
]);
/**
* SharedRenderOptions is the policy-free shape consumed by lower-level render
* helpers after snapshot or viewer code has already chosen defaults.
*
* @typedef {Object} SharedRenderOptions
* @property {Object|null} themeSettings Explicit, normalized appearance settings.
* @property {Object|null} display Explicit display settings.
* @property {Object|null} camera Explicit camera/view state.
* @property {Object|null} framing Explicit framing settings.
* @property {string|null} sceneScale Explicit CAD/URDF scene scale.
* @property {Object|null} clip Explicit clip settings.
* @property {Object|null} selection Explicit part/selection settings.
* @property {Object|null} floor Explicit floor settings.
* @property {Object|null} background Explicit background settings.
* @property {Object|null} lighting Explicit lighting settings.
* @property {Object|null} outputSize Explicit output width/height.
* @property {number|null} renderScale Explicit render scale.
*/
export function createSharedRenderOptions(explicitOptions = {}) {
const result = {};
for (const key of SHARED_RENDER_OPTION_KEYS) {
result[key] = Object.prototype.hasOwnProperty.call(explicitOptions, key)
? explicitOptions[key]
: null;
}
return result;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
export function normalizeRenderSceneScale(value) {
return value === RENDER_SCENE_SCALE.URDF ? RENDER_SCENE_SCALE.URDF : RENDER_SCENE_SCALE.CAD;
}
export function renderSceneScaleSettings(value, settingsByScale) {
return settingsByScale[normalizeRenderSceneScale(value)];
}
export function inferRenderSceneScale({
explicit = "",
kind = "",
parts = []
} = {}) {
const normalizedExplicit = String(explicit || "").trim().toLowerCase();
if (normalizedExplicit) {
return normalizeRenderSceneScale(normalizedExplicit);
}
const normalizedKind = String(kind || "").trim().toLowerCase();
if (normalizedKind === "urdf" || normalizedKind === "srdf" || normalizedKind === "sdf") {
return RENDER_SCENE_SCALE.URDF;
}
return (Array.isArray(parts) ? parts : []).some((part) => String(part?.linkName || "").trim())
? RENDER_SCENE_SCALE.URDF
: RENDER_SCENE_SCALE.CAD;
}
export function resolveAppearanceJobConfig(job = {}, { defaultThemeId = "workbench" } = {}) {
if (typeof job.appearance === "string") {
return {
themeId: job.appearance,
settings: null
};
}
if (job.appearance && typeof job.appearance === "object" && !Array.isArray(job.appearance)) {
return {
themeId: defaultThemeId,
settings: job.appearance
};
}
return {
themeId: defaultThemeId,
settings: null
};
}
export function resolveAppearanceSettings(job = {}, { defaultThemeId = "workbench" } = {}) {
const appearance = resolveAppearanceJobConfig(job, { defaultThemeId });
const themeSettings = cloneThemeSettings(appearance.themeId || defaultThemeId);
const normalized = normalizeThemeSettings(appearance.settings || themeSettings);
return typeof job.appearance === "string"
? resolveThemeSettingsForColorMode(normalized, { prefersDark: false })
: normalized;
}
export function resolveRenderView(camera = "iso", viewPresets = RENDER_VIEW_PRESETS, {
strict = false
} = {}) {
return resolveCameraView(camera, {
presets: viewPresets,
strict
});
}
export function boundsFromVertices(vertices) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let index = 0; index + 2 < (vertices?.length || 0); index += 3) {
const x = Number(vertices[index]);
const y = Number(vertices[index + 1]);
const z = Number(vertices[index + 2]);
if (![x, y, z].every(Number.isFinite)) {
continue;
}
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) {
return { min: [0, 0, 0], max: [1, 1, 1] };
}
return { min, max };
}
export function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
export function colorTextureFromBackground(background, width, height) {
if (background.type === "solid") {
return new THREE.Color(background.solidColor);
}
const canvas = document.createElement("canvas");
canvas.width = Math.max(2, width);
canvas.height = Math.max(2, height);
const context = canvas.getContext("2d");
if (background.type === "radial") {
const gradient = context.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.max(canvas.width, canvas.height) / 1.35
);
gradient.addColorStop(0, background.radialInner);
gradient.addColorStop(1, background.radialOuter);
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
} else {
const angle = ((toFiniteNumber(background.linearAngle, 135) - 90) * Math.PI) / 180;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const length = Math.hypot(canvas.width, canvas.height);
const dx = Math.cos(angle) * length / 2;
const dy = Math.sin(angle) * length / 2;
const gradient = context.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
gradient.addColorStop(0, background.linearStart);
gradient.addColorStop(1, background.linearEnd);
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
export async function applyEnvironment(scene, themeSettings, warnings = []) {
const environment = themeSettings.environment || {};
if (!environment.enabled) {
scene.environment = null;
return;
}
const preset = getEnvironmentPresetById(environment.presetId);
const textureUrl = String(preset?.url || "").trim();
if (!textureUrl) {
return;
}
try {
const loader = new THREE.TextureLoader();
if (typeof loader.setCrossOrigin === "function") {
loader.setCrossOrigin("anonymous");
}
const texture = await loader.loadAsync(textureUrl);
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.colorSpace = THREE.SRGBColorSpace;
scene.environment = texture;
if (scene.environmentRotation?.set) {
scene.environmentRotation.set(0, toFiniteNumber(environment.rotationY), 0);
}
if (environment.useAsBackground) {
scene.background = texture;
}
} catch {
warnings.push(`Environment preset unavailable: ${preset?.label || environment.presetId || "default"}`);
}
}
export function applyLighting(scene, themeSettings) {
const lighting = themeSettings.lighting || {};
const addIfEnabled = (light, enabled) => {
light.visible = enabled;
scene.add(light);
};
addIfEnabled(
new THREE.HemisphereLight(
lighting.hemisphere?.skyColor || "#ffffff",
lighting.hemisphere?.groundColor || "#101014",
toFiniteNumber(lighting.hemisphere?.intensity, 1)
),
lighting.hemisphere?.enabled !== false
);
addIfEnabled(
new THREE.AmbientLight(lighting.ambient?.color || "#ffffff", toFiniteNumber(lighting.ambient?.intensity, 0.2)),
lighting.ambient?.enabled !== false
);
const directional = new THREE.DirectionalLight(
lighting.directional?.color || "#ffffff",
toFiniteNumber(lighting.directional?.intensity, 1)
);
directional.position.set(
toFiniteNumber(lighting.directional?.position?.x, 160),
toFiniteNumber(lighting.directional?.position?.y, -140),
toFiniteNumber(lighting.directional?.position?.z, 240)
);
directional.castShadow = true;
addIfEnabled(directional, lighting.directional?.enabled !== false);
const spot = new THREE.SpotLight(
lighting.spot?.color || "#ffffff",
toFiniteNumber(lighting.spot?.intensity, 0),
toFiniteNumber(lighting.spot?.distance, 0),
toFiniteNumber(lighting.spot?.angle, Math.PI / 6)
);
spot.position.set(
toFiniteNumber(lighting.spot?.position?.x, 160),
toFiniteNumber(lighting.spot?.position?.y, -120),
toFiniteNumber(lighting.spot?.position?.z, 140)
);
addIfEnabled(spot, lighting.spot?.enabled === true);
scene.add(spot.target);
const point = new THREE.PointLight(
lighting.point?.color || "#ffffff",
toFiniteNumber(lighting.point?.intensity, 0),
toFiniteNumber(lighting.point?.distance, 0)
);
point.position.set(
toFiniteNumber(lighting.point?.position?.x, -120),
toFiniteNumber(lighting.point?.position?.y, 80),
toFiniteNumber(lighting.point?.position?.z, 140)
);
addIfEnabled(point, lighting.point?.enabled === true);
}
export function addFloor(scene, bounds, themeSettings, sceneScale, settingsByScale) {
const floor = themeSettings.floor || {};
const mode = floor.mode || THEME_FLOOR_MODES.STAGE;
const floorEnabled = floor.enabled === true || (
!Object.hasOwn(floor, "enabled")
&& mode !== THEME_FLOOR_MODES.NONE
&& mode !== THEME_FLOOR_MODES.GRID
);
const gridSettings = floor.grid && typeof floor.grid === "object" && !Array.isArray(floor.grid)
? floor.grid
: {};
const gridEnabled = gridSettings.enabled === true || mode === THEME_FLOOR_MODES.GRID;
if (!floorEnabled && !gridEnabled) {
return;
}
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const { center, radius } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const minZ = Array.isArray(bounds?.min) ? toFiniteNumber(bounds.min[2]) : 0;
const gridSize = Math.max(radius * 3, settings.minFloorSize);
if (gridEnabled) {
const gridDensity = clamp(
toFiniteNumber(gridSettings.density ?? floor.gridDensity, DEFAULT_FLOOR_GRID_SETTINGS.gridDensity),
MIN_FLOOR_GRID_DENSITY,
MAX_FLOOR_GRID_DENSITY
);
const grid = new THREE.GridHelper(
gridSize,
Math.max(8, Math.round(28 * gridDensity)),
gridSettings.centerColor || floor.gridCenterColor || floor.color || DEFAULT_FLOOR_GRID_SETTINGS.gridCenterColor,
gridSettings.cellColor || floor.gridCellColor || floor.color || DEFAULT_FLOOR_GRID_SETTINGS.gridCellColor
);
const materials = Array.isArray(grid.material) ? grid.material : [grid.material];
for (const material of materials) {
material.transparent = true;
material.opacity = clamp(
toFiniteNumber(gridSettings.opacity ?? floor.gridOpacity, DEFAULT_FLOOR_GRID_SETTINGS.gridOpacity),
0,
1
);
material.depthWrite = false;
material.toneMapped = false;
}
grid.rotation.x = Math.PI / 2;
grid.position.set(center.x, center.y, minZ - 0.02);
scene.add(grid);
}
if (!floorEnabled) {
return;
}
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(gridSize, gridSize),
new THREE.ShadowMaterial({
color: floor.color || "#f1f5f9",
opacity: clamp(toFiniteNumber(floor.shadowOpacity, 0.24), 0, 1)
})
);
plane.rotation.x = 0;
plane.position.set(center.x, center.y, minZ - 0.03);
plane.receiveShadow = true;
scene.add(plane);
}
export function boundsCorners(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
return [
[min[0], min[1], min[2]],
[min[0], min[1], max[2]],
[min[0], max[1], min[2]],
[min[0], max[1], max[2]],
[max[0], min[1], min[2]],
[max[0], min[1], max[2]],
[max[0], max[1], min[2]],
[max[0], max[1], max[2]]
].map((corner) => new THREE.Vector3(
toFiniteNumber(corner[0]),
toFiniteNumber(corner[1]),
toFiniteNumber(corner[2])
));
}
export function framePadding(job = {}) {
const rawPadding = job.render?.padding ?? job.render?.paddingPercent ?? job.padding ?? job.paddingPercent;
return clamp(toFiniteNumber(rawPadding, 0.04), 0, 0.15);
}
export function frameHalfHeightForView(view, bounds, width, height, padding, sceneScale, settingsByScale) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedView = resolveRenderView(view, RENDER_VIEW_PRESETS, { strict: false });
const direction = new THREE.Vector3(...resolvedView.direction).normalize();
const up = new THREE.Vector3(...resolvedView.up).normalize();
const right = new THREE.Vector3().crossVectors(direction, up).normalize();
const screenUp = new THREE.Vector3().crossVectors(right, direction).normalize();
const corners = boundsCorners(bounds);
const xs = corners.map((corner) => corner.dot(right));
const ys = corners.map((corner) => corner.dot(screenUp));
const spanX = Math.max(Math.max(...xs) - Math.min(...xs), settings.minBoundsSpan);
const spanY = Math.max(Math.max(...ys) - Math.min(...ys), settings.minBoundsSpan);
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const safeContentScale = Math.max(1 - (padding * 2), 0.1);
return Math.max(
spanY / (2 * safeContentScale),
spanX / (2 * aspect * safeContentScale),
settings.minBoundsSpan / 2
) / normalizeCameraZoom(resolvedView.zoom, 1);
}
export function fitOrthographicCamera(camera, view, bounds, width, height, {
lockedHalfHeight = null,
padding = 0.12,
sceneScale = RENDER_SCENE_SCALE.CAD,
settingsByScale
} = {}) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedCamera = resolveCameraSnapshot(view, bounds, {
sceneScale,
settingsByScale,
strict: false
});
const target = new THREE.Vector3(...resolvedCamera.target);
camera.position.set(...resolvedCamera.position);
camera.up.set(...resolvedCamera.up);
camera.lookAt(target);
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const halfHeight = lockedHalfHeight || frameHalfHeightForView(resolvedCamera.view, bounds, width, height, padding, sceneScale, settingsByScale);
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.near = 0.01;
camera.far = Math.max(resolvedCamera.radius * 12, settings.minCameraFar);
camera.updateProjectionMatrix();
return resolvedCamera;
}
export function fitPerspectiveCamera(camera, cameraSpec, bounds, width, height, {
sceneScale = RENDER_SCENE_SCALE.CAD,
settingsByScale,
strict = true
} = {}) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedCamera = resolveCameraSnapshot(cameraSpec, bounds, {
sceneScale,
settingsByScale,
strict
});
camera.aspect = Math.max(width / Math.max(height, 1), 0.01);
camera.position.set(...resolvedCamera.position);
camera.up.set(...resolvedCamera.up);
camera.zoom = normalizeCameraZoom(resolvedCamera.zoom, 1);
camera.near = Math.max(resolvedCamera.radius / 1200, 0.01);
camera.far = Math.max(resolvedCamera.radius * 600, settings.minCameraFar, 2000);
camera.lookAt(new THREE.Vector3(...resolvedCamera.target));
camera.updateProjectionMatrix();
return resolvedCamera;
}
export function lockedFrameHalfHeight(outputs, bounds, width, height, job, sceneScale, settingsByScale) {
const padding = framePadding(job);
return Math.max(
...outputs.map((output) => {
const outputWidth = toFiniteNumber(output.width, width);
const outputHeight = toFiniteNumber(output.height, height);
return frameHalfHeightForView(resolveRenderView(output.camera || job.camera || "iso"), bounds, outputWidth, outputHeight, padding, sceneScale, settingsByScale);
}),
renderSceneScaleSettings(sceneScale, settingsByScale).minBoundsSpan / 2
);
}
export function outputSize(output, job) {
return {
width: Math.max(1, Math.floor(toFiniteNumber(output.width, job.width || 1400))),
height: Math.max(1, Math.floor(toFiniteNumber(output.height, job.height || 900)))
};
}
export function configurePngRenderer(width, height, job, themeSettings, {
defaultRenderScale = 1
} = {}) {
const renderer = createCadWebGlRenderer(THREE, {
preserveDrawingBuffer: true
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = Math.max(toFiniteNumber(themeSettings.lighting?.toneMappingExposure, 1), 0.05);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(clamp(toFiniteNumber(job.render?.renderScale, job.renderScale || defaultRenderScale), 1, 3));
renderer.setSize(width, height, false);
document.body.innerHTML = "";
document.body.appendChild(renderer.domElement);
return renderer;
}
export function dataUrlFromRenderer(renderer, mime = "image/png") {
return renderer.domElement.toDataURL(mime);
}
export function shouldBurnInViewLabels(job = {}) {
return typeof job.render?.viewLabels === "boolean"
? job.render.viewLabels
: typeof job.viewLabels === "boolean"
? job.viewLabels
: false;
}
export function drawBurnedInLabel(context, label, width, height, {
corner = "top-left",
fill = "#111827",
background = "rgba(255, 255, 255, 0.9)",
border = "rgba(17, 24, 39, 0.42)"
} = {}) {
const text = String(label || "").trim();
if (!text) {
return;
}
const safeWidth = Math.max(width, 1);
const safeHeight = Math.max(height, 1);
const fontSize = Math.max(18, Math.min(Math.round(safeWidth * 0.018), 32));
const padX = Math.round(fontSize * 0.72);
const padY = Math.round(fontSize * 0.42);
context.save();
context.font = `700 ${fontSize}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`;
context.textBaseline = "top";
const metrics = context.measureText(text);
const boxWidth = Math.ceil(metrics.width + padX * 2);
const boxHeight = Math.ceil(fontSize + padY * 2);
const margin = Math.max(18, Math.round(Math.min(safeWidth, safeHeight) * 0.024));
const x = corner.includes("right") ? safeWidth - margin - boxWidth : margin;
const y = corner.includes("bottom") ? safeHeight - margin - boxHeight : margin;
context.fillStyle = background;
context.strokeStyle = border;
context.lineWidth = Math.max(1, Math.round(fontSize * 0.06));
context.beginPath();
context.roundRect(x, y, boxWidth, boxHeight, Math.round(fontSize * 0.28));
context.fill();
context.stroke();
context.fillStyle = fill;
context.fillText(text, x + padX, y + padY);
context.restore();
}
export function rendererDataUrlWithOptionalLabel(renderer, label, job) {
if (!shouldBurnInViewLabels(job)) {
return dataUrlFromRenderer(renderer);
}
const source = renderer.domElement;
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const context = canvas.getContext("2d");
context.drawImage(source, 0, 0);
drawBurnedInLabel(context, label, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
cloneThemeSettings,
normalizeThemeSettings,
resolveThemeSettingsForColorMode
} from "./themeSettings.js";
import {
addFloor,
createSharedRenderOptions,
RENDER_SCENE_SCALE,
boundsFromVertices,
centerAndRadiusFromBounds,
fitOrthographicCamera,
frameHalfHeightForView,
framePadding,
inferRenderSceneScale,
outputSize,
resolveRenderView,
resolveAppearanceJobConfig,
resolveAppearanceSettings
} from "./renderOptions.js";
const SCALE_SETTINGS = Object.freeze({
[RENDER_SCENE_SCALE.CAD]: Object.freeze({
minBoundsSpan: 1,
minModelRadius: 1,
minFloorSize: 100,
minCameraDistance: 10,
minCameraFar: 1000
}),
[RENDER_SCENE_SCALE.URDF]: Object.freeze({
minBoundsSpan: 0.05,
minModelRadius: 0.05,
minFloorSize: 0.05,
minCameraDistance: 0.5,
minCameraFar: 10
})
});
function assertClose(actual, expected, epsilon = 1e-6) {
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} !== ${expected}`);
}
test("shared render options preserve explicit caller-owned values without defaults", () => {
const options = createSharedRenderOptions({
displayMode: "wireframe",
background: false,
renderScale: 0
});
assert.equal(options.themeSettings, null);
assert.equal(options.display, null);
assert.equal(Object.hasOwn(options, "displayMode"), false);
assert.equal(options.background, false);
assert.equal(options.renderScale, 0);
});
test("appearance resolution uses saved theme ids or direct appearance settings", () => {
assert.deepEqual(
resolveAppearanceSettings({}, { defaultThemeId: "workbench" }),
normalizeThemeSettings(cloneThemeSettings("workbench"))
);
assert.deepEqual(
resolveAppearanceSettings({ appearance: "dark" }, { defaultThemeId: "workbench" }),
resolveThemeSettingsForColorMode(cloneThemeSettings("dark"), { prefersDark: false })
);
assert.deepEqual(
resolveAppearanceJobConfig({
appearance: {
materials: { defaultColor: "#123456" }
}
}, { defaultThemeId: "workbench" }),
{
themeId: "workbench",
settings: { materials: { defaultColor: "#123456" } }
}
);
});
test("view presets and azimuth/elevation camera parsing remain stable", () => {
const top = resolveRenderView("top");
assert.equal(top.name, "top");
assert.deepEqual(top.direction, [0, 0, 1]);
assert.deepEqual(top.up, [0, 1, 0]);
const custom = resolveRenderView("45:30");
assert.equal(custom.name, "45:30");
assertClose(custom.direction[0], Math.SQRT1_2 * Math.cos(Math.PI / 6));
assertClose(custom.direction[1], -Math.SQRT1_2 * Math.cos(Math.PI / 6));
assertClose(custom.direction[2], 0.5);
});
test("scene scale inference, bounds, and camera framing are policy-free helpers", () => {
assert.equal(inferRenderSceneScale({ explicit: "urdf" }), RENDER_SCENE_SCALE.URDF);
assert.equal(inferRenderSceneScale({ kind: "sdf" }), RENDER_SCENE_SCALE.URDF);
assert.equal(inferRenderSceneScale({ parts: [{ linkName: "base_link" }] }), RENDER_SCENE_SCALE.URDF);
assert.equal(inferRenderSceneScale({ kind: "glb", parts: [] }), RENDER_SCENE_SCALE.CAD);
const bounds = boundsFromVertices(new Float32Array([0, 0, 0, 2, 4, 6]));
assert.deepEqual(bounds, { min: [0, 0, 0], max: [2, 4, 6] });
const { center, radius } = centerAndRadiusFromBounds(bounds, RENDER_SCENE_SCALE.CAD, SCALE_SETTINGS);
assert.deepEqual(center.toArray(), [1, 2, 3]);
assertClose(radius, Math.sqrt(56) / 2);
const view = resolveRenderView("iso");
const halfHeight = frameHalfHeightForView(view, bounds, 800, 600, 0.12, RENDER_SCENE_SCALE.CAD, SCALE_SETTINGS);
assert.ok(halfHeight > 0);
const camera = new THREE.OrthographicCamera();
fitOrthographicCamera(camera, view, bounds, 800, 600, {
padding: 0.12,
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assertClose(camera.top, halfHeight);
assertClose(camera.bottom, -halfHeight);
assertClose(camera.left, -halfHeight * (800 / 600));
assertClose(camera.right, halfHeight * (800 / 600));
assert.equal(camera.near, 0.01);
assert.equal(camera.far >= 1000, true);
});
test("output sizing and padding helpers preserve snapshot fallback semantics", () => {
assert.deepEqual(outputSize({}, {}), { width: 1400, height: 900 });
assert.deepEqual(outputSize({ width: 320, height: 240 }, { width: 1400, height: 900 }), { width: 320, height: 240 });
assert.deepEqual(outputSize({}, { width: 1600, height: 1200 }), { width: 1600, height: 1200 });
assert.equal(framePadding({}), 0.04);
assert.equal(framePadding({ render: { padding: 0 } }), 0);
assert.equal(framePadding({ render: { padding: 0.02 } }), 0.02);
assert.equal(framePadding({ render: { padding: -0.02 } }), 0);
assert.equal(framePadding({ paddingPercent: 0.25 }), 0.15);
assert.equal(framePadding({ render: { paddingPercent: 0.13 } }), 0.13);
});
test("grid floors use theme line opacity and density for snapshots", () => {
const scene = new THREE.Scene();
addFloor(
scene,
{ min: [0, 0, 0], max: [10, 10, 10] },
normalizeThemeSettings({
floor: {
mode: "grid",
gridCenterColor: "#123456",
gridCellColor: "#abcdef",
gridOpacity: 0.37,
gridDensity: 2
}
}),
RENDER_SCENE_SCALE.CAD,
SCALE_SETTINGS
);
const grid = scene.children[0];
const materials = Array.isArray(grid.material) ? grid.material : [grid.material];
assert.equal(grid.type, "GridHelper");
assert.equal(grid.geometry.getAttribute("position").count, 4 * (56 + 1));
assert.equal(materials[0].opacity, 0.37);
assert.equal(materials[0].transparent, true);
assert.equal(materials[0].depthWrite, false);
});
test("snapshot floors render grid independently from the stage floor", () => {
const scene = new THREE.Scene();
addFloor(
scene,
{ min: [0, 0, 0], max: [10, 10, 10] },
normalizeThemeSettings({
floor: {
mode: "stage",
enabled: true,
color: "#ddeeff",
shadowOpacity: 0.25,
grid: {
enabled: true,
centerColor: "#123456",
cellColor: "#abcdef",
opacity: 0.37,
density: 2
}
}
}),
RENDER_SCENE_SCALE.CAD,
SCALE_SETTINGS
);
const grid = scene.children.find((child) => child.type === "GridHelper");
const plane = scene.children.find((child) => child.type === "Mesh");
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
assert.ok(grid);
assert.ok(plane);
assert.equal(gridMaterials[0].opacity, 0.37);
assert.equal(plane.receiveShadow, true);
assert.equal(plane.material.opacity, 0.25);
});
@@ -0,0 +1,224 @@
import {
assemblyUsesSelfContainedMesh,
buildSelfContainedAssemblyMeshData
} from "../lib/assembly/meshData.js";
import { buildMeshDataFromGlbBuffer } from "../lib/render/glbMeshData.js";
import {
loadRenderDisplayEdgeBundle,
loadRenderGlb,
loadRenderSelectorBundle,
loadRenderTopologyIndex
} from "../lib/stepRenderAssetClient.js";
import {
buildDisplayEdgeRuntime,
buildSelectorRuntime
} from "../lib/selectors/runtime.js";
import {
loadStepModuleDefinition
} from "./stepModule.js";
import {
hasStepParameterRenderValues,
normalizeStepParameterRenderValues,
stepParameterRenderFrameState,
stepParameterRenderFrameValues
} from "./stepParameters.js";
export const SOURCE_KIND = Object.freeze({
STEP: "step",
STP: "stp",
GLB: "glb",
STL: "stl",
THREE_MF: "3mf",
UNKNOWN: "unknown"
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeKind(value = "") {
const kind = String(value || "").trim().toLowerCase();
if (kind === "step" || kind === "stp" || kind === "glb" || kind === "stl" || kind === "3mf") {
return kind;
}
return SOURCE_KIND.UNKNOWN;
}
function sourceKindFromUrl(url = "") {
const pathname = String(url || "").split(/[?#]/, 1)[0].toLowerCase();
if (pathname.endsWith(".step")) {
return SOURCE_KIND.STEP;
}
if (pathname.endsWith(".stp")) {
return SOURCE_KIND.STP;
}
if (pathname.endsWith(".glb") || pathname.endsWith(".gltf")) {
return SOURCE_KIND.GLB;
}
if (pathname.endsWith(".stl")) {
return SOURCE_KIND.STL;
}
if (pathname.endsWith(".3mf")) {
return SOURCE_KIND.THREE_MF;
}
return SOURCE_KIND.UNKNOWN;
}
export function sourceIsStep(sourceOrKind) {
const kind = typeof sourceOrKind === "string" ? sourceOrKind : sourceOrKind?.kind;
const normalized = normalizeKind(kind);
return normalized === SOURCE_KIND.STEP || normalized === SOURCE_KIND.STP;
}
function assertStepOnlyOption(kind, value, label) {
if (value === undefined || value === null) {
return;
}
if (!sourceIsStep(kind)) {
throw new Error(`${label} is supported only for STEP/STP sources`);
}
}
async function loadStepMeshFromGlb(glbUrl) {
const meshData = await loadRenderGlb(glbUrl);
const topology = await loadRenderTopologyIndex(glbUrl);
if (assemblyUsesSelfContainedMesh(topology)) {
return buildSelfContainedAssemblyMeshData(topology, meshData);
}
return meshData;
}
async function loadSelectorRuntime(glbUrl, { cadPath = "" } = {}) {
if (!glbUrl) {
return null;
}
try {
const selectorBundle = await loadRenderSelectorBundle(glbUrl);
return buildSelectorRuntime(selectorBundle, {
copyCadPath: cadPath
});
} catch {
return null;
}
}
async function loadDisplayEdgeRuntime(glbUrl) {
if (!glbUrl) {
return null;
}
try {
return buildDisplayEdgeRuntime(await loadRenderDisplayEdgeBundle(glbUrl));
} catch {
return null;
}
}
async function loadMeshDataFromUrl(url, kind) {
if (sourceIsStep(kind)) {
return loadStepMeshFromGlb(url);
}
if (kind === SOURCE_KIND.GLB) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load GLB source: HTTP ${response.status}`);
}
return buildMeshDataFromGlbBuffer(await response.arrayBuffer());
}
throw new Error(`Unsupported render source kind: ${kind || SOURCE_KIND.UNKNOWN}`);
}
async function loadStepParameters({
kind,
stepParameters,
stepParameterUrl,
cadPath,
selectorRuntime
}) {
assertStepOnlyOption(kind, stepParameters, "stepParameters");
assertStepOnlyOption(kind, stepParameterUrl, "stepParameterUrl");
const explicit = hasStepParameterRenderValues(stepParameters);
if (!stepParameterUrl) {
if (!explicit) {
return null;
}
throw new Error("STEP render parameters require resolved.stepParameterUrl");
}
const definition = await loadStepModuleDefinition(stepParameterUrl, { cadPath });
const renderParameters = normalizeStepParameterRenderValues(definition, explicit ? stepParameters : {});
return {
definition,
renderParameters,
selectorRuntime,
cadPath: cadPath || definition.cadPath || "",
sourceUrl: stepParameterUrl
};
}
export function stepParameterFrameRuntime(stepParameterSource, frameIndex = 0) {
if (!stepParameterSource) {
return null;
}
const { definition, renderParameters } = stepParameterSource;
return {
definition,
selectorRuntime: stepParameterSource.selectorRuntime || null,
parameterValues: stepParameterRenderFrameValues(definition, renderParameters, frameIndex),
animationState: stepParameterRenderFrameState(renderParameters, frameIndex),
cadPath: stepParameterSource.cadPath || definition.cadPath || "",
sourceUrl: stepParameterSource.sourceUrl || definition.url || ""
};
}
export async function loadSource(input, options = {}) {
const inputObject = isObject(input) ? input : {};
if (Object.prototype.hasOwnProperty.call(inputObject, "params")) {
throw new Error("Render source jobs use stepParameters; params is no longer a shared render API field");
}
const resolved = isObject(inputObject.resolved) ? inputObject.resolved : {};
const explicitMeshData = inputObject.meshData || options.meshData || (
inputObject.vertices && inputObject.indices ? inputObject : null
);
const rawKind = inputObject.kind || resolved.kind || options.kind || (
typeof input === "string" ? sourceKindFromUrl(input) : ""
);
const kind = normalizeKind(rawKind);
const stepParameters = inputObject.stepParameters ?? options.stepParameters;
const stepParameterUrl = String(
inputObject.stepParameterUrl || resolved.stepParameterUrl || options.stepParameterUrl || ""
).trim();
const cadPath = String(inputObject.cadPath || resolved.inputPath || options.cadPath || "").trim();
assertStepOnlyOption(kind, stepParameters, "stepParameters");
assertStepOnlyOption(kind, stepParameterUrl, "stepParameterUrl");
let meshData = explicitMeshData;
const glbUrl = String(inputObject.glbUrl || resolved.glbUrl || options.glbUrl || "").trim();
const url = String(typeof input === "string" ? input : inputObject.url || resolved.url || glbUrl || "").trim();
if (!meshData) {
if (!url) {
throw new Error("loadSource requires meshData, a source URL, or resolved.glbUrl");
}
meshData = await loadMeshDataFromUrl(sourceIsStep(kind) ? glbUrl || url : url, kind);
}
const selectorRuntime = inputObject.selectorRuntime || options.selectorRuntime || await loadSelectorRuntime(glbUrl || url, { cadPath });
const displayEdgeRuntime = inputObject.displayEdgeRuntime || options.displayEdgeRuntime || await loadDisplayEdgeRuntime(glbUrl || url);
const stepParameterSource = await loadStepParameters({
kind,
stepParameters,
stepParameterUrl,
cadPath,
selectorRuntime
});
return {
kind,
meshData,
selectorRuntime,
displayEdgeRuntime,
stepParameterSource,
resolved,
url,
glbUrl,
cadPath
};
}
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import test from "node:test";
import { loadSource } from "./source.js";
function meshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2]),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: []
};
}
async function withTempModule(callback) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "render-source-test-"));
try {
const modulePath = path.join(root, "part.step.mjs");
fs.writeFileSync(modulePath, `
export default {
manifest: {
schemaVersion: 1,
parameters: {
drive: { type: "number", min: 0, max: 360, default: 0 }
}
}
};
`);
return await callback(pathToFileURL(modulePath).href);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
test("loadSource rejects the removed shared params field", async () => {
await assert.rejects(
() => loadSource({
kind: "step",
meshData: meshData(),
params: { drive: 90 }
}),
/use stepParameters/
);
});
test("loadSource rejects STEP parameter options for non-STEP sources", async () => {
await assert.rejects(
() => loadSource({
kind: "glb",
meshData: meshData(),
stepParameters: { drive: 90 }
}),
/stepParameters is supported only for STEP\/STP sources/
);
await assert.rejects(
() => loadSource({
kind: "glb",
meshData: meshData(),
stepParameterUrl: "file:///tmp/part.step.mjs"
}),
/stepParameterUrl is supported only for STEP\/STP sources/
);
});
test("loadSource accepts STEP parameter sidecars for STEP sources", async () => {
await withTempModule(async (stepParameterUrl) => {
const source = await loadSource({
kind: "step",
meshData: meshData(),
cadPath: "part.step",
stepParameterUrl,
stepParameters: { drive: 90 }
});
assert.equal(source.kind, "step");
assert.equal(source.stepParameterSource.renderParameters.values.drive, 90);
assert.equal(source.stepParameterSource.cadPath, "part.step");
});
});
@@ -0,0 +1,512 @@
import {
buildCadRefToken,
normalizeCadPath,
normalizeCadRefSelectors,
parseCadRefSelector,
parseCadRefToken
} from "../lib/cadRefs.js";
export const STEP_MODULE_SCHEMA_VERSION = 1;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeString(value, fallback = "") {
const text = String(value ?? fallback).trim();
return text || fallback;
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeParameterType(value) {
const type = normalizeString(value, "number").toLowerCase();
if (["number", "boolean", "enum", "select", "color", "string", "button"].includes(type)) {
return type === "select" ? "enum" : type;
}
return "number";
}
function normalizeParameterOptions(value) {
return (Array.isArray(value) ? value : [])
.map((option) => {
if (isObject(option)) {
const valueText = normalizeString(option.value);
return valueText ? {
value: valueText,
label: normalizeString(option.label, valueText)
} : null;
}
const valueText = normalizeString(option);
return valueText ? { value: valueText, label: valueText } : null;
})
.filter(Boolean);
}
function normalizeParameterDefinition(id, rawDefinition) {
const raw = isObject(rawDefinition) ? rawDefinition : {};
const type = normalizeParameterType(raw.type);
const min = toFiniteNumber(raw.min, 0);
const max = Math.max(toFiniteNumber(raw.max, type === "number" ? 1 : min), min);
const options = normalizeParameterOptions(raw.options || raw.values);
const fallbackDefault = type === "boolean"
? false
: type === "color"
? "#ffffff"
: type === "enum"
? options[0]?.value || ""
: type === "string"
? ""
: 0;
return {
id,
type,
label: normalizeString(raw.label, id),
description: normalizeString(raw.description),
unit: normalizeString(raw.unit),
min,
max,
step: Math.max(toFiniteNumber(raw.step, type === "number" ? 0.01 : 1), 0),
defaultValue: normalizeParameterValue({ type, min, max, options }, raw.default ?? raw.defaultValue ?? fallbackDefault),
options
};
}
export function normalizeParameterValue(definition, value) {
const type = normalizeParameterType(definition?.type);
if (type === "boolean") {
return value === true;
}
if (type === "color") {
const color = normalizeString(value, normalizeString(definition?.defaultValue, "#ffffff"));
return HEX_COLOR_RE.test(color) ? color : "#ffffff";
}
if (type === "enum") {
const options = Array.isArray(definition?.options) ? definition.options : [];
const valueText = normalizeString(value, options[0]?.value || "");
return options.some((option) => option.value === valueText) ? valueText : (options[0]?.value || "");
}
if (type === "string") {
return String(value ?? "");
}
if (type === "button") {
return Math.max(0, Math.floor(toFiniteNumber(value, 0)));
}
const min = toFiniteNumber(definition?.min, 0);
const max = Math.max(toFiniteNumber(definition?.max, min), min);
return clamp(toFiniteNumber(value, toFiniteNumber(definition?.defaultValue, min)), min, max);
}
function normalizeParameters(value) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawDefinition]) => normalizeParameterDefinition(normalizeString(id), rawDefinition))
.filter((definition) => definition.id);
}
function inferCadPathFromStepModuleUrl(url) {
let pathname = String(url || "").split("?")[0].split("#")[0].replace(/\\/g, "/").trim();
if (!pathname) {
return "";
}
try {
pathname = new URL(pathname, "http://localhost").pathname;
} catch {
// Keep the raw path for relative module URLs.
}
const normalizedPath = pathname.replace(/^\/+|\/+$/g, "");
const slashIndex = normalizedPath.lastIndexOf("/");
const directory = slashIndex >= 0 ? normalizedPath.slice(0, slashIndex) : "";
const fileName = slashIndex >= 0 ? normalizedPath.slice(slashIndex + 1) : normalizedPath;
const match = fileName.match(/^\.?(.+)\.step\.js$/);
if (!match?.[1]) {
return "";
}
return normalizeCadPath([directory, match[1]].filter(Boolean).join("/"));
}
function normalizeRelativeStepPath(value) {
const rawPath = normalizeString(value).replace(/\\/g, "/");
if (!rawPath || rawPath.startsWith("/") || /^[A-Za-z]:\//.test(rawPath) || /^[a-z][a-z0-9+.-]*:/i.test(rawPath)) {
return "";
}
const parts = rawPath.split("/").filter((part) => part && part !== ".");
if (!parts.length || parts.some((part) => part === "..")) {
return "";
}
const normalizedPath = parts.join("/");
return /\.(step|stp)$/i.test(normalizedPath) ? normalizedPath : "";
}
function cadPathFromStepPath(stepPath) {
return normalizeCadPath(String(stepPath || "").replace(/\.(step|stp)$/i, ""));
}
function normalizeStepLink(value) {
const raw = isObject(value) ? value : {};
return {
...raw,
path: normalizeRelativeStepPath(raw.path)
};
}
function normalizeManifestStep(value, normalizedStep) {
if (!isObject(value) && !normalizedStep.path) {
return null;
}
const step = isObject(value) ? { ...value } : {};
if (normalizedStep.path) {
step.path = normalizedStep.path;
} else {
delete step.path;
}
return Object.keys(step).length ? step : null;
}
function normalizeFeatureRef(rawFeature, cadPath = "") {
const raw = isObject(rawFeature) ? rawFeature : {};
const ref = normalizeString(raw.ref);
void cadPath;
const fullToken = parseCadRefToken(ref);
if (fullToken) {
return buildCadRefToken({ selectors: fullToken.selectors });
}
const rawSelectors = ref.startsWith("#") ? ref.slice(1) : "";
const selectors = normalizeCadRefSelectors(
rawSelectors ||
raw.selector ||
raw.selectors ||
raw.occurrence ||
raw.occurrences ||
[]
);
return selectors.length
? buildCadRefToken({ selectors })
: ref;
}
function normalizeFeatures(value, { cadPath = "" } = {}) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawFeature]) => {
const raw = isObject(rawFeature) ? rawFeature : {};
const featureId = normalizeString(id);
return featureId ? {
id: featureId,
label: normalizeString(raw.label, featureId),
description: normalizeString(raw.description),
ref: normalizeFeatureRef(raw, cadPath),
partIds: Array.isArray(raw.partIds)
? raw.partIds.map((partId) => normalizeString(partId)).filter(Boolean)
: [],
axis: normalizeVector3(raw.axis, [0, 0, 1]),
origin: normalizePoint3(raw.origin, null)
} : null;
})
.filter(Boolean);
}
function normalizeAnimations(value) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawAnimation]) => {
const raw = isObject(rawAnimation) ? rawAnimation : {};
const animationId = normalizeString(id);
return animationId ? {
id: animationId,
label: normalizeString(raw.label, animationId),
description: normalizeString(raw.description),
duration: Math.max(toFiniteNumber(raw.duration ?? raw.durationSeconds, 1), 0.001),
loop: raw.loop !== false,
update: typeof raw.update === "function" ? raw.update : null,
raw
} : null;
})
.filter(Boolean);
}
function normalizeVector3(value, fallback = [0, 0, 0]) {
const source = Array.isArray(value) ? value : fallback;
return [
toFiniteNumber(source?.[0], fallback[0] || 0),
toFiniteNumber(source?.[1], fallback[1] || 0),
toFiniteNumber(source?.[2], fallback[2] || 0)
];
}
function normalizePoint3(value, fallback = [0, 0, 0]) {
if (!Array.isArray(value) && fallback === null) {
return null;
}
return normalizeVector3(value, fallback || [0, 0, 0]);
}
export function normalizeStepModuleDefinition(rawModule, { url = "", cadPath = "" } = {}) {
if (!isObject(rawModule)) {
throw new Error("STEP runtime module must export an object");
}
const manifest = isObject(rawModule.manifest) ? rawModule.manifest : rawModule;
const schemaVersion = Number(manifest.schemaVersion || rawModule.schemaVersion || 1);
if (schemaVersion !== STEP_MODULE_SCHEMA_VERSION) {
throw new Error(`Unsupported STEP runtime module schemaVersion ${schemaVersion || "unknown"}`);
}
const step = normalizeStepLink(manifest.step);
const stepCadPath = cadPathFromStepPath(step.path);
const normalizedCadPath = stepCadPath || normalizeCadPath(cadPath) || inferCadPathFromStepModuleUrl(url);
const parameters = normalizeParameters(manifest.parameters);
const parameterMap = Object.fromEntries(parameters.map((definition) => [definition.id, definition]));
const defaultParameterValues = Object.fromEntries(
parameters.map((definition) => [definition.id, definition.defaultValue])
);
const normalizedManifestStep = normalizeManifestStep(manifest.step, step);
const normalizedManifest = {
...manifest,
schemaVersion: STEP_MODULE_SCHEMA_VERSION
};
if (normalizedManifestStep) {
normalizedManifest.step = normalizedManifestStep;
} else {
delete normalizedManifest.step;
}
return {
url: normalizeString(url),
cadPath: normalizedCadPath,
step: {
path: step.path,
cadPath: stepCadPath || normalizedCadPath,
explicit: Boolean(step.path),
inferred: !step.path && Boolean(normalizedCadPath)
},
module: rawModule,
manifest: normalizedManifest,
features: normalizeFeatures(manifest.features, { cadPath: normalizedCadPath }),
parameters,
parameterMap,
defaultParameterValues,
animations: normalizeAnimations(manifest.animations)
};
}
export async function loadStepModuleDefinition(url, options = {}) {
const namespace = await import(/* webpackIgnore: true */ /* @vite-ignore */ url);
return normalizeStepModuleDefinition(namespace?.default ?? namespace, { url, ...options });
}
export function normalizeStepModuleParameterValues(definition, values = {}) {
const parameterMap = definition?.parameterMap || {};
return Object.fromEntries(
Object.values(parameterMap).map((parameter) => [
parameter.id,
normalizeParameterValue(parameter, isObject(values) && Object.hasOwn(values, parameter.id)
? values[parameter.id]
: parameter.defaultValue)
])
);
}
const STEP_MODULE_ZERO_STATE_EPSILON = 1e-6;
function stepModuleValueMatchesZeroState(parameter, value, zeroValue) {
const type = normalizeParameterType(parameter?.type);
if (type === "number" || type === "button") {
const numericValue = Number(value);
const numericZeroValue = Number(zeroValue);
return Number.isFinite(numericValue) &&
Number.isFinite(numericZeroValue) &&
Math.abs(numericValue - numericZeroValue) <= STEP_MODULE_ZERO_STATE_EPSILON;
}
if (type === "color") {
return String(value || "").toLowerCase() === String(zeroValue || "").toLowerCase();
}
return value === zeroValue;
}
export function stepModuleParameterValuesAtZeroState(definition, values = {}) {
const parameters = Array.isArray(definition?.parameters) ? definition.parameters : [];
if (!parameters.length) {
return true;
}
const normalizedValues = normalizeStepModuleParameterValues(definition, values);
const zeroValues = normalizeStepModuleParameterValues(definition, definition?.defaultParameterValues || {});
return parameters.every((parameter) => (
stepModuleValueMatchesZeroState(parameter, normalizedValues[parameter.id], zeroValues[parameter.id])
));
}
export function stepModuleAnimationStateAtZeroState(animationState = {}) {
return animationState?.playing !== true &&
Math.abs(toFiniteNumber(animationState?.elapsedSec, 0)) <= STEP_MODULE_ZERO_STATE_EPSILON;
}
export function stepModuleRuntimeAtZeroState(definition, runtimeState = {}) {
return stepModuleParameterValuesAtZeroState(definition, runtimeState?.parameterValues) &&
stepModuleAnimationStateAtZeroState(runtimeState?.animationState);
}
function boundsCenter(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : min;
return [
(toFiniteNumber(min[0]) + toFiniteNumber(max[0])) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1])) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2])) / 2
];
}
function mergeBounds(boundsList) {
const validBounds = (Array.isArray(boundsList) ? boundsList : [])
.filter((bounds) => Array.isArray(bounds?.min) && Array.isArray(bounds?.max));
if (!validBounds.length) {
return null;
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const bounds of validBounds) {
for (let index = 0; index < 3; index += 1) {
min[index] = Math.min(min[index], toFiniteNumber(bounds.min[index]));
max[index] = Math.max(max[index], toFiniteNumber(bounds.max[index]));
}
}
return { min, max };
}
function partIdsForSelector(selector, meshData) {
const normalizedSelector = normalizeString(selector);
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
if (!normalizedSelector || !parts.length) {
return [];
}
return parts
.filter((part) => {
const ids = [part?.id, part?.occurrenceId]
.map((id) => normalizeString(id))
.filter(Boolean);
return ids.some((id) => (
id === normalizedSelector ||
id.startsWith(`${normalizedSelector}.`) ||
normalizedSelector.startsWith(`${id}.`)
));
})
.map((part) => normalizeString(part.id || part.occurrenceId))
.filter(Boolean);
}
function referenceForSelector(selector, selectorRuntime) {
const normalizedSelector = normalizeString(selector);
if (!normalizedSelector || !selectorRuntime) {
return null;
}
return selectorRuntime.referenceByNormalizedSelector?.get(normalizedSelector)
|| selectorRuntime.referenceByDisplaySelector?.get(normalizedSelector)
|| selectorRuntime.referenceMap?.get(normalizedSelector)
|| null;
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => normalizeString(value))
.filter(Boolean))];
}
function featureSelectors(feature) {
const token = parseCadRefToken(feature?.ref);
if (!token) {
return [];
}
return token.selectors.length ? token.selectors : ["__model__"];
}
export function resolveStepModuleFeatures(definition, {
meshData = null,
selectorRuntime = null
} = {}) {
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const partMap = new Map(parts.map((part) => [normalizeString(part.id || part.occurrenceId), part]));
const allPartIds = parts.length
? parts.map((part) => normalizeString(part.id || part.occurrenceId)).filter(Boolean)
: ["__model__"];
return Object.fromEntries(
(Array.isArray(definition?.features) ? definition.features : []).map((feature) => {
const selectors = featureSelectors(feature);
const references = selectors
.map((selector) => selector === "__model__" ? null : referenceForSelector(selector, selectorRuntime))
.filter(Boolean);
const selectorPartIds = selectors.flatMap((selector) => {
if (selector === "__model__") {
return allPartIds;
}
const parsedSelector = parseCadRefSelector(selector);
if (parsedSelector?.occurrenceId) {
return partIdsForSelector(parsedSelector.occurrenceId, meshData);
}
const reference = referenceForSelector(selector, selectorRuntime);
return partIdsForSelector(reference?.partId || reference?.occurrenceId || selector, meshData);
});
const partIds = uniqueStrings([
...feature.partIds,
...selectorPartIds
]);
const partsForFeature = partIds.map((partId) => partMap.get(partId)).filter(Boolean);
const bounds = mergeBounds(partsForFeature.map((part) => part.bounds)) || meshData?.bounds || null;
return [feature.id, {
...feature,
selectors,
references,
partIds,
parts: partsForFeature,
bounds,
center: feature.origin || boundsCenter(bounds),
transform: partsForFeature.length === 1 ? partsForFeature[0]?.transform || null : null,
transforms: partsForFeature.map((part) => part?.transform || null),
missing: selectors.length > 0 && partIds.length === 0 && selectors[0] !== "__model__"
}];
})
);
}
export function stepModuleTargetPartIds(target, features, meshData) {
if (Array.isArray(target)) {
return uniqueStrings(target.flatMap((item) => stepModuleTargetPartIds(item, features, meshData)));
}
if (isObject(target)) {
if (Array.isArray(target.partIds)) {
return uniqueStrings(target.partIds);
}
if (target.partId) {
return uniqueStrings([target.partId]);
}
if (target.feature) {
return stepModuleTargetPartIds(target.feature, features, meshData);
}
if (target.ref) {
return stepModuleTargetPartIds(target.ref, features, meshData);
}
}
const normalizedTarget = normalizeString(target);
if (!normalizedTarget) {
return [];
}
if (normalizedTarget === "*" || normalizedTarget === "all" || normalizedTarget === "__all__") {
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
return parts.length
? uniqueStrings(parts.map((part) => part?.id || part?.occurrenceId))
: ["__model__"];
}
const feature = features?.[normalizedTarget];
if (feature) {
return uniqueStrings(feature.partIds);
}
const token = parseCadRefToken(normalizedTarget);
if (token) {
const selectors = token.selectors.length ? token.selectors : ["*"];
return uniqueStrings(selectors.flatMap((selector) => stepModuleTargetPartIds(selector, features, meshData)));
}
return uniqueStrings(partIdsForSelector(normalizedTarget, meshData));
}
@@ -0,0 +1,323 @@
import { stepModuleTargetPartIds } from "./stepModule.js";
function toNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || "").trim())
.filter(Boolean))];
}
export function buildPartTransformMatrix(THREE, transform) {
const matrix = new THREE.Matrix4();
if (!Array.isArray(transform) || transform.length !== 16) {
matrix.identity();
return matrix;
}
matrix.set(
Number(transform[0]) || 0,
Number(transform[1]) || 0,
Number(transform[2]) || 0,
Number(transform[3]) || 0,
Number(transform[4]) || 0,
Number(transform[5]) || 0,
Number(transform[6]) || 0,
Number(transform[7]) || 0,
Number(transform[8]) || 0,
Number(transform[9]) || 0,
Number(transform[10]) || 0,
Number(transform[11]) || 0,
Number(transform[12]) || 0,
Number(transform[13]) || 0,
Number(transform[14]) || 0,
Number(transform[15]) || 0
);
return matrix;
}
export function displayTransformForPart(meshData, part, renderPartsIndividually = true) {
if (!renderPartsIndividually || meshData?.partTransformsBaked === true) {
return null;
}
return part?.transform || null;
}
function normalizeStepModulePoint(value, fallback = [0, 0, 0]) {
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
return [
toNumber(value[0], fallback[0] || 0),
toNumber(value[1], fallback[1] || 0),
toNumber(value[2], fallback[2] || 0)
];
}
if (value && typeof value === "object") {
return [
toNumber(value.x, fallback[0] || 0),
toNumber(value.y, fallback[1] || 0),
toNumber(value.z, fallback[2] || 0)
];
}
return [...fallback];
}
function stepModuleVector3(THREE, value, fallback = [0, 0, 0]) {
const point = normalizeStepModulePoint(value, fallback);
return new THREE.Vector3(point[0], point[1], point[2]);
}
function buildStepModuleRotateMatrix(THREE, spec = {}) {
const axis = stepModuleVector3(THREE, spec.axis, [0, 0, 1]);
if (axis.lengthSq() <= 1e-12) {
axis.set(0, 0, 1);
}
axis.normalize();
const origin = stepModuleVector3(THREE, spec.origin, [0, 0, 0]);
const angleRad = Number.isFinite(Number(spec.angleRad))
? Number(spec.angleRad)
: (toNumber(spec.angleDeg, 0) * Math.PI) / 180;
return new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeRotationAxis(axis, angleRad))
.multiply(new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z));
}
function buildStepModuleScaleMatrix(THREE, spec = {}) {
const rawScale = spec.scale ?? spec;
const scale = Array.isArray(rawScale) || ArrayBuffer.isView(rawScale)
? normalizeStepModulePoint(rawScale, [1, 1, 1])
: [toNumber(rawScale, 1), toNumber(rawScale, 1), toNumber(rawScale, 1)];
const origin = stepModuleVector3(THREE, spec.origin, [0, 0, 0]);
return new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeScale(scale[0], scale[1], scale[2]))
.multiply(new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z));
}
function buildStepModuleTranslateMatrix(THREE, value) {
const translate = normalizeStepModulePoint(value, [0, 0, 0]);
return new THREE.Matrix4().makeTranslation(translate[0], translate[1], translate[2]);
}
function buildStepModuleSingleEffectMatrix(THREE, spec = {}) {
if (!spec || typeof spec !== "object") {
return new THREE.Matrix4();
}
if (Array.isArray(spec.matrix) && spec.matrix.length === 16) {
return buildPartTransformMatrix(THREE, spec.matrix);
}
const matrix = new THREE.Matrix4();
if (spec.scale !== undefined) {
matrix.premultiply(buildStepModuleScaleMatrix(THREE, spec));
}
if (spec.rotate && typeof spec.rotate === "object") {
matrix.premultiply(buildStepModuleRotateMatrix(THREE, spec.rotate));
}
if (spec.translate !== undefined) {
matrix.premultiply(buildStepModuleTranslateMatrix(THREE, spec.translate));
}
return matrix;
}
export function buildStepModuleEffectMatrix(THREE, spec = {}) {
if (!spec || typeof spec !== "object") {
return new THREE.Matrix4();
}
const steps = Array.isArray(spec.transforms) ? spec.transforms : [spec];
const matrix = new THREE.Matrix4();
for (const step of steps) {
matrix.premultiply(buildStepModuleSingleEffectMatrix(THREE, step));
}
return matrix;
}
function matrixHasTransform(matrix, epsilon = 1e-6) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return false;
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return matrix.elements.some((value, index) => Math.abs(Number(value) - identity[index]) > epsilon);
}
export function resetStepModuleRecordEffects(records) {
for (const record of Array.isArray(records) ? records : []) {
record.effectMatrix = null;
record.effectStyle = null;
record.effectVisible = null;
record.effectHighlighted = false;
}
}
function allStepModulePartIds(meshData, displayRecords) {
const partIds = Array.isArray(meshData?.parts)
? meshData.parts.map((part) => String(part?.id || part?.occurrenceId || "").trim()).filter(Boolean)
: [];
if (partIds.length) {
return uniqueStrings(partIds);
}
const displayPartIds = (Array.isArray(displayRecords) ? displayRecords : [])
.map((record) => String(record?.partId || "").trim())
.filter(Boolean);
return uniqueStrings(displayPartIds.length ? displayPartIds : ["__model__"]);
}
function resolveStepModuleEffectTargetPartIds(target, features, meshData, displayRecords) {
if (target && typeof target === "object" && !Array.isArray(target)) {
if (Array.isArray(target.partIds)) {
return uniqueStrings(target.partIds);
}
if (target.partId) {
return uniqueStrings([target.partId]);
}
if (target.feature) {
return resolveStepModuleEffectTargetPartIds(target.feature, features, meshData, displayRecords);
}
if (target.ref) {
return stepModuleTargetPartIds(String(target.ref), features, meshData);
}
}
const normalizedTarget = String(target || "").trim();
if (normalizedTarget === "*" || normalizedTarget === "all" || normalizedTarget === "__all__") {
return allStepModulePartIds(meshData, displayRecords);
}
return stepModuleTargetPartIds(target, features, meshData);
}
export function createStepModuleEffectsApi(THREE, {
meshData,
features,
runtime,
effectsByPartId,
onTransformEffect
}) {
const ensureEffect = (partId) => {
const id = String(partId || "").trim();
if (!id) {
return null;
}
const current = effectsByPartId.get(id) || {
matrix: null,
style: null,
visible: null,
highlighted: false
};
effectsByPartId.set(id, current);
return current;
};
const forEachTarget = (target, callback) => {
const partIds = resolveStepModuleEffectTargetPartIds(target, features, meshData, runtime?.displayRecords);
for (const partId of partIds) {
const effect = ensureEffect(partId);
if (effect) {
callback(effect, partId);
}
}
return partIds;
};
return {
transform(target, spec) {
const matrix = buildStepModuleEffectMatrix(THREE, spec);
const partIds = forEachTarget(target, (effect) => {
effect.matrix = effect.matrix
? effect.matrix.clone().premultiply(matrix)
: matrix.clone();
});
if (partIds.length && matrixHasTransform(matrix)) {
onTransformEffect?.({
target,
spec,
partIds,
matrix: matrix.clone()
});
}
},
style(target, style = {}) {
if (!style || typeof style !== "object") {
return;
}
forEachTarget(target, (effect) => {
effect.style = {
...(effect.style || {}),
...style
};
});
},
visible(target, visible = true) {
forEachTarget(target, (effect) => {
effect.visible = visible !== false;
});
},
highlight(target, highlighted = true) {
forEachTarget(target, (effect) => {
effect.highlighted = highlighted !== false;
});
},
clear(target) {
const partIds = resolveStepModuleEffectTargetPartIds(target, features, meshData, runtime?.displayRecords);
for (const partId of partIds) {
effectsByPartId.delete(partId);
}
}
};
}
export function buildStepModuleTimeState(animationState = {}) {
const duration = Math.max(Number(animationState.duration) || 0, 0);
const elapsedSec = Math.max(Number(animationState.elapsedSec) || 0, 0);
return {
elapsed: elapsedSec,
elapsedSec,
duration,
progress: duration > 0 ? clamp(elapsedSec / duration, 0, 1) : 0,
cycle: duration > 0 ? elapsedSec / duration : 0,
playing: animationState.playing === true,
speed: Math.max(Number(animationState.speed) || 1, 0)
};
}
export function buildStepModuleContext({
runtime,
stepModuleRuntime,
features,
effects,
cleanup
}) {
const definition = stepModuleRuntime?.definition || null;
return {
THREE: runtime?.THREE,
scene: runtime?.scene,
modelGroup: runtime?.modelGroup,
edgesGroup: runtime?.edgesGroup,
runtime,
manifest: definition?.manifest || {},
module: definition?.module || null,
cadPath: stepModuleRuntime?.cadPath || definition?.cadPath || "",
params: stepModuleRuntime?.parameterValues || {},
features,
effects,
time: buildStepModuleTimeState(stepModuleRuntime?.animationState),
cleanup: typeof cleanup === "function" ? cleanup : () => {},
requestRender: () => runtime?.requestRender?.()
};
}
export function applyStepModuleEffectsToRecords(THREE, records, effectsByPartId) {
resetStepModuleRecordEffects(records);
for (const record of Array.isArray(records) ? records : []) {
const effect = effectsByPartId.get(String(record?.partId || "").trim());
if (!effect) {
continue;
}
record.effectMatrix = effect.matrix instanceof THREE.Matrix4 ? effect.matrix.clone() : null;
record.effectStyle = effect.style && typeof effect.style === "object" ? { ...effect.style } : null;
record.effectVisible = effect.visible;
record.effectHighlighted = effect.highlighted === true;
}
}
@@ -0,0 +1,173 @@
import {
normalizeParameterValue,
normalizeStepModuleParameterValues
} from "./stepModule.js";
export const DEFAULT_STEP_PARAMETER_FPS = 18;
export const DEFAULT_STEP_PARAMETER_DURATION_SECONDS = 4;
export const MAX_STEP_PARAMETER_FRAMES = 720;
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function positiveInteger(value, fallback) {
const parsed = Math.round(Number(value));
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function rawParamsUseEnvelope(rawParams) {
return isObject(rawParams) && (
isObject(rawParams.values)
|| isObject(rawParams.animate)
|| Object.hasOwn(rawParams, "durationSeconds")
|| Object.hasOwn(rawParams, "duration")
|| Object.hasOwn(rawParams, "fps")
|| Object.hasOwn(rawParams, "loop")
);
}
export function hasStepParameterRenderValues(value) {
return value !== undefined && value !== null;
}
export function stepParameterRenderValuesAreAnimated(rawParams) {
return isObject(rawParams) && isObject(rawParams.animate) && Object.keys(rawParams.animate).length > 0;
}
function parameterMapForDefinition(definition) {
return definition?.parameterMap && typeof definition.parameterMap === "object"
? definition.parameterMap
: {};
}
function assertKnownParameterIds(definition, values, label) {
const parameterMap = parameterMapForDefinition(definition);
for (const key of Object.keys(isObject(values) ? values : {})) {
if (!parameterMap[key]) {
throw new Error(`Unknown STEP parameter in ${label}: ${key}`);
}
}
}
function normalizeRange(definition, parameterId, rawRange) {
const parameter = parameterMapForDefinition(definition)[parameterId];
if (!parameter) {
throw new Error(`Unknown STEP parameter animation: ${parameterId}`);
}
if (parameter.type !== "number") {
throw new Error(`STEP parameter animation must be numeric: ${parameterId}`);
}
if (!isObject(rawRange)) {
throw new Error(`STEP parameter animation range must be an object: ${parameterId}`);
}
const from = Number(rawRange.from);
const to = Number(rawRange.to);
if (!Number.isFinite(from) || !Number.isFinite(to)) {
throw new Error(`STEP parameter animation range requires finite from/to values: ${parameterId}`);
}
return {
parameterId,
from,
to
};
}
function normalizeAnimationRanges(definition, rawAnimate) {
return Object.fromEntries(
Object.entries(isObject(rawAnimate) ? rawAnimate : {})
.map(([parameterId, rawRange]) => {
const normalizedId = String(parameterId || "").trim();
if (!normalizedId) {
return null;
}
const range = normalizeRange(definition, normalizedId, rawRange);
return [normalizedId, range];
})
.filter(Boolean)
);
}
export function normalizeStepParameterRenderValues(definition, rawParams = {}) {
if (!isObject(rawParams)) {
throw new Error("STEP parameters must be a JSON object");
}
const usesEnvelope = rawParamsUseEnvelope(rawParams);
const rawValues = usesEnvelope ? (rawParams.values || {}) : rawParams;
if (!isObject(rawValues)) {
throw new Error("STEP parameters.values must be a JSON object");
}
assertKnownParameterIds(definition, rawValues, "stepParameters");
const values = normalizeStepModuleParameterValues(definition, rawValues);
const animate = usesEnvelope ? normalizeAnimationRanges(definition, rawParams.animate) : {};
const animated = Object.keys(animate).length > 0;
const fps = clamp(positiveInteger(rawParams.fps, DEFAULT_STEP_PARAMETER_FPS), 1, 60);
const durationSeconds = clamp(
toFiniteNumber(rawParams.durationSeconds ?? rawParams.duration, DEFAULT_STEP_PARAMETER_DURATION_SECONDS),
0.1,
60
);
const frameCount = animated
? Math.max(2, Math.min(Math.round(fps * durationSeconds), MAX_STEP_PARAMETER_FRAMES))
: 1;
return {
values,
animate,
animated,
fps,
durationSeconds,
frameCount,
loop: rawParams.loop !== false
};
}
export function stepParameterRenderFrameProgress(params, frameIndex) {
const frameCount = Math.max(Math.floor(Number(params?.frameCount) || 1), 1);
const index = clamp(Math.floor(Number(frameIndex) || 0), 0, frameCount - 1);
if (frameCount <= 1) {
return 0;
}
return params?.loop === false
? index / (frameCount - 1)
: index / frameCount;
}
export function stepParameterRenderFrameValues(definition, params, frameIndex) {
const progress = stepParameterRenderFrameProgress(params, frameIndex);
const values = { ...(params?.values || {}) };
for (const [parameterId, range] of Object.entries(isObject(params?.animate) ? params.animate : {})) {
const parameter = parameterMapForDefinition(definition)[parameterId];
if (!parameter) {
continue;
}
values[parameterId] = normalizeParameterValue(
parameter,
range.from + ((range.to - range.from) * progress)
);
}
return values;
}
export function stepParameterRenderFrameState(params, frameIndex) {
const progress = stepParameterRenderFrameProgress(params, frameIndex);
const duration = Math.max(Number(params?.durationSeconds) || 0, 0);
const elapsedSec = progress * duration;
return {
activeId: "params",
playing: Boolean(params?.animated),
elapsedSec,
duration,
speed: 1,
loop: params?.loop !== false
};
}
@@ -0,0 +1,43 @@
import path from "node:path";
export function isPerStepViewerDirectoryName(name) {
const normalized = String(name || "").toLowerCase();
return normalized.startsWith(".") && (normalized.endsWith(".step") || normalized.endsWith(".stp"));
}
export function isInlineStepGlbArtifactPath(filePath) {
const name = path.basename(String(filePath || "")).toLowerCase();
return name.startsWith(".") && (name.endsWith(".step.glb") || name.endsWith(".stp.glb"));
}
export function isInlineStepParameterPath(filePath) {
const name = path.basename(String(filePath || "")).toLowerCase();
return name.startsWith(".") && name.endsWith(".step.js");
}
export function isPathInsidePerStepViewerDirectory(filePath) {
return String(filePath || "")
.split(path.sep)
.some((part) => isPerStepViewerDirectoryName(part));
}
export function inlineStepGlbArtifactPathForSource(sourcePath) {
return path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}.glb`);
}
export function legacyStepGlbArtifactPathForSource(sourcePath) {
return path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}`, "model.glb");
}
export function stepGlbArtifactPathForSource(sourcePath, { existsSync = null } = {}) {
const inlinePath = inlineStepGlbArtifactPathForSource(sourcePath);
if (!existsSync || existsSync(inlinePath)) {
return inlinePath;
}
return legacyStepGlbArtifactPathForSource(sourcePath);
}
export function stepParameterPathForStepSource(sourcePath) {
const stem = path.basename(sourcePath, path.extname(sourcePath));
return path.join(path.dirname(sourcePath), `.${stem}.step.js`);
}
@@ -0,0 +1,103 @@
export const STEP_TOPOLOGY_EXTENSION = "STEP_topology";
export const STEP_TOPOLOGY_SCHEMA_VERSION = 2;
export const STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM = "oc-brep-continuity-v1";
export const STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM = "oc-polygon-on-triangulation-v1";
export const STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG = 2;
export const STEP_TOPOLOGY_EDGE_SAMPLE_COUNT = 3;
export const STEP_EDGE_BARYCENTRIC_ATTRIBUTE = "_CAD_EDGE_BARYCENTRIC";
export const STEP_EDGE_CLASS_ATTRIBUTE = "_CAD_EDGE_CLASS";
export const STEP_EDGE_FLAGS = Object.freeze({
DEGENERATE: 1 << 1,
SEAM: 1 << 2,
NOT_REFERENCEABLE: 1 << 3,
BOUNDARY: 1 << 4,
NON_MANIFOLD: 1 << 5,
HARD: 1 << 6,
TANGENT: 1 << 7,
UNKNOWN_CONTINUITY: 1 << 8
});
export const STEP_EDGE_VISIBILITY_CLASSES = Object.freeze({
FEATURE: "feature",
TANGENT: "tangent",
SEAM: "seam",
DEGENERATE: "degenerate",
BOUNDARY: "boundary",
NON_MANIFOLD: "nonManifold",
UNKNOWN: "unknown"
});
export const STEP_EDGE_SURFACE_CLASS_CODES = Object.freeze({
none: 0,
feature: 1,
tangent: 2,
seam: 3,
degenerate: 4,
boundary: 5,
nonManifold: 6,
unknown: 7
});
export const STEP_EDGE_RENDER_VISIBILITY_CLASSES = Object.freeze([
STEP_EDGE_VISIBILITY_CLASSES.FEATURE,
STEP_EDGE_VISIBILITY_CLASSES.TANGENT,
STEP_EDGE_VISIBILITY_CLASSES.SEAM,
STEP_EDGE_VISIBILITY_CLASSES.DEGENERATE
]);
export function isCurrentStepTopologySchemaVersion(value) {
return Number(value) === STEP_TOPOLOGY_SCHEMA_VERSION;
}
export function stepEdgeSurfaceClassCode(edge = {}) {
const flags = Number(edge?.flags || 0);
const visibilityClass = String(edge?.visibilityClass || "").trim();
if (flags & STEP_EDGE_FLAGS.DEGENERATE || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.DEGENERATE) {
return STEP_EDGE_SURFACE_CLASS_CODES.degenerate;
}
if (flags & STEP_EDGE_FLAGS.SEAM || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.SEAM) {
return STEP_EDGE_SURFACE_CLASS_CODES.seam;
}
if (flags & STEP_EDGE_FLAGS.BOUNDARY || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.BOUNDARY) {
return STEP_EDGE_SURFACE_CLASS_CODES.boundary;
}
if (flags & STEP_EDGE_FLAGS.NON_MANIFOLD || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.NON_MANIFOLD) {
return STEP_EDGE_SURFACE_CLASS_CODES.nonManifold;
}
if (flags & STEP_EDGE_FLAGS.UNKNOWN_CONTINUITY || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.UNKNOWN) {
return STEP_EDGE_SURFACE_CLASS_CODES.unknown;
}
if (flags & STEP_EDGE_FLAGS.TANGENT || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.TANGENT) {
return STEP_EDGE_SURFACE_CLASS_CODES.tangent;
}
if (visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.FEATURE || flags & STEP_EDGE_FLAGS.HARD) {
return STEP_EDGE_SURFACE_CLASS_CODES.feature;
}
return STEP_EDGE_SURFACE_CLASS_CODES.feature;
}
export function isDisplayableStepEdgeSurfaceClassCode(value) {
const code = Number(value);
return Number.isFinite(code) &&
code !== STEP_EDGE_SURFACE_CLASS_CODES.none &&
code !== STEP_EDGE_SURFACE_CLASS_CODES.degenerate;
}
export function stepTopologyCapabilities() {
return {
edgeClassification: {
algorithm: STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM,
angularToleranceDeg: STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG,
samples: STEP_TOPOLOGY_EDGE_SAMPLE_COUNT
},
surfaceEdgeRendering: {
algorithm: STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM,
primitiveAttributes: {
barycentric: STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
class: STEP_EDGE_CLASS_ATTRIBUTE
},
classCodes: STEP_EDGE_SURFACE_CLASS_CODES,
visibilityClasses: STEP_EDGE_RENDER_VISIBILITY_CLASSES
}
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
import {
buildTransformedDisplayEdgeRuntime,
buildTransformedSelectorRuntime
} from "../lib/selectors/runtime.js";
import {
shouldUseTopologyDisplayEdges
} from "./topologyDisplayEdges.js";
const displayEdgeVisibilityClassRuntimeCache = new WeakMap();
const transformedSelectorRuntimeCache = new WeakMap();
const transformedDisplayEdgeRuntimeCache = new WeakMap();
const MAX_TRANSFORMED_RUNTIME_CACHE_ENTRIES = 32;
export function topologyDisplayEdgeSurfaceOffsetForSettings(edgeSettings = {}) {
return 0;
}
export function applyTopologyDisplayEdgeSurfaceOffset(records, edgeSettings = {}) {
// Topology display edges must depth-test against the true solid surface.
// Pushing the whole mesh away makes close-but-hidden edges bleed through at distance.
for (const record of Array.isArray(records) ? records : []) {
const material = record?.material;
if (!material) {
continue;
}
material.polygonOffset = false;
material.polygonOffsetFactor = 0;
material.polygonOffsetUnits = 0;
material.needsUpdate = true;
}
}
export function disposeTopologyDisplayEdgeObject(object) {
if (!object) {
return;
}
while (object.children?.length) {
disposeTopologyDisplayEdgeObject(object.children[0]);
}
object.parent?.remove?.(object);
if (typeof object.userData?.beforeDispose === "function") {
object.userData.beforeDispose(object);
delete object.userData.beforeDispose;
}
if (object.userData?.disposeGeometry !== false) {
object.geometry?.dispose?.();
}
if (object.userData?.disposeMaterial !== false) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material?.dispose?.();
}
}
}
export function matrixHasTransform(matrix, epsilon = 1e-6) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return false;
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return matrix.elements.some((value, index) => Math.abs(Number(value) - identity[index]) > epsilon);
}
export function rowMajorArrayFromMatrix4(matrix) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return null;
}
const elements = matrix.elements;
return [
elements[0], elements[4], elements[8], elements[12],
elements[1], elements[5], elements[9], elements[13],
elements[2], elements[6], elements[10], elements[14],
elements[3], elements[7], elements[11], elements[15],
];
}
function transformSignatureValue(value) {
const number = Number(value);
return Number.isFinite(number) ? String(number) : "0";
}
function transformEntriesSignature(transforms) {
if (!transforms?.size) {
return "";
}
return [...transforms.entries()]
.map(([partId, transform]) => {
const transformValues = Array.isArray(transform) || ArrayBuffer.isView(transform)
? Array.from(transform, transformSignatureValue)
: [];
return `${String(partId || "").trim()}:${transformValues.join(",")}`;
})
.sort()
.join("|");
}
function cachedTransformedRuntime(cache, baseRuntime, transforms, buildRuntime) {
if (!baseRuntime || !transforms?.size) {
return null;
}
const signature = transformEntriesSignature(transforms);
if (!signature) {
return null;
}
let runtimeBySignature = cache.get(baseRuntime);
if (!runtimeBySignature) {
runtimeBySignature = new Map();
cache.set(baseRuntime, runtimeBySignature);
}
if (runtimeBySignature.has(signature)) {
return runtimeBySignature.get(signature);
}
const runtime = buildRuntime(baseRuntime, transforms);
runtimeBySignature.set(signature, runtime);
if (runtimeBySignature.size > MAX_TRANSFORMED_RUNTIME_CACHE_ENTRIES) {
runtimeBySignature.delete(runtimeBySignature.keys().next().value);
}
return runtime;
}
export function selectorTransformsFromDisplayRecords(displayRecords) {
const transforms = new Map();
for (const record of Array.isArray(displayRecords) ? displayRecords : []) {
const partId = String(record?.partId || "").trim();
if (!partId || !record?.effectMatrix || !matrixHasTransform(record.effectMatrix)) {
continue;
}
const transform = rowMajorArrayFromMatrix4(record.effectMatrix);
if (transform) {
transforms.set(partId, transform);
}
}
return transforms;
}
function runtimeHasVisibilityClassRows(runtime) {
return Array.isArray(runtime?.edges) &&
runtime.edges.some((row) => String(row?.visibilityClass || "").trim());
}
function displayEdgeRowsCanUseSelectorClasses(displayEdgeRuntime, selectorRuntime) {
const displayRows = Array.isArray(displayEdgeRuntime?.edges) ? displayEdgeRuntime.edges : [];
const selectorRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : [];
const alignedRows = displayRows.every((row, rowIndex) => {
const selectorRow = selectorRows[rowIndex] || {};
const displayStart = Number(row?.segmentStart);
const selectorStart = Number(selectorRow?.segmentStart);
const displayCount = Number(row?.segmentCount);
const selectorCount = Number(selectorRow?.segmentCount);
return (
(!Number.isFinite(displayStart) || !Number.isFinite(selectorStart) || displayStart === selectorStart) &&
(!Number.isFinite(displayCount) || !Number.isFinite(selectorCount) || displayCount === selectorCount)
);
});
return Boolean(
displayRows.length &&
displayRows.length === selectorRows.length &&
alignedRows &&
!runtimeHasVisibilityClassRows(displayEdgeRuntime) &&
runtimeHasVisibilityClassRows(selectorRuntime)
);
}
function runtimeUsesSurfaceOwnedEdges(runtime) {
return Boolean(runtime?.surfaceEdgeRendering || Number(runtime?.schemaVersion || 0) >= 3);
}
export function displayEdgeRuntimeWithSelectorVisibilityClasses(displayEdgeRuntime, selectorRuntime) {
if (!displayEdgeRowsCanUseSelectorClasses(displayEdgeRuntime, selectorRuntime)) {
return displayEdgeRuntime || null;
}
const cached = displayEdgeVisibilityClassRuntimeCache.get(displayEdgeRuntime);
if (cached?.selectorRuntime === selectorRuntime) {
return cached.runtime;
}
const runtime = {
...displayEdgeRuntime,
edges: displayEdgeRuntime.edges.map((row, rowIndex) => ({
...row,
visibilityClass: selectorRuntime.edges[rowIndex]?.visibilityClass || "feature"
}))
};
displayEdgeVisibilityClassRuntimeCache.set(displayEdgeRuntime, { selectorRuntime, runtime });
return runtime;
}
export function resolveTopologyDisplayEdgeRuntimes({
selectorRuntime = null,
displayEdgeRuntime = null,
displayRecords = null,
transformDisplayEdges = true
} = {}) {
const selectorTransforms = selectorTransformsFromDisplayRecords(displayRecords);
const baseDisplayEdgeRuntime = displayEdgeRuntimeWithSelectorVisibilityClasses(displayEdgeRuntime, selectorRuntime);
const transformedSelectorRuntime = selectorTransforms.size && selectorRuntime
? cachedTransformedRuntime(
transformedSelectorRuntimeCache,
selectorRuntime,
selectorTransforms,
buildTransformedSelectorRuntime
)
: null;
const transformedDisplayEdgeRuntime = transformDisplayEdges && selectorTransforms.size && baseDisplayEdgeRuntime
? cachedTransformedRuntime(
transformedDisplayEdgeRuntimeCache,
baseDisplayEdgeRuntime,
selectorTransforms,
buildTransformedDisplayEdgeRuntime
)
: null;
const activeSelectorRuntime = transformedSelectorRuntime || selectorRuntime || null;
const activeDisplayEdgeRuntime = transformedDisplayEdgeRuntime || baseDisplayEdgeRuntime || null;
return {
selectorTransforms,
transformCount: selectorTransforms.size,
transformedSelectorRuntime,
transformedDisplayEdgeRuntime,
selectorRuntime: activeSelectorRuntime,
displayEdgeRuntime: activeDisplayEdgeRuntime,
topologyRuntime: activeDisplayEdgeRuntime || activeSelectorRuntime
};
}
export function shouldRenderTopologyDisplayEdges({
edgesVisible = false,
wireframeMode = false,
cadEdgeSource = false,
displayEdgeRuntime = null,
selectorRuntime = null,
edgeSettings = null
} = {}) {
return Boolean(
edgesVisible &&
!wireframeMode &&
cadEdgeSource &&
!runtimeUsesSurfaceOwnedEdges(displayEdgeRuntime || selectorRuntime) &&
shouldUseTopologyDisplayEdges(displayEdgeRuntime || selectorRuntime, edgeSettings)
);
}
export function shouldUseRecordTopologyEdgeTransforms({
transformDetected = false,
topologyDisplayEdgesVisible = false,
displayEdgeRuntime = null,
displayRecords = []
} = {}) {
return Boolean(
transformDetected &&
topologyDisplayEdgesVisible &&
displayEdgeRuntime &&
Array.isArray(displayRecords) &&
displayRecords.length > 1
);
}
@@ -0,0 +1,185 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyTopologyDisplayEdgeSurfaceOffset,
displayEdgeRuntimeWithSelectorVisibilityClasses,
resolveTopologyDisplayEdgeRuntimes,
rowMajorArrayFromMatrix4,
selectorTransformsFromDisplayRecords,
shouldRenderTopologyDisplayEdges,
topologyDisplayEdgeSurfaceOffsetForSettings
} from "./topologyDisplayEdgeRuntime.js";
const TRANSLATE_2_3_4 = Object.freeze({
elements: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
2, 3, 4, 1
]
});
const TRANSLATE_3_4_5 = Object.freeze({
elements: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
3, 4, 5, 1
]
});
function displayEdgeRuntime() {
return {
edges: [{ occurrenceId: "part-a" }],
proxy: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1]),
edgeIds: new Uint32Array([0])
}
};
}
test("rowMajorArrayFromMatrix4 converts Three.js matrix storage", () => {
assert.deepEqual(rowMajorArrayFromMatrix4(TRANSLATE_2_3_4), [
1, 0, 0, 2,
0, 1, 0, 3,
0, 0, 1, 4,
0, 0, 0, 1
]);
});
test("selectorTransformsFromDisplayRecords reads part effect transforms", () => {
const transforms = selectorTransformsFromDisplayRecords([
{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 },
{ partId: "identity", effectMatrix: { elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } }
]);
assert.equal(transforms.size, 1);
assert.deepEqual(transforms.get("part-a"), [
1, 0, 0, 2,
0, 1, 0, 3,
0, 0, 1, 4,
0, 0, 0, 1
]);
});
test("resolveTopologyDisplayEdgeRuntimes prefers transformed display edges", () => {
const baseRuntime = displayEdgeRuntime();
const resolved = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseRuntime,
selectorRuntime: { proxy: {} },
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 }]
});
assert.equal(resolved.transformCount, 1);
assert.equal(resolved.topologyRuntime, resolved.displayEdgeRuntime);
assert.notEqual(resolved.displayEdgeRuntime, baseRuntime);
assert.deepEqual([...resolved.displayEdgeRuntime.proxy.edgePositions], [2, 3, 4, 3, 3, 4]);
});
test("resolveTopologyDisplayEdgeRuntimes reuses stable transformed runtimes", () => {
const baseDisplayRuntime = displayEdgeRuntime();
const baseSelectorRuntime = {
edges: [{ occurrenceId: "part-a" }],
proxy: {}
};
const first = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseDisplayRuntime,
selectorRuntime: baseSelectorRuntime,
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 }]
});
const second = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseDisplayRuntime,
selectorRuntime: baseSelectorRuntime,
displayRecords: [{
partId: "part-a",
effectMatrix: { elements: [...TRANSLATE_2_3_4.elements] }
}]
});
const changed = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseDisplayRuntime,
selectorRuntime: baseSelectorRuntime,
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_3_4_5 }]
});
assert.equal(second.transformedSelectorRuntime, first.transformedSelectorRuntime);
assert.equal(second.transformedDisplayEdgeRuntime, first.transformedDisplayEdgeRuntime);
assert.notEqual(changed.transformedSelectorRuntime, first.transformedSelectorRuntime);
assert.notEqual(changed.transformedDisplayEdgeRuntime, first.transformedDisplayEdgeRuntime);
});
test("resolveTopologyDisplayEdgeRuntimes can skip transformed display edges for record-driven overlays", () => {
const baseRuntime = displayEdgeRuntime();
const resolved = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseRuntime,
selectorRuntime: { proxy: {} },
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 }],
transformDisplayEdges: false
});
assert.equal(resolved.transformCount, 1);
assert.equal(resolved.displayEdgeRuntime, baseRuntime);
assert.equal(resolved.transformedDisplayEdgeRuntime, null);
});
test("display edge runtimes inherit selector visibility classes when compact payloads omit them", () => {
const displayRuntime = {
edges: [
{ occurrenceId: "o1", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "o1", segmentStart: 1, segmentCount: 2 }
],
proxy: {}
};
const selectorRuntime = {
edges: [
{ id: "o1.e1", visibilityClass: "feature" },
{ id: "o1.e2", visibilityClass: "tangent" }
],
proxy: {}
};
const enriched = displayEdgeRuntimeWithSelectorVisibilityClasses(displayRuntime, selectorRuntime);
const enrichedAgain = displayEdgeRuntimeWithSelectorVisibilityClasses(displayRuntime, selectorRuntime);
assert.notEqual(enriched, displayRuntime);
assert.equal(enrichedAgain, enriched);
assert.deepEqual(enriched.edges, [
{ occurrenceId: "o1", segmentStart: 0, segmentCount: 1, visibilityClass: "feature" },
{ occurrenceId: "o1", segmentStart: 1, segmentCount: 2, visibilityClass: "tangent" }
]);
const resolved = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: displayRuntime,
selectorRuntime
});
assert.deepEqual(resolved.displayEdgeRuntime.edges, enriched.edges);
});
test("shouldRenderTopologyDisplayEdges gates CAD topology overlays", () => {
const runtime = displayEdgeRuntime();
assert.equal(shouldRenderTopologyDisplayEdges({
edgesVisible: true,
cadEdgeSource: true,
displayEdgeRuntime: runtime
}), true);
assert.equal(shouldRenderTopologyDisplayEdges({
edgesVisible: true,
wireframeMode: true,
cadEdgeSource: true,
displayEdgeRuntime: runtime
}), false);
assert.equal(shouldRenderTopologyDisplayEdges({
edgesVisible: true,
cadEdgeSource: true,
selectorRuntime: { schemaVersion: 3, surfaceEdgeRendering: true, edges: [] }
}), false);
});
test("surface offset scales for thick topology display edges", () => {
const material = {};
applyTopologyDisplayEdgeSurfaceOffset([{ material }], { thickness: 6 });
assert.equal(topologyDisplayEdgeSurfaceOffsetForSettings({ thickness: 1 }), 0);
assert.equal(topologyDisplayEdgeSurfaceOffsetForSettings({ thickness: 6 }), 0);
assert.equal(material.polygonOffset, false);
assert.equal(material.polygonOffsetFactor, 0);
assert.equal(material.polygonOffsetUnits, 0);
assert.equal(material.needsUpdate, true);
});
@@ -0,0 +1,434 @@
function isFloat32Array(value) {
return value instanceof Float32Array;
}
function isUint32Array(value) {
return value instanceof Uint32Array;
}
function pointDistance(left, right) {
return Math.hypot(
Number(right?.[0] || 0) - Number(left?.[0] || 0),
Number(right?.[1] || 0) - Number(left?.[1] || 0),
Number(right?.[2] || 0) - Number(left?.[2] || 0)
);
}
function boundsForPolylines(polylines) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const positions of polylines) {
for (let offset = 0; offset + 2 < positions.length; offset += 3) {
min[0] = Math.min(min[0], positions[offset]);
min[1] = Math.min(min[1], positions[offset + 1]);
min[2] = Math.min(min[2], positions[offset + 2]);
max[0] = Math.max(max[0], positions[offset]);
max[1] = Math.max(max[1], positions[offset + 1]);
max[2] = Math.max(max[2], positions[offset + 2]);
}
}
if (!Number.isFinite(min[0]) || !Number.isFinite(max[0])) {
return null;
}
return { min, max };
}
function stitchToleranceForPolylines(polylines) {
const bounds = boundsForPolylines(polylines);
if (!bounds) {
return 1e-5;
}
return Math.max(1e-5, pointDistance(bounds.min, bounds.max) * 1e-7);
}
function pointFromProxy(proxy, vertexIndex) {
const offset = Number(vertexIndex) * 3;
if (
!Number.isInteger(vertexIndex) ||
offset < 0 ||
offset + 2 >= proxy.edgePositions.length
) {
return null;
}
return [
proxy.edgePositions[offset],
proxy.edgePositions[offset + 1],
proxy.edgePositions[offset + 2]
];
}
function normalizePartIdSet(value) {
return new Set(
(Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
}
function normalizeVisibilityClassSet(value) {
const values = (Array.isArray(value) ? value : [value])
.map((item) => String(item || "").trim().toLowerCase())
.filter(Boolean);
return values.length ? new Set(values) : null;
}
function occurrenceMatchesPartIds(occurrenceId, partIds) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
if (!normalizedOccurrenceId || !partIds?.size) {
return false;
}
for (const partId of partIds) {
if (normalizedOccurrenceId === partId || normalizedOccurrenceId.startsWith(`${partId}.`)) {
return true;
}
}
return false;
}
function edgeRowMatchesPartIds(row, partIds) {
return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds);
}
function edgeRowPassesPartFilter(row, includePartIds, excludePartIds) {
if (includePartIds.size && !edgeRowMatchesPartIds(row, includePartIds)) {
return false;
}
if (excludePartIds.size && edgeRowMatchesPartIds(row, excludePartIds)) {
return false;
}
return true;
}
function edgeRowPassesVisibilityClassFilter(row, visibilityClasses) {
if (!visibilityClasses?.size) {
return true;
}
const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature";
return visibilityClasses.has(visibilityClass);
}
function edgeRowPassesFilters(row, includePartIds, excludePartIds, visibilityClasses) {
return (
edgeRowPassesPartFilter(row, includePartIds, excludePartIds) &&
edgeRowPassesVisibilityClassFilter(row, visibilityClasses)
);
}
function pushPoint(target, point, tolerance = 1e-7) {
if (!point) {
return;
}
const last = target.length >= 3
? [target[target.length - 3], target[target.length - 2], target[target.length - 1]]
: null;
if (!last || pointDistance(last, point) > tolerance) {
target.push(point[0], point[1], point[2]);
}
}
function buildTopologyPolylinesForRun(proxy, segmentStart, segmentCount) {
const polylines = [];
let current = [];
let lastPoint = null;
for (let segmentIndex = segmentStart; segmentIndex < segmentStart + segmentCount; segmentIndex += 1) {
const startVertexIndex = Number(proxy.edgeIndices[segmentIndex * 2]);
const endVertexIndex = Number(proxy.edgeIndices[(segmentIndex * 2) + 1]);
const startPoint = pointFromProxy(proxy, startVertexIndex);
const endPoint = pointFromProxy(proxy, endVertexIndex);
if (!startPoint || !endPoint || pointDistance(startPoint, endPoint) <= 1e-10) {
continue;
}
if (lastPoint && pointDistance(lastPoint, startPoint) > 1e-5) {
if (current.length >= 6) {
polylines.push(new Float32Array(current));
}
current = [];
lastPoint = null;
}
if (!lastPoint) {
pushPoint(current, startPoint);
}
pushPoint(current, endPoint);
lastPoint = endPoint;
}
if (current.length >= 6) {
polylines.push(new Float32Array(current));
}
return polylines;
}
function buildTopologyPolylinesForEdgeId(proxy, edgeIds, rowIndex, segmentCount) {
const polylines = [];
let runStart = -1;
let runCount = 0;
const flushRun = () => {
if (runStart >= 0 && runCount > 0) {
polylines.push(...buildTopologyPolylinesForRun(proxy, runStart, runCount));
}
runStart = -1;
runCount = 0;
};
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
if (Number(edgeIds[segmentIndex]) === rowIndex) {
if (runStart < 0) {
runStart = segmentIndex;
}
runCount += 1;
} else {
flushRun();
}
}
flushRun();
return polylines;
}
function polylinePoint(positions, vertexIndex) {
const offset = vertexIndex * 3;
return [
positions[offset],
positions[offset + 1],
positions[offset + 2]
];
}
function polylineStart(positions) {
return polylinePoint(positions, 0);
}
function polylineEnd(positions) {
return polylinePoint(positions, Math.floor(positions.length / 3) - 1);
}
function reversePolyline(positions) {
const reversed = [];
for (let offset = positions.length - 3; offset >= 0; offset -= 3) {
reversed.push(positions[offset], positions[offset + 1], positions[offset + 2]);
}
return reversed;
}
function appendPolyline(target, source, { skipFirst = true, tolerance = 1e-7 } = {}) {
const startOffset = skipFirst ? 3 : 0;
for (let offset = startOffset; offset + 2 < source.length; offset += 3) {
pushPoint(target, [source[offset], source[offset + 1], source[offset + 2]], tolerance);
}
}
function closePolylineIfNeeded(positions, tolerance) {
if (positions.length < 12) {
return positions;
}
const start = polylineStart(positions);
const end = polylineEnd(positions);
if (pointDistance(start, end) > tolerance) {
return positions;
}
const output = Array.from(positions);
output[output.length - 3] = output[0];
output[output.length - 2] = output[1];
output[output.length - 1] = output[2];
return new Float32Array(output);
}
export function stitchTopologyDisplayEdgePolylines(polylines) {
if (!Array.isArray(polylines) || polylines.length <= 1) {
return polylines || [];
}
const tolerance = stitchToleranceForPolylines(polylines);
const chains = polylines
.filter((positions) => positions?.length >= 6)
.map((positions) => Array.from(positions));
for (let index = 0; index < chains.length; index += 1) {
let changed = true;
while (changed) {
changed = false;
for (let candidateIndex = index + 1; candidateIndex < chains.length; candidateIndex += 1) {
const current = chains[index];
const candidate = chains[candidateIndex];
const currentStart = polylineStart(current);
const currentEnd = polylineEnd(current);
const candidateStart = polylineStart(candidate);
const candidateEnd = polylineEnd(candidate);
let merged = null;
if (pointDistance(currentEnd, candidateStart) <= tolerance) {
merged = [...current];
appendPolyline(merged, candidate, { tolerance });
} else if (pointDistance(currentEnd, candidateEnd) <= tolerance) {
merged = [...current];
appendPolyline(merged, reversePolyline(candidate), { tolerance });
} else if (pointDistance(currentStart, candidateEnd) <= tolerance) {
merged = [...candidate];
appendPolyline(merged, current, { tolerance });
} else if (pointDistance(currentStart, candidateStart) <= tolerance) {
merged = reversePolyline(candidate);
appendPolyline(merged, current, { tolerance });
}
if (merged) {
chains[index] = merged;
chains.splice(candidateIndex, 1);
changed = true;
break;
}
}
}
}
return chains
.filter((positions) => positions.length >= 6)
.map((positions) => closePolylineIfNeeded(new Float32Array(positions), tolerance));
}
export function hasTopologyDisplayEdgeProxy(selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
return (
isFloat32Array(proxy.edgePositions) &&
isUint32Array(proxy.edgeIndices) &&
proxy.edgePositions.length >= 6 &&
proxy.edgeIndices.length >= 2
);
}
export function hasTopologyDisplayEdgeClassification(selectorRuntime) {
return hasTopologyDisplayEdgeProxy(selectorRuntime);
}
export function shouldUseTopologyDisplayEdges(selectorRuntime) {
return !selectorRuntime?.surfaceEdgeRendering &&
Number(selectorRuntime?.schemaVersion || 0) < 3 &&
hasTopologyDisplayEdgeProxy(selectorRuntime);
}
export function buildTopologyDisplayEdgePositions(selectorRuntime, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = null
} = {}) {
if (!hasTopologyDisplayEdgeProxy(selectorRuntime)) {
return null;
}
const proxy = selectorRuntime.proxy;
const segmentCount = Math.floor(proxy.edgeIndices.length / 2);
if (segmentCount <= 0) {
return null;
}
const includePartIdSet = normalizePartIdSet(includePartIds);
const excludePartIdSet = normalizePartIdSet(excludePartIds);
const hasPartFilter = includePartIdSet.size > 0 || excludePartIdSet.size > 0;
const visibilityClassSet = normalizeVisibilityClassSet(visibilityClasses);
const edgeRows = Array.isArray(selectorRuntime.edges) ? selectorRuntime.edges : [];
const hasVisibilityClassFilter = Boolean(visibilityClassSet?.size && edgeRows.length);
const hasEdgeRowFilter = hasPartFilter || hasVisibilityClassFilter;
const edgeIds = isUint32Array(proxy.edgeIds) ? proxy.edgeIds : null;
const linePositions = new Float32Array(segmentCount * 6);
let writeOffset = 0;
const writeSegment = (segmentIndex) => {
const startVertexIndex = Number(proxy.edgeIndices[segmentIndex * 2]);
const endVertexIndex = Number(proxy.edgeIndices[(segmentIndex * 2) + 1]);
const startPoint = pointFromProxy(proxy, startVertexIndex);
const endPoint = pointFromProxy(proxy, endVertexIndex);
if (!startPoint || !endPoint || pointDistance(startPoint, endPoint) <= 1e-10) {
return;
}
linePositions[writeOffset] = startPoint[0];
linePositions[writeOffset + 1] = startPoint[1];
linePositions[writeOffset + 2] = startPoint[2];
linePositions[writeOffset + 3] = endPoint[0];
linePositions[writeOffset + 4] = endPoint[1];
linePositions[writeOffset + 5] = endPoint[2];
writeOffset += 6;
};
if (hasEdgeRowFilter) {
if (!edgeRows.length) {
return null;
}
for (let rowIndex = 0; rowIndex < edgeRows.length; rowIndex += 1) {
const row = edgeRows[rowIndex];
if (!edgeRowPassesFilters(row, includePartIdSet, excludePartIdSet, visibilityClassSet)) {
continue;
}
const start = Number(row?.segmentStart || 0);
const count = Number(row?.segmentCount || 0);
if (Number.isInteger(start) && Number.isInteger(count) && start >= 0 && count > 0 && start + count <= segmentCount) {
for (let segmentIndex = start; segmentIndex < start + count; segmentIndex += 1) {
writeSegment(segmentIndex);
}
continue;
}
if (edgeIds) {
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
if (Number(edgeIds[segmentIndex]) === rowIndex) {
writeSegment(segmentIndex);
}
}
}
}
} else {
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
writeSegment(segmentIndex);
}
}
if (writeOffset <= 0) {
return null;
}
return writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset);
}
export function buildTopologyDisplayEdgePolylines(selectorRuntime, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = null
} = {}) {
if (!hasTopologyDisplayEdgeProxy(selectorRuntime)) {
return [];
}
const proxy = selectorRuntime.proxy;
const segmentCount = Math.floor(proxy.edgeIndices.length / 2);
if (segmentCount <= 0) {
return [];
}
const edgeRows = Array.isArray(selectorRuntime.edges) ? selectorRuntime.edges : [];
const edgeIds = isUint32Array(proxy.edgeIds) ? proxy.edgeIds : null;
const includePartIdSet = normalizePartIdSet(includePartIds);
const excludePartIdSet = normalizePartIdSet(excludePartIds);
const hasPartFilter = includePartIdSet.size > 0 || excludePartIdSet.size > 0;
const visibilityClassSet = normalizeVisibilityClassSet(visibilityClasses);
const hasVisibilityClassFilter = Boolean(visibilityClassSet?.size && edgeRows.length);
const hasEdgeRowFilter = hasPartFilter || hasVisibilityClassFilter;
const polylines = [];
if (edgeRows.length) {
for (let rowIndex = 0; rowIndex < edgeRows.length; rowIndex += 1) {
const row = edgeRows[rowIndex];
if (!row) {
continue;
}
if (!edgeRowPassesFilters(row, includePartIdSet, excludePartIdSet, visibilityClassSet)) {
continue;
}
const start = Number(row.segmentStart || 0);
const count = Number(row.segmentCount || 0);
if (Number.isInteger(start) && Number.isInteger(count) && start >= 0 && count > 0 && start + count <= segmentCount) {
polylines.push(...buildTopologyPolylinesForRun(proxy, start, count));
} else if (edgeIds) {
polylines.push(...buildTopologyPolylinesForEdgeId(proxy, edgeIds, rowIndex, segmentCount));
}
}
return stitchTopologyDisplayEdgePolylines(polylines);
}
if (hasEdgeRowFilter) {
return [];
}
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
const rowIndex = edgeIds ? Number(edgeIds[segmentIndex]) : -1;
void rowIndex;
polylines.push(...buildTopologyPolylinesForRun(proxy, segmentIndex, 1));
}
return stitchTopologyDisplayEdgePolylines(polylines);
}
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildTopologyDisplayEdgePositions,
buildTopologyDisplayEdgePolylines,
hasTopologyDisplayEdgeClassification,
hasTopologyDisplayEdgeProxy,
shouldUseTopologyDisplayEdges,
stitchTopologyDisplayEdgePolylines
} from "./topologyDisplayEdges.js";
function runtimeWithEdges(edges, options = {}) {
return {
schemaVersion: options.schemaVersion,
edges,
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5]),
edgeIds: new Uint32Array([0, 1, 2])
}
};
}
test("topology display edges expose proxy availability", () => {
assert.equal(hasTopologyDisplayEdgeProxy(runtimeWithEdges([])), true);
assert.equal(hasTopologyDisplayEdgeProxy({
proxy: {
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0)
}
}), false);
});
test("classified topology display edges expose v2 availability", () => {
assert.equal(hasTopologyDisplayEdgeClassification(runtimeWithEdges([])), true);
assert.equal(hasTopologyDisplayEdgeClassification(runtimeWithEdges([{ relevance: 1 }])), true);
assert.equal(shouldUseTopologyDisplayEdges(runtimeWithEdges([{ relevance: 1 }])), true);
assert.equal(shouldUseTopologyDisplayEdges(runtimeWithEdges([{ relevance: 1 }], { schemaVersion: 3 })), false);
});
test("topology display edges use every valid proxy segment", () => {
const positions = buildTopologyDisplayEdgePositions(runtimeWithEdges([
{ flags: 0, visibilityClass: "feature" },
{ flags: 4, visibilityClass: "seam" },
{ flags: 8, visibilityClass: "tangent" }
]));
assert.deepEqual(Array.from(positions), [
0, 0, 0, 1, 0, 0,
2, 0, 0, 3, 0, 0,
4, 0, 0, 5, 0, 0
]);
});
test("topology display edges can filter by visibility class", () => {
const positions = buildTopologyDisplayEdgePositions(runtimeWithEdges([
{ flags: 0, visibilityClass: "feature" },
{ flags: 4, visibilityClass: "seam" },
{ flags: 8, visibilityClass: "tangent" }
]), { visibilityClasses: ["feature"] });
assert.deepEqual(Array.from(positions), [
0, 0, 0, 1, 0, 0
]);
});
test("topology display edges can filter by focused occurrence ids", () => {
const runtime = runtimeWithEdges([
{ occurrenceId: "part-a", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", segmentStart: 1, segmentCount: 1 },
{ occurrenceId: "part-a.child", segmentStart: 2, segmentCount: 1 }
]);
assert.deepEqual(Array.from(buildTopologyDisplayEdgePositions(runtime, { includePartIds: ["part-a"] })), [
0, 0, 0, 1, 0, 0,
4, 0, 0, 5, 0, 0
]);
assert.deepEqual(Array.from(buildTopologyDisplayEdgePositions(runtime, { excludePartIds: ["part-a"] })), [
2, 0, 0, 3, 0, 0
]);
});
test("topology display edge polylines can filter by visibility class and focused ids", () => {
const runtime = runtimeWithEdges([
{ occurrenceId: "part-a", visibilityClass: "feature", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", visibilityClass: "tangent", segmentStart: 1, segmentCount: 1 },
{ occurrenceId: "part-a.child", visibilityClass: "tangent", segmentStart: 2, segmentCount: 1 }
]);
const polylines = buildTopologyDisplayEdgePolylines(runtime, {
includePartIds: ["part-a"],
visibilityClasses: ["tangent"]
});
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
4, 0, 0,
5, 0, 0
]);
});
test("topology display edges preserve continuous CAD edge polylines", () => {
const polylines = buildTopologyDisplayEdgePolylines({
edges: [
{
flags: 0,
visibilityClass: "feature",
segmentStart: 0,
segmentCount: 2
}
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2]),
edgeIds: new Uint32Array([0, 0])
}
});
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
0, 0, 0,
1, 0, 0,
2, 0, 0
]);
});
test("topology display edges stitch adjacent CAD edge fragments", () => {
const polylines = buildTopologyDisplayEdgePolylines({
edges: [
{
flags: 0,
visibilityClass: "tangent",
segmentStart: 0,
segmentCount: 1
},
{
flags: 0,
visibilityClass: "tangent",
segmentStart: 1,
segmentCount: 1
}
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2])
}
});
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
0, 0, 0,
1, 0, 0,
2, 0, 0
]);
});
test("topology polyline stitching handles reversed fragments", () => {
const polylines = stitchTopologyDisplayEdgePolylines([
new Float32Array([
0, 0, 0,
1, 0, 0
]),
new Float32Array([
2, 0, 0,
1, 0, 0
])
]);
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
0, 0, 0,
1, 0, 0,
2, 0, 0
]);
});
test("all topology display edges include every valid proxy segment", () => {
const positions = buildTopologyDisplayEdgePositions(runtimeWithEdges([
{ flags: 0, relevance: 10 },
{ flags: 4, relevance: 0 },
{ flags: 8, relevance: 0 }
]));
assert.deepEqual(Array.from(positions), [
0, 0, 0, 1, 0, 0,
2, 0, 0, 3, 0, 0,
4, 0, 0, 5, 0, 0
]);
});
@@ -0,0 +1,39 @@
export function cadWebGlRendererAttributes({
alpha = true,
antialias = true,
powerPreference = "high-performance",
preserveDrawingBuffer = false,
logarithmicDepthBuffer = true
} = {}) {
return {
alpha,
antialias,
powerPreference,
preserveDrawingBuffer,
logarithmicDepthBuffer
};
}
export function fallbackCadWebGlRendererAttributes(options = {}) {
return {
...cadWebGlRendererAttributes(options),
antialias: false,
powerPreference: "default",
logarithmicDepthBuffer: false
};
}
export function createCadWebGlRenderer(THREE, {
allowFallback = false,
isRecoverableError = () => true,
...options
} = {}) {
try {
return new THREE.WebGLRenderer(cadWebGlRendererAttributes(options));
} catch (error) {
if (!allowFallback || !isRecoverableError(error)) {
throw error;
}
return new THREE.WebGLRenderer(fallbackCadWebGlRendererAttributes(options));
}
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cadWebGlRendererAttributes,
fallbackCadWebGlRendererAttributes
} from "./webglRenderer.js";
test("shared CAD WebGL renderer attributes match viewer depth defaults", () => {
assert.deepEqual(cadWebGlRendererAttributes(), {
alpha: true,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: false,
logarithmicDepthBuffer: true
});
assert.equal(cadWebGlRendererAttributes({ preserveDrawingBuffer: true }).preserveDrawingBuffer, true);
});
test("shared CAD WebGL renderer fallback preserves snapshot/viewer compatibility knobs", () => {
assert.deepEqual(fallbackCadWebGlRendererAttributes(), {
alpha: true,
antialias: false,
powerPreference: "default",
preserveDrawingBuffer: false,
logarithmicDepthBuffer: false
});
});
@@ -0,0 +1,4 @@
export * from "./common/cadScene.js";
export * from "./common/renderModel.js";
export * from "./common/source.js";
export * from "./common/themeSettings.js";
@@ -0,0 +1,531 @@
import { mergeBounds } from "../urdf/kinematics.js";
const IDENTITY_TRANSFORM = Object.freeze([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
function toTransformArray(value) {
if (!Array.isArray(value) || value.length !== 16) {
return [...IDENTITY_TRANSFORM];
}
return value.map((component, index) => Number.isFinite(Number(component)) ? Number(component) : IDENTITY_TRANSFORM[index]);
}
export function assemblyMeshDescriptor(topologyManifest) {
const mesh = topologyManifest?.assembly?.mesh;
return mesh && typeof mesh === "object" ? mesh : null;
}
export function assemblyUsesSelfContainedMesh(topologyManifest) {
return String(assemblyMeshDescriptor(topologyManifest)?.addressing || "").trim() === "gltf-node-extras";
}
export function assemblyRootFromTopology(topologyManifest) {
const root = topologyManifest?.assembly?.root;
return root && typeof root === "object" ? root : null;
}
function toVectorArray(value) {
if (!Array.isArray(value) || value.length < 3) {
return null;
}
const vector = value.slice(0, 3).map((component) => Number(component));
return vector.every((component) => Number.isFinite(component)) ? vector : null;
}
function normalizeMateEndpoint(endpoint) {
if (!endpoint || typeof endpoint !== "object") {
return null;
}
const result = {
part: String(endpoint.part || "").trim(),
frame: String(endpoint.frame || "").trim()
};
const position = toVectorArray(endpoint.position);
const orientation = toVectorArray(endpoint.orientation);
if (position) {
result.position = position;
}
if (orientation) {
result.orientation = orientation;
}
const axes = endpoint.axes && typeof endpoint.axes === "object" ? endpoint.axes : null;
if (axes) {
const normalizedAxes = {};
for (const key of ["x", "y", "z"]) {
const axis = toVectorArray(axes[key]);
if (axis) {
normalizedAxes[key] = axis;
}
}
if (Object.keys(normalizedAxes).length) {
result.axes = normalizedAxes;
}
}
return result.position || result.orientation || result.part || result.frame ? result : null;
}
export function assemblyMatesFromTopology(topologyManifest) {
const mates = topologyManifest?.assemblyMates;
if (!Array.isArray(mates)) {
return [];
}
return mates
.filter((mate) => mate && typeof mate === "object")
.map((mate, index) => {
const id = String(mate.id || `m${index + 1}`).trim() || `m${index + 1}`;
return {
id,
label: String(mate.label || id).trim() || id,
sourceLabel: String(mate.sourceLabel || mate.name || "").trim(),
type: String(mate.type || mate.relation || "mate").trim(),
relation: String(mate.relation || mate.type || "mate").trim(),
fixed: String(mate.fixed || "").trim(),
moving: String(mate.moving || "").trim(),
parameters: mate.parameters && typeof mate.parameters === "object" ? mate.parameters : {},
fixedEndpoint: normalizeMateEndpoint(mate.fixedEndpoint),
movingEndpoint: normalizeMateEndpoint(mate.movingEndpoint)
};
});
}
export function flattenAssemblyLeafParts(root) {
const leafParts = [];
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
const children = Array.isArray(node?.children) ? node.children : [];
if (children.length) {
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
continue;
}
if (String(node?.nodeType || "").trim() === "part") {
leafParts.push(node);
}
}
return leafParts;
}
export function flattenAssemblyNodes(root) {
const nodes = [];
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
nodes.push(node);
const children = Array.isArray(node?.children) ? node.children : [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return nodes;
}
export function findAssemblyNode(root, nodeId) {
const normalizedNodeId = String(nodeId || "").trim();
if (!root || !normalizedNodeId || normalizedNodeId === "root") {
return root || null;
}
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (String(node?.id || "").trim() === normalizedNodeId) {
return node;
}
const children = Array.isArray(node?.children) ? node.children : [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return null;
}
export function rootAssemblyInspectionNodeId(root) {
return String(root?.id || "").trim() || "root";
}
export function normalizeAssemblyInspectionNodeId(root, nodeId) {
if (!root) {
return "";
}
const rootId = rootAssemblyInspectionNodeId(root);
const normalizedNodeId = String(nodeId || "").trim();
if (!normalizedNodeId || normalizedNodeId === "root" || normalizedNodeId === rootId) {
return rootId;
}
const node = findAssemblyNode(root, normalizedNodeId);
return String(node?.id || "").trim() || rootId;
}
export function assemblyInspectionNode(root, nodeId) {
if (!root) {
return null;
}
return findAssemblyNode(root, normalizeAssemblyInspectionNodeId(root, nodeId)) || root;
}
function directChildAssemblyNodeIds(node) {
return (Array.isArray(node?.children) ? node.children : [])
.map((child) => String(child?.id || "").trim())
.filter(Boolean);
}
export function selectableAssemblyNodeIdsForInspection(root, nodeId) {
const inspectedNode = assemblyInspectionNode(root, nodeId);
return directChildAssemblyNodeIds(inspectedNode);
}
export function treeSelectableAssemblyNodeIdsForInspection(root, nodeId) {
const inspectedNode = assemblyInspectionNode(root, nodeId);
return directChildAssemblyNodeIds(inspectedNode);
}
export function focusedLeafPartIdsForAssemblyInspection(root, nodeId) {
const inspectedNodeId = normalizeAssemblyInspectionNodeId(root, nodeId);
const rootId = rootAssemblyInspectionNodeId(root);
if (!root || !inspectedNodeId || inspectedNodeId === rootId) {
return [];
}
return descendantLeafPartIds(assemblyInspectionNode(root, inspectedNodeId));
}
export function descendantLeafPartIds(node) {
return flattenAssemblyLeafParts(node)
.map((part) => String(part?.id || "").trim())
.filter(Boolean);
}
export function representativeAssemblyLeafPartId(node) {
const nodeId = String(node?.id || "").trim();
if (!node) {
return "";
}
if (String(node?.nodeType || "").trim() === "part") {
return nodeId;
}
const declaredLeafPartIds = Array.isArray(node?.leafPartIds)
? node.leafPartIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
if (declaredLeafPartIds.length) {
return declaredLeafPartIds[0];
}
return descendantLeafPartIds(node)[0] || nodeId;
}
export function buildAssemblyLeafToNodePickMap(nodes) {
const map = new Map();
for (const node of Array.isArray(nodes) ? nodes : []) {
const nodeId = String(node?.id || "").trim();
if (!nodeId) {
continue;
}
const leafPartIds = Array.isArray(node?.leafPartIds) && node.leafPartIds.length
? node.leafPartIds
: descendantLeafPartIds(node);
for (const leafPartId of leafPartIds) {
const normalizedLeafPartId = String(leafPartId || "").trim();
if (normalizedLeafPartId) {
map.set(normalizedLeafPartId, nodeId);
}
}
}
return map;
}
export function resolveAssemblyPickedPartId(partId, {
pickPartIdMap,
validLeafPartIds = []
} = {}) {
const normalizedPartId = String(partId || "").trim();
if (!normalizedPartId) {
return "";
}
const validLeafPartIdSet = validLeafPartIds instanceof Set
? validLeafPartIds
: new Set(
(Array.isArray(validLeafPartIds) ? validLeafPartIds : [])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const mappedPartId = pickPartIdMap instanceof Map
? String(pickPartIdMap.get(normalizedPartId) || "").trim()
: "";
if (mappedPartId) {
return mappedPartId;
}
if (validLeafPartIdSet.size && validLeafPartIdSet.has(normalizedPartId)) {
return normalizedPartId;
}
return mappedPartId || normalizedPartId;
}
export function leafPartIdsForAssemblySelection(partId, {
assemblyPartMap,
fallbackPartId = "",
validLeafPartIds = []
} = {}) {
const normalizedPartId = String(partId || "").trim();
const validLeafPartIdSet = validLeafPartIds instanceof Set
? validLeafPartIds
: new Set(
(Array.isArray(validLeafPartIds) ? validLeafPartIds : [])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const leafIdIsValid = (id) => {
return !validLeafPartIdSet.size || validLeafPartIdSet.has(id);
};
const normalizeLeafIds = (leafPartIds) => {
const seen = new Set();
const result = [];
for (const leafPartId of Array.isArray(leafPartIds) ? leafPartIds : []) {
const normalizedLeafPartId = String(leafPartId || "").trim();
if (!normalizedLeafPartId || seen.has(normalizedLeafPartId) || !leafIdIsValid(normalizedLeafPartId)) {
continue;
}
seen.add(normalizedLeafPartId);
result.push(normalizedLeafPartId);
}
return result;
};
if (normalizedPartId) {
const selectedNode = assemblyPartMap instanceof Map
? assemblyPartMap.get(normalizedPartId) || null
: null;
const selectedLeafPartIds = selectedNode
? normalizeLeafIds(descendantLeafPartIds(selectedNode))
: normalizeLeafIds([normalizedPartId]);
if (selectedLeafPartIds.length) {
return selectedLeafPartIds;
}
}
const normalizedFallbackPartId = String(fallbackPartId || "").trim();
return normalizeLeafIds([normalizedFallbackPartId]);
}
export function assemblyBreadcrumb(root, nodeId) {
const normalizedNodeId = String(nodeId || "").trim();
if (!root) {
return [];
}
const path = [];
function visit(node) {
path.push(node);
if (!normalizedNodeId || normalizedNodeId === "root" || String(node?.id || "").trim() === normalizedNodeId) {
return true;
}
for (const child of Array.isArray(node?.children) ? node.children : []) {
if (visit(child)) {
return true;
}
}
path.pop();
return false;
}
return visit(root) ? [...path] : [root];
}
function meshPartId(part) {
return String(part?.occurrenceId || part?.id || "").trim();
}
function meshPartNumericValue(part, key) {
return Math.max(0, Math.floor(Number(part?.[key]) || 0));
}
function meshPartIdMatches(part, ids) {
const partIds = [
String(part?.occurrenceId || "").trim(),
String(part?.id || "").trim()
].filter(Boolean);
return partIds.some((partId) => ids.has(partId));
}
function meshPartIdHasPrefix(part, prefixes) {
const partIds = [
String(part?.occurrenceId || "").trim(),
String(part?.id || "").trim()
].filter(Boolean);
return partIds.some((partId) => prefixes.some((prefix) => partId.startsWith(prefix)));
}
function meshPartsForTopologyLeaf(meshData, manifestPart) {
const allParts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const ids = new Set(
[manifestPart?.occurrenceId, manifestPart?.id]
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const exactParts = allParts.filter((part) => meshPartIdMatches(part, ids));
if (exactParts.length) {
return exactParts;
}
const prefixes = [...ids].map((id) => `${id}.`);
return prefixes.length ? allParts.filter((part) => meshPartIdHasPrefix(part, prefixes)) : [];
}
export function buildSelfContainedAssemblyMeshData(topologyManifest, meshData) {
const assemblyRoot = assemblyRootFromTopology(topologyManifest);
if (!assemblyRoot) {
throw new Error("Assembly topology is missing assembly.root");
}
const manifestParts = flattenAssemblyLeafParts(assemblyRoot);
const partSources = [];
const meshlessLeafPartIds = [];
for (const manifestPart of manifestParts) {
const partId = String(manifestPart?.id || manifestPart?.occurrenceId || "").trim();
const occurrenceId = String(manifestPart?.occurrenceId || manifestPart?.id || "").trim();
const sourceParts = meshPartsForTopologyLeaf(meshData, manifestPart);
if (!sourceParts.length) {
const meshlessLeafPartId = occurrenceId || partId;
if (meshlessLeafPartId) {
meshlessLeafPartIds.push(meshlessLeafPartId);
}
continue;
}
partSources.push({
manifestPart,
sourceParts
});
}
if (!partSources.length) {
throw new Error("Assembly topology did not match any renderable GLB nodes");
}
let totalVertexCount = 0;
let totalIndexCount = 0;
for (const { sourceParts } of partSources) {
for (const sourcePart of sourceParts) {
totalVertexCount += meshPartNumericValue(sourcePart, "vertexCount");
totalIndexCount += meshPartNumericValue(sourcePart, "triangleCount") * 3;
}
}
const sourceVertices = meshData?.vertices || new Float32Array(0);
const sourceNormals = meshData?.normals || new Float32Array(0);
const sourceColors = meshData?.colors || new Float32Array(0);
const sourceSurfaceEdgeBarycentric = meshData?.surfaceEdgeBarycentric || new Float32Array(0);
const sourceSurfaceEdgeClass = meshData?.surfaceEdgeClass || new Uint8Array(0);
const sourceIndices = meshData?.indices || new Uint32Array(0);
const hasSourceColors = sourceColors.length === sourceVertices.length && sourceColors.length > 0;
const hasSurfaceEdgeAttributes =
sourceSurfaceEdgeBarycentric.length === sourceVertices.length &&
sourceSurfaceEdgeClass.length === sourceVertices.length &&
sourceVertices.length > 0;
const vertices = new Float32Array(totalVertexCount * 3);
const normals = new Float32Array(totalVertexCount * 3);
const colors = hasSourceColors ? new Float32Array(totalVertexCount * 3) : new Float32Array(0);
const surfaceEdgeBarycentric = hasSurfaceEdgeAttributes ? new Float32Array(totalVertexCount * 3) : new Float32Array(0);
const surfaceEdgeClass = hasSurfaceEdgeAttributes ? new Uint8Array(totalVertexCount * 3) : new Uint8Array(0);
const indices = new Uint32Array(totalIndexCount);
const parts = [];
let vertexOffset = 0;
let indexOffset = 0;
for (const { manifestPart, sourceParts } of partSources) {
const partId = String(manifestPart?.id || manifestPart?.occurrenceId || "").trim();
const occurrenceId = String(manifestPart?.occurrenceId || manifestPart?.id || "").trim();
const firstMeshPart = sourceParts[0];
const partVertexOffset = vertexOffset;
const partTriangleOffset = Math.floor(indexOffset / 3);
const sourcePartRanges = [];
for (const sourcePart of sourceParts) {
const sourceVertexOffset = meshPartNumericValue(sourcePart, "vertexOffset");
const sourceVertexCount = meshPartNumericValue(sourcePart, "vertexCount");
const sourceTriangleOffset = meshPartNumericValue(sourcePart, "triangleOffset");
const sourceTriangleCount = meshPartNumericValue(sourcePart, "triangleCount");
const rangeTriangleOffset = Math.floor(indexOffset / 3) - partTriangleOffset;
sourcePartRanges.push({
occurrenceId: meshPartId(sourcePart),
primitiveIndex: meshPartNumericValue(sourcePart, "primitiveIndex"),
triangleOffset: rangeTriangleOffset,
triangleCount: sourceTriangleCount
});
const sourcePositionStart = sourceVertexOffset * 3;
const sourcePositionEnd = sourcePositionStart + sourceVertexCount * 3;
vertices.set(sourceVertices.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
if (sourceNormals.length >= sourcePositionEnd) {
normals.set(sourceNormals.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSourceColors && sourceColors.length >= sourcePositionEnd) {
colors.set(sourceColors.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSurfaceEdgeAttributes && sourceSurfaceEdgeBarycentric.length >= sourcePositionEnd) {
surfaceEdgeBarycentric.set(sourceSurfaceEdgeBarycentric.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSurfaceEdgeAttributes && sourceSurfaceEdgeClass.length >= sourcePositionEnd) {
surfaceEdgeClass.set(sourceSurfaceEdgeClass.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
const sourceIndexStart = sourceTriangleOffset * 3;
const sourceIndexEnd = sourceIndexStart + sourceTriangleCount * 3;
for (let index = sourceIndexStart; index < sourceIndexEnd; index += 1) {
indices[indexOffset] = sourceIndices[index] - sourceVertexOffset + vertexOffset;
indexOffset += 1;
}
vertexOffset += sourceVertexCount;
}
const sourcePath = String(manifestPart?.sourcePath || "").trim();
const displayName = String(
manifestPart?.displayName ||
manifestPart?.instancePath ||
manifestPart?.occurrenceId ||
sourcePath ||
firstMeshPart?.label ||
firstMeshPart?.name ||
meshPartId(firstMeshPart)
).trim();
const sourceBounds = mergeBounds(sourceParts.map((part) => part.bounds));
parts.push({
...firstMeshPart,
...manifestPart,
id: partId || occurrenceId || meshPartId(firstMeshPart),
occurrenceId: occurrenceId || partId || meshPartId(firstMeshPart),
name: displayName,
label: displayName,
nodeType: "part",
sourceKind: String(manifestPart?.sourceKind || "").trim(),
sourcePath,
partSourcePath: sourcePath,
sourceBounds,
bounds: manifestPart?.bbox || sourceBounds,
transform: toTransformArray(manifestPart?.worldTransform || manifestPart?.transform),
hasSourceColors: manifestPartUsesSourceColors(manifestPart) && (
hasSourceColors || sourceParts.some((part) => !!part.hasSourceColors || !!part.color)
),
vertexOffset: partVertexOffset,
vertexCount: vertexOffset - partVertexOffset,
triangleOffset: partTriangleOffset,
triangleCount: Math.floor(indexOffset / 3) - partTriangleOffset,
sourcePartRanges,
edgeIndexOffset: 0,
edgeIndexCount: 0
});
}
return {
...meshData,
vertices,
indices,
normals,
colors,
surfaceEdgeBarycentric,
surfaceEdgeClass,
edge_indices: new Uint32Array(0),
parts,
bounds: mergeBounds(parts.map((part) => part.bounds)) || meshData?.bounds,
assemblyRoot,
assemblyMates: assemblyMatesFromTopology(topologyManifest),
meshlessLeafPartIds,
partTransformsBaked: true,
has_source_colors: hasSourceColors
};
}
function manifestPartUsesSourceColors(part) {
return part?.useSourceColors !== false;
}
@@ -0,0 +1,545 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
assemblyBreadcrumb,
assemblyInspectionNode,
buildAssemblyLeafToNodePickMap,
buildSelfContainedAssemblyMeshData,
descendantLeafPartIds,
findAssemblyNode,
focusedLeafPartIdsForAssemblyInspection,
flattenAssemblyNodes,
flattenAssemblyLeafParts,
leafPartIdsForAssemblySelection,
normalizeAssemblyInspectionNodeId,
representativeAssemblyLeafPartId,
rootAssemblyInspectionNodeId,
selectableAssemblyNodeIdsForInspection,
treeSelectableAssemblyNodeIdsForInspection,
resolveAssemblyPickedPartId
} from "./meshData.js";
test("self-contained assembly mesh data maps GLB node parts by occurrence id", () => {
const topology = {
assembly: {
mesh: {
url: ".assembly.step.glb?v=abc",
addressing: "gltf-node-extras",
occurrenceIdKey: "cadOccurrenceId"
},
root: {
id: "root",
nodeType: "assembly",
children: [
{
id: "o1.2",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "sample_part",
sourcePath: "parts/sample_part.step",
worldTransform: [
1, 0, 0, 10,
0, 1, 0, 20,
0, 0, 1, 30,
0, 0, 0, 1
],
bbox: {
min: [10, 20, 30],
max: [11, 21, 30]
},
children: []
}
]
}
}
};
const parsedGlbMeshData = {
vertices: new Float32Array([
10, 20, 30,
11, 20, 30,
10, 21, 30
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: {
min: [10, 20, 30],
max: [11, 21, 30]
},
parts: [
{
id: "o1.2",
occurrenceId: "o1.2",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: {
min: [10, 20, 30],
max: [11, 21, 30]
}
}
]
};
const meshData = buildSelfContainedAssemblyMeshData(topology, parsedGlbMeshData);
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.partTransformsBaked, true);
assert.equal(meshData.parts[0].id, "o1.2");
assert.equal(meshData.parts[0].label, "sample_part");
assert.equal(meshData.parts[0].partSourcePath, "parts/sample_part.step");
assert.equal(meshData.parts[0].vertexOffset, 0);
assert.deepEqual(meshData.parts[0].bounds, {
min: [10, 20, 30],
max: [11, 21, 30]
});
assert.deepEqual(Array.from(meshData.vertices), [
10, 20, 30,
11, 20, 30,
10, 21, 30
]);
});
test("self-contained assembly mesh data groups descendant GLB nodes under topology leaves", () => {
const topology = {
assembly: {
mesh: {
url: ".assembly.step.glb?v=abc",
addressing: "gltf-node-extras",
occurrenceIdKey: "cadOccurrenceId"
},
root: {
id: "root",
nodeType: "assembly",
children: [
{
id: "o1.2",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "compound_part",
children: []
}
]
}
}
};
const parsedGlbMeshData = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
10, 0, 0,
11, 0, 0,
10, 1, 0
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: {
min: [0, 0, 0],
max: [11, 1, 0]
},
parts: [
{
id: "o1.2.1",
occurrenceId: "o1.2.1",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
primitiveIndex: 0,
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
}
},
{
id: "o1.2.2",
occurrenceId: "o1.2.2",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
primitiveIndex: 2,
bounds: {
min: [10, 0, 0],
max: [11, 1, 0]
}
}
]
};
const meshData = buildSelfContainedAssemblyMeshData(topology, parsedGlbMeshData);
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].id, "o1.2");
assert.equal(meshData.parts[0].vertexCount, 6);
assert.equal(meshData.parts[0].triangleCount, 2);
assert.deepEqual(meshData.parts[0].sourcePartRanges, [
{
occurrenceId: "o1.2.1",
primitiveIndex: 0,
triangleOffset: 0,
triangleCount: 1
},
{
occurrenceId: "o1.2.2",
primitiveIndex: 2,
triangleOffset: 1,
triangleCount: 1
}
]);
assert.deepEqual(Array.from(meshData.indices), [0, 1, 2, 3, 4, 5]);
assert.deepEqual(meshData.parts[0].sourceBounds, {
min: [0, 0, 0],
max: [11, 1, 0]
});
});
test("self-contained assembly mesh data skips topology leaves without GLB triangles", () => {
const topology = {
assembly: {
mesh: {
url: ".assembly.step.glb?v=abc",
addressing: "gltf-node-extras",
occurrenceIdKey: "cadOccurrenceId"
},
root: {
id: "root",
nodeType: "assembly",
children: [
{
id: "o1.1",
occurrenceId: "o1.1",
nodeType: "part",
displayName: "rendered_part",
children: []
},
{
id: "o1.2",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "wire_only_part",
children: []
}
]
}
}
};
const parsedGlbMeshData = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: [
{
id: "o1.1",
occurrenceId: "o1.1",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
}
}
]
};
const meshData = buildSelfContainedAssemblyMeshData(topology, parsedGlbMeshData);
assert.deepEqual(meshData.parts.map((part) => part.id), ["o1.1"]);
assert.deepEqual(meshData.meshlessLeafPartIds, ["o1.2"]);
assert.equal(meshData.indices.length, 3);
});
test("assembly helpers navigate nested assemblies down to leaf parts", () => {
const root = {
id: "root",
nodeType: "assembly",
displayName: "sample_root",
children: [
{
id: "sample_module",
nodeType: "assembly",
displayName: "sample_module",
children: [
{
id: "sample_part",
nodeType: "part",
displayName: "sample_part",
children: []
}
]
}
]
};
assert.deepEqual(flattenAssemblyLeafParts(root).map((part) => part.id), ["sample_part"]);
assert.deepEqual(flattenAssemblyNodes(root).map((node) => node.id), ["root", "sample_module", "sample_part"]);
assert.equal(findAssemblyNode(root, "sample_module")?.displayName, "sample_module");
assert.deepEqual(assemblyBreadcrumb(root, "sample_part").map((node) => node.id), ["root", "sample_module", "sample_part"]);
assert.deepEqual(descendantLeafPartIds(root.children[0]), ["sample_part"]);
assert.equal(representativeAssemblyLeafPartId(root.children[0]), "sample_part");
});
test("assembly picking maps rendered leaves to scoped assembly nodes", () => {
const root = {
id: "root",
nodeType: "assembly",
children: [
{
id: "module",
occurrenceId: "o1.1",
nodeType: "assembly",
leafPartIds: ["leaf_a", "leaf_b"],
children: [
{
id: "leaf_a",
occurrenceId: "o1.1.1",
nodeType: "part",
sourcePath: "parts/a.step",
children: []
},
{
id: "leaf_b",
occurrenceId: "o1.1.2",
nodeType: "part",
children: []
}
]
}
]
};
const pickPartIdMap = buildAssemblyLeafToNodePickMap(root.children);
assert.deepEqual(
[...pickPartIdMap.entries()],
[
["leaf_a", "module"],
["leaf_b", "module"]
]
);
assert.equal(
resolveAssemblyPickedPartId("leaf_a", {
pickPartIdMap,
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
"module"
);
assert.equal(
resolveAssemblyPickedPartId("legacy_mesh_leaf", {
pickPartIdMap: new Map([["legacy_mesh_leaf", "module"]]),
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
"module"
);
const assemblyPartMap = new Map(flattenAssemblyNodes(root).map((node) => [node.id, node]));
assert.deepEqual(
leafPartIdsForAssemblySelection("module", {
assemblyPartMap,
fallbackPartId: "leaf_a",
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
["leaf_a", "leaf_b"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("leaf_a", {
assemblyPartMap,
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
["leaf_a"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("missing", {
assemblyPartMap,
fallbackPartId: "leaf_b",
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
["leaf_b"]
);
assert.equal(representativeAssemblyLeafPartId(root.children[0]), "leaf_a");
});
test("nested assembly selection resolves descendant render leaves without loading sibling topology", () => {
const root = {
id: "root",
nodeType: "assembly",
children: [
{
id: "outer",
nodeType: "assembly",
children: [
{
id: "inner",
nodeType: "assembly",
children: [
{
id: "leaf_a",
nodeType: "part",
occurrenceId: "o1.1.1.1",
children: []
},
{
id: "leaf_b",
nodeType: "part",
occurrenceId: "o1.1.1.2",
children: []
}
]
}
]
},
{
id: "sibling_leaf",
nodeType: "part",
occurrenceId: "o1.2",
children: []
}
]
};
const assemblyPartMap = new Map(flattenAssemblyNodes(root).map((node) => [node.id, node]));
const validLeafPartIds = flattenAssemblyLeafParts(root).map((node) => node.id);
assert.deepEqual(
leafPartIdsForAssemblySelection("inner", {
assemblyPartMap,
validLeafPartIds
}),
["leaf_a", "leaf_b"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("outer", {
assemblyPartMap,
validLeafPartIds
}),
["leaf_a", "leaf_b"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("root", {
assemblyPartMap,
validLeafPartIds
}),
["leaf_a", "leaf_b", "sibling_leaf"]
);
});
test("assembly inspection helpers keep one inspected node and limit selectable children", () => {
const root = {
id: "root",
nodeType: "assembly",
children: [
{
id: "module",
nodeType: "assembly",
children: [
{
id: "leaf_a",
nodeType: "part",
children: []
},
{
id: "leaf_b",
nodeType: "part",
children: []
}
]
},
{
id: "compound_part",
nodeType: "part",
children: [
{
id: "leaf_c",
nodeType: "part",
children: []
},
{
id: "leaf_d",
nodeType: "part",
children: []
}
]
},
{
id: "sibling",
nodeType: "part",
children: []
}
]
};
assert.equal(rootAssemblyInspectionNodeId(root), "root");
assert.equal(normalizeAssemblyInspectionNodeId(root, ""), "root");
assert.equal(normalizeAssemblyInspectionNodeId(root, "missing"), "root");
assert.equal(normalizeAssemblyInspectionNodeId(root, "leaf_a"), "leaf_a");
assert.equal(assemblyInspectionNode(root, "module")?.id, "module");
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, ""), ["module", "compound_part", "sibling"]);
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, "module"), ["leaf_a", "leaf_b"]);
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, "compound_part"), ["leaf_c", "leaf_d"]);
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, "leaf_a"), []);
assert.equal(selectableAssemblyNodeIdsForInspection(root, "module").includes("sibling"), false);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, ""), ["module", "compound_part", "sibling"]);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, "module"), ["leaf_a", "leaf_b"]);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, "compound_part"), ["leaf_c", "leaf_d"]);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, "leaf_a"), []);
assert.equal(treeSelectableAssemblyNodeIdsForInspection(root, "module").includes("sibling"), false);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, ""), []);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, "module"), ["leaf_a", "leaf_b"]);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, "compound_part"), ["leaf_c", "leaf_d"]);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, "leaf_a"), ["leaf_a"]);
});
test("assembly picking maps rendered leaves to the current selectable node before accepting leaf ids", () => {
const pickPartIdMap = new Map([
["leaf_a", "module"],
["leaf_b", "module"],
["sibling", "sibling"]
]);
const validLeafPartIds = ["leaf_a", "leaf_b", "sibling"];
assert.equal(
resolveAssemblyPickedPartId("leaf_a", { pickPartIdMap, validLeafPartIds }),
"module"
);
assert.equal(
resolveAssemblyPickedPartId("sibling", { pickPartIdMap, validLeafPartIds }),
"sibling"
);
assert.equal(
resolveAssemblyPickedPartId("unknown", { pickPartIdMap, validLeafPartIds }),
"unknown"
);
});
@@ -0,0 +1,20 @@
export async function mapWithConcurrency(items, concurrency, mapper) {
const list = Array.isArray(items) ? items : [];
const workerCount = Math.min(
list.length,
Math.max(1, Math.floor(Number(concurrency) || 1))
);
const results = new Array(list.length);
let nextIndex = 0;
async function runWorker() {
while (nextIndex < list.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(list[currentIndex], currentIndex);
}
}
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
return results;
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mapWithConcurrency } from "./concurrency.js";
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
test("mapWithConcurrency preserves result order while capping active work", async () => {
let activeCount = 0;
let peakActiveCount = 0;
const results = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (value) => {
activeCount += 1;
peakActiveCount = Math.max(peakActiveCount, activeCount);
await delay(5);
activeCount -= 1;
return value * 10;
});
assert.deepEqual(results, [10, 20, 30, 40, 50]);
assert.equal(peakActiveCount, 2);
});
test("mapWithConcurrency handles empty input", async () => {
const results = await mapWithConcurrency([], 4, async () => {
throw new Error("mapper should not run for empty input");
});
assert.deepEqual(results, []);
});
@@ -0,0 +1,174 @@
const CAD_TOKEN_RE = /^\s*#([^\s]*)/;
const OCCURRENCE_SELECTOR_RE = /^o((?:\d+)(?:\.\d+)*)$/;
const OCCURRENCE_ENTITY_SELECTOR_RE = /^o((?:\d+)(?:\.\d+)*)\.([sfev])(\d+)$/;
const ENTITY_SELECTOR_RE = /^([sfev])(\d+)$/;
function selectorTypeForKind(kind) {
if (kind === "s") {
return "shape";
}
if (kind === "f") {
return "face";
}
if (kind === "e") {
return "edge";
}
return "vertex";
}
export function normalizeCadPath(rawCadPath) {
let normalized = String(rawCadPath || "").replace(/\\/g, "/").trim().replace(/^\/+|\/+$/g, "");
if (!normalized) {
return "";
}
if (normalized.split("/").some((part) => !part || part === "." || part === "..")) {
return "";
}
return normalized;
}
function parseStructuredSelector(rawSelector, { inheritedOccurrenceId = "" } = {}) {
const selector = String(rawSelector || "").trim().replace(/^#/, "");
if (!selector) {
return null;
}
const occurrenceEntityMatch = selector.match(OCCURRENCE_ENTITY_SELECTOR_RE);
if (occurrenceEntityMatch) {
const occurrenceId = `o${occurrenceEntityMatch[1]}`;
const kind = occurrenceEntityMatch[2];
const ordinal = Number(occurrenceEntityMatch[3]);
return {
selectorType: selectorTypeForKind(kind),
occurrenceId,
ordinal,
kind,
canonical: `${occurrenceId}.${kind}${ordinal}`
};
}
const occurrenceMatch = selector.match(OCCURRENCE_SELECTOR_RE);
if (occurrenceMatch) {
return {
selectorType: "occurrence",
occurrenceId: `o${occurrenceMatch[1]}`,
ordinal: null,
kind: "",
canonical: `o${occurrenceMatch[1]}`
};
}
const entityMatch = selector.match(ENTITY_SELECTOR_RE);
if (entityMatch) {
const kind = entityMatch[1];
const ordinal = Number(entityMatch[2]);
if (inheritedOccurrenceId) {
return {
selectorType: selectorTypeForKind(kind),
occurrenceId: inheritedOccurrenceId,
ordinal,
kind,
canonical: `${inheritedOccurrenceId}.${kind}${ordinal}`
};
}
return {
selectorType: selectorTypeForKind(kind),
occurrenceId: "",
ordinal,
kind,
canonical: `${kind}${ordinal}`
};
}
return {
selectorType: "opaque",
occurrenceId: "",
ordinal: null,
kind: "",
canonical: selector
};
}
export function parseCadRefSelector(rawSelector, options = {}) {
return parseStructuredSelector(rawSelector, options);
}
export function normalizeCadRefSelectors(selectors) {
const rawSelectors = Array.isArray(selectors)
? selectors.flatMap((selector) => String(selector || "").split(","))
: String(selectors || "").split(",");
const normalizedSelectors = [];
let inheritedOccurrenceId = "";
for (const rawSelector of rawSelectors) {
const parsedSelector = parseStructuredSelector(rawSelector, { inheritedOccurrenceId });
if (!parsedSelector) {
continue;
}
normalizedSelectors.push(parsedSelector.canonical);
if (parsedSelector.occurrenceId) {
inheritedOccurrenceId = parsedSelector.occurrenceId;
}
}
return normalizedSelectors;
}
export function sortCadRefSelectors(selectors) {
const normalizedSelectors = normalizeCadRefSelectors(selectors);
const uniqueSelectors = [...new Set(normalizedSelectors.filter(Boolean))];
const rank = {
occurrence: 1,
shape: 2,
face: 3,
edge: 4,
vertex: 5,
opaque: 6
};
return uniqueSelectors
.map((selector, index) => ({ selector, index, parsed: parseStructuredSelector(selector) }))
.sort((left, right) => {
const leftOccurrence = left.parsed?.occurrenceId || "";
const rightOccurrence = right.parsed?.occurrenceId || "";
if (leftOccurrence !== rightOccurrence) {
return leftOccurrence.localeCompare(rightOccurrence);
}
const leftRank = rank[left.parsed?.selectorType] ?? 99;
const rightRank = rank[right.parsed?.selectorType] ?? 99;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
const leftOrdinal = Number(left.parsed?.ordinal || 0);
const rightOrdinal = Number(right.parsed?.ordinal || 0);
if (leftOrdinal !== rightOrdinal) {
return leftOrdinal - rightOrdinal;
}
if (left.selector !== right.selector) {
return left.selector.localeCompare(right.selector);
}
return left.index - right.index;
})
.map((item) => item.selector);
}
export function parseCadRefToken(copyText) {
const match = String(copyText || "").trim().match(CAD_TOKEN_RE);
if (!match) {
return null;
}
const selectorText = String(match[1] || "").trim();
return {
token: match[0],
cadPath: "",
selectors: normalizeCadRefSelectors(selectorText)
};
}
export function buildCadRefToken({ cadPath = "", selector = "", selectors } = {}) {
void cadPath;
const selectorList = selectors !== undefined ? sortCadRefSelectors(selectors) : sortCadRefSelectors(selector ? [selector] : []);
if (!selectorList.length) {
return "#";
}
return `#${selectorList.join(",")}`;
}
@@ -0,0 +1 @@
export * from "../common/displaySettings.js";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildDxfPreviewMeshData } from "./buildPreviewMesh.js";
import { parseDxf } from "./parseDxf.js";
function dxfText(lines) {
return `${lines.join("\n")}\n`;
}
function line(start, end) {
return {
kind: "cut",
start,
end
};
}
function rectangle(minX, minY, maxX, maxY) {
return [
line([minX, minY], [maxX, minY]),
line([maxX, minY], [maxX, maxY]),
line([maxX, maxY], [minX, maxY]),
line([minX, maxY], [minX, minY])
];
}
test("DXF preview extrudes multiple disconnected no-bend flat contours", () => {
const dxfData = {
geometry: {
lines: [
...rectangle(0, 0, 10, 10),
...rectangle(5, 20, 15, 30)
],
arcs: [],
circles: []
},
defaultThicknessMm: 2
};
const meshData = buildDxfPreviewMeshData(dxfData, 2);
assert.equal(meshData.triangle_count > 0, true);
assert.equal(meshData.guide_line_segments.length, 0);
assert.deepEqual(meshData.bounds.min, [0, -1, 0]);
assert.deepEqual(meshData.bounds.max, [15, 1, 30]);
});
test("DXF preview extrudes bulged lwpolyline contours", () => {
const quarterBulge = Math.tan(Math.PI / 8);
const dxfData = parseDxf(dxfText([
"0", "SECTION",
"2", "ENTITIES",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "0",
"20", "0",
"10", "10",
"20", "0",
"10", "10",
"20", "10",
"10", "0",
"20", "10",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "7",
"20", "5",
"42", String(quarterBulge),
"10", "5",
"20", "7",
"42", String(quarterBulge),
"10", "3",
"20", "5",
"42", String(quarterBulge),
"10", "5",
"20", "3",
"42", String(quarterBulge),
"0", "ENDSEC",
"0", "EOF"
]));
const meshData = buildDxfPreviewMeshData(dxfData, 2);
assert.equal(meshData.triangle_count > 0, true);
assert.equal(meshData.guide_line_segments.length, 0);
assert.deepEqual(meshData.bounds.min, [0, -1, 0]);
assert.deepEqual(meshData.bounds.max, [10, 1, 10]);
});
@@ -0,0 +1,504 @@
const DXF_RENDER_SCHEMA_VERSION = 1;
const SUPPORTED_ENTITY_TYPES = new Set(["LINE", "ARC", "CIRCLE", "LWPOLYLINE"]);
const ANGLE_EPSILON = 1e-9;
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function semanticKindForLayer(layerName) {
return String(layerName || "").trim().toLowerCase().includes("bend") ? "bend" : "cut";
}
function normalizeLayerName(value) {
const text = String(value || "").trim();
return text || "0";
}
function normalizeAngle(angleDeg) {
const value = angleDeg % 360;
return value < 0 ? value + 360 : value;
}
function angleInSweep(angleDeg, startAngleDeg, sweepAngleDeg) {
const absSweepAngleDeg = Math.abs(sweepAngleDeg);
if (absSweepAngleDeg >= 360 - ANGLE_EPSILON) {
return true;
}
if (sweepAngleDeg >= 0) {
const normalizedDelta = (normalizeAngle(angleDeg) - normalizeAngle(startAngleDeg) + 360) % 360;
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
}
const normalizedDelta = (normalizeAngle(startAngleDeg) - normalizeAngle(angleDeg) + 360) % 360;
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
}
function pointOnCircle(center, radius, angleDeg) {
const radians = (angleDeg * Math.PI) / 180;
return [
center[0] + radius * Math.cos(radians),
center[1] + radius * Math.sin(radians)
];
}
function arcExtremaPoints(arc) {
const endAngleDeg = arc.startAngleDeg + arc.sweepAngleDeg;
const points = [
pointOnCircle(arc.center, arc.radius, arc.startAngleDeg),
pointOnCircle(arc.center, arc.radius, endAngleDeg)
];
for (const candidateAngle of [0, 90, 180, 270]) {
if (angleInSweep(candidateAngle, arc.startAngleDeg, arc.sweepAngleDeg)) {
points.push(pointOnCircle(arc.center, arc.radius, candidateAngle));
}
}
return points;
}
function expandBounds(current, nextBounds) {
if (!current) {
return nextBounds;
}
return {
minX: Math.min(current.minX, nextBounds.minX),
minY: Math.min(current.minY, nextBounds.minY),
maxX: Math.max(current.maxX, nextBounds.maxX),
maxY: Math.max(current.maxY, nextBounds.maxY)
};
}
function lineBounds(line) {
return {
minX: Math.min(line.start[0], line.end[0]),
minY: Math.min(line.start[1], line.end[1]),
maxX: Math.max(line.start[0], line.end[0]),
maxY: Math.max(line.start[1], line.end[1])
};
}
function circleBounds(circle) {
return {
minX: circle.center[0] - circle.radius,
minY: circle.center[1] - circle.radius,
maxX: circle.center[0] + circle.radius,
maxY: circle.center[1] + circle.radius
};
}
function arcBounds(arc) {
const points = arcExtremaPoints(arc);
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
return {
minX: Math.min(...xs),
minY: Math.min(...ys),
maxX: Math.max(...xs),
maxY: Math.max(...ys)
};
}
function screenPoint(point, { minX, maxY }) {
return [point[0] - minX, maxY - point[1]];
}
function formatNumber(value) {
const rounded = Math.round(toFiniteNumber(value) * 1_000_000) / 1_000_000;
return Math.abs(rounded) < ANGLE_EPSILON ? 0 : rounded;
}
function parseRecordPairs(text) {
const normalized = String(text || "").replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
if (lines.length && lines[lines.length - 1] === "") {
lines.pop();
}
if (lines.length % 2 !== 0) {
throw new Error("DXF group code stream is malformed");
}
const pairs = [];
for (let index = 0; index < lines.length; index += 2) {
const code = Number.parseInt(lines[index].trim(), 10);
if (!Number.isFinite(code)) {
throw new Error(`Invalid DXF group code: ${JSON.stringify(lines[index])}`);
}
pairs.push({
code,
value: lines[index + 1] ?? ""
});
}
return pairs;
}
function parseHeader(records) {
let sourceUnits = 0;
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
if (record.code !== 9) {
continue;
}
const variableName = String(record.value || "").trim();
const valueRecord = records[index + 1];
if (!valueRecord) {
continue;
}
if (variableName === "$INSUNITS") {
sourceUnits = Math.max(0, Math.trunc(toFiniteNumber(valueRecord.value, 0)));
}
}
return {
sourceUnits,
defaultThicknessMm: 0
};
}
function parseLineEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const startX = toFiniteNumber(records.find((record) => record.code === 10)?.value);
const startY = toFiniteNumber(records.find((record) => record.code === 20)?.value);
const endX = toFiniteNumber(records.find((record) => record.code === 11)?.value);
const endY = toFiniteNumber(records.find((record) => record.code === 21)?.value);
return {
layer,
start: [startX, startY],
end: [endX, endY]
};
}
function parseArcEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
if (radius <= 0) {
throw new Error("Invalid DXF arc radius");
}
const startAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 50)?.value));
const endAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 51)?.value));
let sweepAngleDeg = (endAngleDeg - startAngleDeg + 360) % 360;
if (sweepAngleDeg <= ANGLE_EPSILON) {
sweepAngleDeg = 360;
}
return {
layer,
center: [
toFiniteNumber(records.find((record) => record.code === 10)?.value),
toFiniteNumber(records.find((record) => record.code === 20)?.value)
],
radius,
startAngleDeg,
sweepAngleDeg,
endAngleDeg: startAngleDeg + sweepAngleDeg
};
}
function parseCircleEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
if (radius <= 0) {
throw new Error("Invalid DXF circle radius");
}
return {
layer,
center: [
toFiniteNumber(records.find((record) => record.code === 10)?.value),
toFiniteNumber(records.find((record) => record.code === 20)?.value)
],
radius
};
}
function arcFromBulgeSegment(layer, start, end, bulge) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const chordLength = Math.hypot(dx, dy);
if (chordLength <= ANGLE_EPSILON || Math.abs(bulge) <= ANGLE_EPSILON) {
return null;
}
const includedAngleRad = 4 * Math.atan(bulge);
const radius = (chordLength * (1 + bulge * bulge)) / (4 * Math.abs(bulge));
const midpoint = [
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2
];
const leftNormal = [-dy / chordLength, dx / chordLength];
const centerOffset = (chordLength * (1 - bulge * bulge)) / (4 * bulge);
const center = [
midpoint[0] + leftNormal[0] * centerOffset,
midpoint[1] + leftNormal[1] * centerOffset
];
return {
layer,
center,
radius,
startAngleDeg: normalizeAngle((Math.atan2(start[1] - center[1], start[0] - center[0]) * 180) / Math.PI),
sweepAngleDeg: (includedAngleRad * 180) / Math.PI
};
}
function parseLwpolylineEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const flags = Math.trunc(toFiniteNumber(records.find((record) => record.code === 70)?.value, 0));
const vertices = [];
let currentVertex = null;
for (const record of records) {
if (record.code === 10) {
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
vertices.push(currentVertex);
}
currentVertex = { point: [toFiniteNumber(record.value), Number.NaN], bulge: 0 };
continue;
}
if (record.code === 20 && currentVertex) {
currentVertex.point[1] = toFiniteNumber(record.value);
continue;
}
if (record.code === 42 && currentVertex) {
currentVertex.bulge = toFiniteNumber(record.value);
}
}
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
vertices.push(currentVertex);
}
if (vertices.length < 2) {
throw new Error("Invalid DXF LWPOLYLINE; expected at least 2 vertices");
}
const lines = [];
const arcs = [];
const addSegment = (startVertex, endVertex) => {
const start = startVertex.point;
const end = endVertex.point;
if (start[0] === end[0] && start[1] === end[1]) {
return;
}
if (Math.abs(startVertex.bulge) > ANGLE_EPSILON) {
const arc = arcFromBulgeSegment(layer, start, end, startVertex.bulge);
if (arc) {
arcs.push(arc);
}
return;
}
lines.push({ layer, start, end });
};
for (let index = 0; index < vertices.length - 1; index += 1) {
addSegment(vertices[index], vertices[index + 1]);
}
if ((flags & 1) !== 0) {
addSegment(vertices[vertices.length - 1], vertices[0]);
}
return { lines, arcs };
}
function parseEntities(records) {
const lines = [];
const arcs = [];
const circles = [];
let index = 0;
while (index < records.length) {
const startRecord = records[index];
if (startRecord.code !== 0) {
index += 1;
continue;
}
const entityType = String(startRecord.value || "").trim().toUpperCase();
if (entityType === "ENDSEC") {
break;
}
const entityRecords = [];
index += 1;
while (index < records.length && records[index].code !== 0) {
entityRecords.push(records[index]);
index += 1;
}
if (!SUPPORTED_ENTITY_TYPES.has(entityType)) {
throw new Error(`Unsupported DXF entity ${entityType}`);
}
if (entityType === "LINE") {
lines.push(parseLineEntity(entityRecords));
continue;
}
if (entityType === "ARC") {
arcs.push(parseArcEntity(entityRecords));
continue;
}
if (entityType === "CIRCLE") {
circles.push(parseCircleEntity(entityRecords));
continue;
}
const polyline = parseLwpolylineEntity(entityRecords);
lines.push(...polyline.lines);
arcs.push(...polyline.arcs);
}
if (!lines.length && !arcs.length && !circles.length) {
throw new Error("No supported DXF entities found");
}
return { lines, arcs, circles };
}
function splitSections(records) {
const sections = new Map();
let index = 0;
while (index < records.length) {
const record = records[index];
if (record.code !== 0 || String(record.value || "").trim().toUpperCase() !== "SECTION") {
index += 1;
continue;
}
const nameRecord = records[index + 1];
const sectionName = String(nameRecord?.value || "").trim().toUpperCase();
index += 2;
const sectionRecords = [];
while (index < records.length) {
const nextRecord = records[index];
if (nextRecord.code === 0 && String(nextRecord.value || "").trim().toUpperCase() === "ENDSEC") {
index += 1;
break;
}
sectionRecords.push(nextRecord);
index += 1;
}
sections.set(sectionName, sectionRecords);
}
return sections;
}
function buildPathRecord(layerName, kind, pathData) {
return {
layer: layerName,
kind,
d: pathData
};
}
function touchLayer(layerSummary, layerName) {
const existing = layerSummary.get(layerName);
if (existing) {
return existing;
}
const next = {
name: layerName,
kind: semanticKindForLayer(layerName),
pathCount: 0,
circleCount: 0
};
layerSummary.set(layerName, next);
return next;
}
export function parseDxf(dxfText, { fileRef = "", sourceUrl = "" } = {}) {
const records = parseRecordPairs(dxfText);
const sections = splitSections(records);
const header = parseHeader(sections.get("HEADER") || []);
const entities = parseEntities(sections.get("ENTITIES") || []);
let rawBounds = null;
for (const line of entities.lines) {
rawBounds = expandBounds(rawBounds, lineBounds(line));
}
for (const arc of entities.arcs) {
rawBounds = expandBounds(rawBounds, arcBounds(arc));
}
for (const circle of entities.circles) {
rawBounds = expandBounds(rawBounds, circleBounds(circle));
}
if (!rawBounds) {
throw new Error("Failed to compute DXF bounds");
}
const width = Math.max(rawBounds.maxX - rawBounds.minX, 0);
const height = Math.max(rawBounds.maxY - rawBounds.minY, 0);
const pathRecords = [];
const circleRecords = [];
const layerSummary = new Map();
for (const line of entities.lines) {
const start = screenPoint(line.start, { minX: rawBounds.minX, maxY: rawBounds.maxY });
const end = screenPoint(line.end, { minX: rawBounds.minX, maxY: rawBounds.maxY });
pathRecords.push(
buildPathRecord(
line.layer,
semanticKindForLayer(line.layer),
`M ${formatNumber(start[0])} ${formatNumber(start[1])} L ${formatNumber(end[0])} ${formatNumber(end[1])}`
)
);
touchLayer(layerSummary, line.layer).pathCount += 1;
}
for (const arc of entities.arcs) {
const start = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg), {
minX: rawBounds.minX,
maxY: rawBounds.maxY
});
const end = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg + arc.sweepAngleDeg), {
minX: rawBounds.minX,
maxY: rawBounds.maxY
});
const largeArcFlag = Math.abs(arc.sweepAngleDeg) > 180 + ANGLE_EPSILON ? 1 : 0;
const sweepFlag = arc.sweepAngleDeg >= 0 ? 0 : 1;
pathRecords.push(
buildPathRecord(
arc.layer,
semanticKindForLayer(arc.layer),
`M ${formatNumber(start[0])} ${formatNumber(start[1])} A ${formatNumber(arc.radius)} ${formatNumber(arc.radius)} 0 ${largeArcFlag} ${sweepFlag} ${formatNumber(end[0])} ${formatNumber(end[1])}`
)
);
touchLayer(layerSummary, arc.layer).pathCount += 1;
}
for (const circle of entities.circles) {
const center = screenPoint(circle.center, { minX: rawBounds.minX, maxY: rawBounds.maxY });
circleRecords.push({
layer: circle.layer,
kind: semanticKindForLayer(circle.layer),
cx: formatNumber(center[0]),
cy: formatNumber(center[1]),
r: formatNumber(circle.radius)
});
touchLayer(layerSummary, circle.layer).circleCount += 1;
}
return {
schemaVersion: DXF_RENDER_SCHEMA_VERSION,
fileRef,
sourceUrl,
sourceUnits: header.sourceUnits,
defaultThicknessMm: formatNumber(header.defaultThicknessMm),
bounds: {
minX: 0,
minY: 0,
maxX: formatNumber(width),
maxY: formatNumber(height),
width: formatNumber(width),
height: formatNumber(height)
},
counts: {
paths: pathRecords.length,
circles: circleRecords.length,
entities: pathRecords.length + circleRecords.length
},
layers: [...layerSummary.keys()].sort().map((name) => layerSummary.get(name)),
geometry: {
lines: entities.lines.map((line) => ({
layer: line.layer,
kind: semanticKindForLayer(line.layer),
start: [formatNumber(line.start[0]), formatNumber(line.start[1])],
end: [formatNumber(line.end[0]), formatNumber(line.end[1])]
})),
arcs: entities.arcs.map((arc) => ({
layer: arc.layer,
kind: semanticKindForLayer(arc.layer),
center: [formatNumber(arc.center[0]), formatNumber(arc.center[1])],
radius: formatNumber(arc.radius),
startAngleDeg: formatNumber(arc.startAngleDeg),
sweepAngleDeg: formatNumber(arc.sweepAngleDeg)
})),
circles: entities.circles.map((circle) => ({
layer: circle.layer,
kind: semanticKindForLayer(circle.layer),
center: [formatNumber(circle.center[0]), formatNumber(circle.center[1])],
radius: formatNumber(circle.radius)
}))
},
paths: pathRecords,
circles: circleRecords
};
}
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseDxf } from "./parseDxf.js";
function dxfText(lines) {
return `${lines.join("\n")}\n`;
}
test("parseDxf normalizes closed lwpolyline, circles, and bends", () => {
const payload = parseDxf(dxfText([
"0", "SECTION",
"2", "HEADER",
"0", "ENDSEC",
"0", "SECTION",
"2", "ENTITIES",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "0",
"20", "0",
"10", "10",
"20", "0",
"10", "10",
"20", "5",
"10", "0",
"20", "5",
"0", "CIRCLE",
"8", "CUT",
"10", "5",
"20", "2.5",
"40", "1",
"0", "LINE",
"8", "BEND",
"10", "3",
"20", "0",
"11", "3",
"21", "5",
"0", "ENDSEC",
"0", "EOF"
]), { fileRef: "test/panel.dxf" });
assert.equal(payload.fileRef, "test/panel.dxf");
assert.equal(payload.defaultThicknessMm, 0);
assert.equal(payload.bounds.width, 10);
assert.equal(payload.bounds.height, 5);
assert.equal(payload.counts.paths, 5);
assert.equal(payload.counts.circles, 1);
assert.equal(payload.geometry.lines.length, 5);
assert.equal(payload.layers.length, 2);
assert.deepEqual(payload.layers.map((layer) => layer.name), ["BEND", "CUT"]);
});
test("parseDxf converts lwpolyline bulges into arcs", () => {
const quarterBulge = Math.tan(Math.PI / 8);
const payload = parseDxf(dxfText([
"0", "SECTION",
"2", "ENTITIES",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "1",
"20", "0",
"42", String(quarterBulge),
"10", "0",
"20", "1",
"42", String(quarterBulge),
"10", "-1",
"20", "0",
"42", String(quarterBulge),
"10", "0",
"20", "-1",
"42", String(quarterBulge),
"0", "ENDSEC",
"0", "EOF"
]), { fileRef: "test/bulged-hole.dxf" });
assert.equal(payload.counts.paths, 4);
assert.equal(payload.counts.circles, 0);
assert.equal(payload.geometry.lines.length, 0);
assert.equal(payload.geometry.arcs.length, 4);
assert.equal(payload.bounds.width, 2);
assert.equal(payload.bounds.height, 2);
assert.deepEqual(payload.geometry.arcs.map((arc) => arc.radius), [1, 1, 1, 1]);
assert.deepEqual(payload.geometry.arcs.map((arc) => arc.sweepAngleDeg), [90, 90, 90, 90]);
assert.match(payload.paths[0].d, /A 1 1 0 0 0 /);
});
@@ -0,0 +1,173 @@
import {
entrySourceFormat,
meshAssetKeyForEntry,
RENDER_FORMAT
} from "./fileFormats.js";
export { meshAssetKeyForEntry } from "./fileFormats.js";
function normalizeString(value) {
return String(value || "").trim();
}
function normalizedKind(entry) {
return normalizeString(entry?.kind).toLowerCase();
}
function primaryAsset(entry) {
if (!entry || typeof entry !== "object") {
return null;
}
const url = normalizeString(entry.url);
if (!url) {
return null;
}
return {
url,
hash: normalizeString(entry.hash),
bytes: Number.isFinite(Number(entry.bytes)) && Number(entry.bytes) >= 0 ? Number(entry.bytes) : 0,
};
}
function relationAsset(entry, key) {
const relation = entry?.relations?.[key];
if (!relation || typeof relation !== "object") {
return null;
}
const url = normalizeString(relation.url);
return url
? {
url,
hash: normalizeString(relation.hash),
bytes: Number.isFinite(Number(relation.bytes)) && Number(relation.bytes) >= 0 ? Number(relation.bytes) : 0,
}
: null;
}
export function entryAsset(entry, key) {
const assetKey = normalizeString(key).toLowerCase();
const kind = normalizedKind(entry);
const sourceFormat = entrySourceFormat(entry);
if (assetKey === "urdf" && kind === "srdf") {
return relationAsset(entry, "urdf");
}
if (
assetKey === kind ||
assetKey === sourceFormat ||
(sourceFormat === RENDER_FORMAT.STEP && ["glb", "topology", "selectortopology", "displayedgetopology"].includes(assetKey)) ||
(kind === "srdf" && assetKey === "srdf")
) {
return primaryAsset(entry);
}
return null;
}
export function entryAssetUrl(entry, key) {
return normalizeString(entryAsset(entry, key)?.url);
}
export function entryAssetHash(entry, key) {
return normalizeString(entryAsset(entry, key)?.hash);
}
export function entryAssetBytes(entry, key) {
const bytes = Number(entryAsset(entry, key)?.bytes);
return Number.isFinite(bytes) && bytes >= 0 ? bytes : 0;
}
export function entryMeshAssetUrl(entry) {
return entryAssetUrl(entry, meshAssetKeyForEntry(entry));
}
export function entryMeshAssetHash(entry) {
return entryAssetHash(entry, meshAssetKeyForEntry(entry));
}
export function entryMeshAssetBytes(entry) {
return entryAssetBytes(entry, meshAssetKeyForEntry(entry));
}
export function entryTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "topology") || entryAssetUrl(entry, "glb");
}
export function entrySelectorTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "selectorTopology") || entryTopologyAssetUrl(entry);
}
export function entryDisplayEdgeTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "displayEdgeTopology") || entryTopologyAssetUrl(entry);
}
export function entryUrdfAssetHash(entry) {
return [
entryAssetHash(entry, "urdf"),
entryAssetHash(entry, "srdf"),
entryAssetHash(entry, "sdf")
].filter(Boolean).join(":");
}
export function entryMeshAssetSignature(entry) {
return String(
entry?.kind === "assembly"
? entryAssetHash(entry, "glb")
: entryMeshAssetHash(entry)
);
}
export function entryReferenceAssetSignature(entry) {
return entryAssetHash(entry, "selectorTopology") || normalizeString(entry?.hash);
}
export function entryHasMesh(entry) {
if (entrySourceFormat(entry) === RENDER_FORMAT.STEP) {
return Boolean(
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "glb")
);
}
const meshKey = meshAssetKeyForEntry(entry);
return Boolean(entryAssetUrl(entry, meshKey) && entryAssetHash(entry, meshKey));
}
export function entryHasUrdf(entry) {
const kind = normalizeString(entry?.kind).toLowerCase();
if (kind === RENDER_FORMAT.SDF) {
return Boolean(entryAssetUrl(entry, "sdf") && entryAssetHash(entry, "sdf"));
}
return Boolean(entryAssetUrl(entry, "urdf") && entryAssetHash(entry, "urdf"));
}
export function entryHasReferences(entry) {
return Boolean(
entrySourceFormat(entry) === RENDER_FORMAT.STEP &&
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "selectorTopology")
);
}
export function entryHasDisplayEdges(entry) {
return Boolean(
entrySourceFormat(entry) === RENDER_FORMAT.STEP &&
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "displayEdgeTopology")
);
}
export function entryHasDxf(entry) {
return Boolean(entryAssetUrl(entry, "dxf") && entryAssetHash(entry, "dxf"));
}
export function entryHasGcode(entry) {
return Boolean(entryAssetUrl(entry, "gcode") && entryAssetHash(entry, "gcode"));
}
export function entryHasImplicitCad(entry) {
return Boolean(entryAssetUrl(entry, "implicit") && entryAssetHash(entry, "implicit"));
}
export function entryStepModuleUrl(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.STEP
? normalizeString(entry?.moduleUrl)
: "";
}
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
entryAssetHash,
entryAssetBytes,
entryAssetUrl,
entryHasDxf,
entryHasGcode,
entryHasDisplayEdges,
entryHasMesh,
entryHasReferences,
entryHasUrdf,
entryMeshAssetBytes,
entryMeshAssetHash,
entryMeshAssetSignature,
entryReferenceAssetSignature,
entryDisplayEdgeTopologyAssetUrl,
entrySelectorTopologyAssetUrl,
entryTopologyAssetUrl,
entryStepModuleUrl,
entryUrdfAssetHash
} from "./entryAssets.js";
function stepEntry(overrides = {}) {
return {
file: "parts/bracket.step",
kind: "part",
url: "/assets/bracket.glb",
hash: "glb-hash",
bytes: 42,
...overrides
};
}
test("entry asset helpers normalize urls and hashes", () => {
const entry = stepEntry({
url: " /mesh.glb ",
hash: " hash-a "
});
assert.equal(entryAssetUrl(entry, "glb"), "/mesh.glb");
assert.equal(entryAssetHash(entry, "glb"), "hash-a");
assert.equal(entryAssetBytes(stepEntry(), "glb"), 42);
assert.equal(entryMeshAssetBytes(stepEntry()), 42);
assert.equal(entryAssetUrl(entry, "missing"), "");
assert.equal(entryAssetHash(null, "glb"), "");
assert.equal(entryAssetBytes(null, "glb"), 0);
});
test("entry mesh signatures distinguish assemblies from simple mesh sidecars", () => {
assert.equal(entryMeshAssetHash(stepEntry()), "glb-hash");
assert.equal(entryMeshAssetSignature(stepEntry()), "glb-hash");
assert.equal(entryMeshAssetSignature(stepEntry({ kind: "assembly" })), "glb-hash");
assert.equal(entryMeshAssetHash({
kind: "stl",
url: "/mesh.stl",
hash: "stl-hash"
}), "stl-hash");
});
test("entry topology urls resolve to the primary STEP GLB", () => {
assert.equal(entrySelectorTopologyAssetUrl(stepEntry()), "/assets/bracket.glb");
assert.equal(entryDisplayEdgeTopologyAssetUrl(stepEntry()), "/assets/bracket.glb");
assert.equal(entryTopologyAssetUrl(stepEntry()), "/assets/bracket.glb");
});
test("STEP module urls are explicit catalog data instead of guessed sidecars", () => {
assert.equal(entryStepModuleUrl(stepEntry()), "");
assert.equal(entryStepModuleUrl(stepEntry({ moduleUrl: " /assets/.bracket.step.js " })), "/assets/.bracket.step.js");
assert.equal(entryStepModuleUrl({ kind: "stl", moduleUrl: "/assets/not-step.js" }), "");
});
test("entry availability helpers preserve existing viewer gates", () => {
assert.equal(entryHasMesh(stepEntry()), true);
assert.equal(entryHasReferences(stepEntry()), true);
assert.equal(entryHasDisplayEdges(stepEntry()), true);
assert.equal(entryHasMesh(stepEntry({ artifact: { ok: false } })), true);
assert.equal(entryHasReferences(stepEntry({ artifact: { ok: false } })), true);
assert.equal(entryHasDisplayEdges(stepEntry({ artifact: { ok: false } })), true);
assert.equal(entryHasDisplayEdges(stepEntry({ hash: "" })), false);
assert.equal(entryHasReferences(stepEntry({ hash: "" })), false);
assert.equal(entryHasDxf({ kind: "dxf", url: "/plate.dxf", hash: "dxf-hash" }), true);
assert.equal(entryHasGcode({ kind: "gcode", url: "/part.gcode", hash: "gcode-hash" }), true);
assert.equal(entryHasUrdf({ kind: "urdf", url: "/robot.urdf", hash: "urdf-hash" }), true);
assert.equal(entryHasUrdf({ kind: "sdf", url: "/robot.sdf", hash: "sdf-hash" }), true);
});
test("robot and reference signatures match persisted session expectations", () => {
assert.equal(entryReferenceAssetSignature(stepEntry()), "glb-hash");
assert.equal(entryUrdfAssetHash({
kind: "srdf",
url: "/robot.srdf",
hash: "srdf-hash",
relations: {
urdf: { url: "/robot.urdf", hash: "urdf-hash" }
}
}), "urdf-hash:srdf-hash");
assert.equal(entryUrdfAssetHash({
kind: "sdf",
url: "/robot.sdf",
hash: "sdf-hash"
}), "sdf-hash");
});
@@ -0,0 +1,207 @@
export const RENDER_FORMAT = Object.freeze({
STEP: "step",
STL: "stl",
THREE_MF: "3mf",
GLB: "glb",
GCODE: "gcode",
DXF: "dxf",
IMPLICIT: "implicit",
URDF: "urdf",
SRDF: "srdf",
SDF: "sdf"
});
export const MESH_RENDER_FORMATS = Object.freeze([
RENDER_FORMAT.STL,
RENDER_FORMAT.THREE_MF,
RENDER_FORMAT.GLB
]);
export const ROBOT_RENDER_FORMATS = Object.freeze([
RENDER_FORMAT.URDF,
RENDER_FORMAT.SRDF,
RENDER_FORMAT.SDF
]);
export function normalizeFormat(value) {
return String(value || "").trim().toLowerCase();
}
export function normalizeRenderFormat(value, { defaultFormat = RENDER_FORMAT.STEP } = {}) {
const normalized = normalizeFormat(value || defaultFormat);
if (normalized === "stp") {
return RENDER_FORMAT.STEP;
}
if (normalized === "gltf") {
return RENDER_FORMAT.GLB;
}
if (
normalized === RENDER_FORMAT.STEP ||
normalized === RENDER_FORMAT.STL ||
normalized === RENDER_FORMAT.THREE_MF ||
normalized === RENDER_FORMAT.GLB ||
normalized === RENDER_FORMAT.GCODE ||
normalized === RENDER_FORMAT.DXF ||
normalized === RENDER_FORMAT.IMPLICIT ||
normalized === RENDER_FORMAT.URDF ||
normalized === RENDER_FORMAT.SRDF ||
normalized === RENDER_FORMAT.SDF
) {
return normalized;
}
return defaultFormat;
}
export function entryKind(entry) {
return normalizeFormat(entry?.kind);
}
export function entrySourceFormat(entry) {
const kind = entryKind(entry);
if (kind === RENDER_FORMAT.DXF) {
return RENDER_FORMAT.DXF;
}
if (kind === RENDER_FORMAT.STL) {
return RENDER_FORMAT.STL;
}
if (kind === RENDER_FORMAT.THREE_MF) {
return RENDER_FORMAT.THREE_MF;
}
if (kind === RENDER_FORMAT.GLB || kind === "gltf") {
return RENDER_FORMAT.GLB;
}
if (kind === RENDER_FORMAT.GCODE) {
return RENDER_FORMAT.GCODE;
}
if (kind === RENDER_FORMAT.IMPLICIT) {
return RENDER_FORMAT.IMPLICIT;
}
if (kind === RENDER_FORMAT.URDF) {
return RENDER_FORMAT.URDF;
}
if (kind === RENDER_FORMAT.SRDF) {
return RENDER_FORMAT.SRDF;
}
if (kind === RENDER_FORMAT.SDF) {
return RENDER_FORMAT.SDF;
}
return RENDER_FORMAT.STEP;
}
export function isMeshRenderFormat(format) {
return MESH_RENDER_FORMATS.includes(normalizeFormat(format));
}
export function isRobotRenderFormat(format) {
return ROBOT_RENDER_FORMATS.includes(normalizeFormat(format));
}
export function meshAssetKeyForFormat(format) {
const normalized = normalizeFormat(format);
return isMeshRenderFormat(normalized) ? normalized : RENDER_FORMAT.GLB;
}
export function meshAssetKeyForEntry(entry) {
return meshAssetKeyForFormat(entrySourceFormat(entry));
}
export function fileSheetKindForEntry(entry) {
if (!entry) {
return "";
}
const kind = entryKind(entry);
if (kind === RENDER_FORMAT.DXF) {
return RENDER_FORMAT.DXF;
}
if (kind === RENDER_FORMAT.URDF) {
return RENDER_FORMAT.URDF;
}
if (kind === RENDER_FORMAT.SRDF) {
return RENDER_FORMAT.SRDF;
}
if (kind === RENDER_FORMAT.SDF) {
return RENDER_FORMAT.SDF;
}
if (kind === RENDER_FORMAT.GCODE) {
return RENDER_FORMAT.GCODE;
}
if (kind === RENDER_FORMAT.IMPLICIT) {
return RENDER_FORMAT.IMPLICIT;
}
if (entrySourceFormat(entry) === RENDER_FORMAT.STEP) {
return RENDER_FORMAT.STEP;
}
if (isMeshRenderFormat(entrySourceFormat(entry))) {
return "mesh";
}
return "";
}
export function fileExtensionFromPath(value, { baseUrl = "" } = {}) {
const rawValue = String(value || "").trim();
if (!rawValue) {
return "";
}
let pathname = rawValue;
try {
pathname = new URL(rawValue, baseUrl || "http://localhost/").pathname;
} catch {
pathname = rawValue.split("?")[0].split("#")[0];
}
const normalizedPath = pathname.toLowerCase();
const slashIndex = normalizedPath.lastIndexOf("/");
const dotIndex = normalizedPath.lastIndexOf(".");
return dotIndex > slashIndex ? normalizedPath.slice(dotIndex) : "";
}
export function renderFormatFromExtension(extension) {
const normalized = normalizeFormat(extension).replace(/^\./, "");
if (normalized === "step" || normalized === "stp") {
return RENDER_FORMAT.STEP;
}
if (normalized === "stl") {
return RENDER_FORMAT.STL;
}
if (normalized === "3mf") {
return RENDER_FORMAT.THREE_MF;
}
if (normalized === "glb" || normalized === "gltf") {
return RENDER_FORMAT.GLB;
}
if (normalized === "gcode") {
return RENDER_FORMAT.GCODE;
}
if (normalized === "implicit" || normalized === "implicit.js" || normalized === "implicit.mjs") {
return RENDER_FORMAT.IMPLICIT;
}
if (normalized === "dxf") {
return RENDER_FORMAT.DXF;
}
if (normalized === "urdf") {
return RENDER_FORMAT.URDF;
}
if (normalized === "srdf") {
return RENDER_FORMAT.SRDF;
}
if (normalized === "sdf") {
return RENDER_FORMAT.SDF;
}
return "";
}
export function renderFormatFromPath(value, options = {}) {
const rawValue = String(value || "").trim();
let pathname = rawValue;
try {
pathname = new URL(rawValue, options.baseUrl || "http://localhost/").pathname;
} catch {
pathname = rawValue.split("?")[0].split("#")[0];
}
const normalizedPath = pathname.toLowerCase();
if (normalizedPath.endsWith(".implicit.js") || normalizedPath.endsWith(".implicit.mjs")) {
return RENDER_FORMAT.IMPLICIT;
}
return renderFormatFromExtension(fileExtensionFromPath(value, options));
}
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
entrySourceFormat,
fileExtensionFromPath,
fileSheetKindForEntry,
isMeshRenderFormat,
isRobotRenderFormat,
meshAssetKeyForEntry,
normalizeRenderFormat,
renderFormatFromPath,
RENDER_FORMAT
} from "./fileFormats.js";
test("entrySourceFormat maps manifest kinds to stable render formats", () => {
assert.equal(entrySourceFormat({ kind: "part" }), RENDER_FORMAT.STEP);
assert.equal(entrySourceFormat({ kind: "assembly" }), RENDER_FORMAT.STEP);
assert.equal(entrySourceFormat({ kind: "dxf" }), RENDER_FORMAT.DXF);
assert.equal(entrySourceFormat({ kind: "stl" }), RENDER_FORMAT.STL);
assert.equal(entrySourceFormat({ kind: "3mf" }), RENDER_FORMAT.THREE_MF);
assert.equal(entrySourceFormat({ kind: "glb" }), RENDER_FORMAT.GLB);
assert.equal(entrySourceFormat({ kind: "gltf" }), RENDER_FORMAT.GLB);
assert.equal(entrySourceFormat({ kind: "gcode" }), RENDER_FORMAT.GCODE);
assert.equal(entrySourceFormat({ kind: "implicit" }), RENDER_FORMAT.IMPLICIT);
assert.equal(entrySourceFormat({ kind: "urdf" }), RENDER_FORMAT.URDF);
assert.equal(entrySourceFormat({ kind: "srdf" }), RENDER_FORMAT.SRDF);
assert.equal(entrySourceFormat({ kind: "sdf" }), RENDER_FORMAT.SDF);
});
test("fileSheetKindForEntry preserves specialized sheet routing", () => {
assert.equal(fileSheetKindForEntry({ kind: "part" }), "step");
assert.equal(fileSheetKindForEntry({ kind: "assembly" }), "step");
assert.equal(fileSheetKindForEntry({ kind: "stl" }), "mesh");
assert.equal(fileSheetKindForEntry({ kind: "3mf" }), "mesh");
assert.equal(fileSheetKindForEntry({ kind: "glb" }), "mesh");
assert.equal(fileSheetKindForEntry({ kind: "gcode" }), "gcode");
assert.equal(fileSheetKindForEntry({ kind: "urdf" }), "urdf");
assert.equal(fileSheetKindForEntry({ kind: "srdf" }), "srdf");
assert.equal(fileSheetKindForEntry({ kind: "sdf" }), "sdf");
assert.equal(fileSheetKindForEntry({ kind: "dxf" }), "dxf");
assert.equal(fileSheetKindForEntry({ kind: "implicit" }), "implicit");
});
test("mesh and robot format predicates stay narrow", () => {
assert.equal(isMeshRenderFormat(RENDER_FORMAT.STL), true);
assert.equal(isMeshRenderFormat(RENDER_FORMAT.THREE_MF), true);
assert.equal(isMeshRenderFormat(RENDER_FORMAT.GLB), true);
assert.equal(isMeshRenderFormat(RENDER_FORMAT.STEP), false);
assert.equal(isRobotRenderFormat(RENDER_FORMAT.URDF), true);
assert.equal(isRobotRenderFormat(RENDER_FORMAT.SRDF), true);
assert.equal(isRobotRenderFormat(RENDER_FORMAT.SDF), true);
});
test("normalizeRenderFormat preserves tab-state format aliases and defaults", () => {
assert.equal(normalizeRenderFormat("stp"), RENDER_FORMAT.STEP);
assert.equal(normalizeRenderFormat("gltf"), RENDER_FORMAT.GLB);
assert.equal(normalizeRenderFormat("srdf"), RENDER_FORMAT.SRDF);
assert.equal(normalizeRenderFormat("3mf"), RENDER_FORMAT.THREE_MF);
assert.equal(normalizeRenderFormat("gcode"), RENDER_FORMAT.GCODE);
assert.equal(normalizeRenderFormat("implicit"), RENDER_FORMAT.IMPLICIT);
assert.equal(normalizeRenderFormat("unknown"), RENDER_FORMAT.STEP);
assert.equal(normalizeRenderFormat("unknown", { defaultFormat: RENDER_FORMAT.DXF }), RENDER_FORMAT.DXF);
});
test("meshAssetKeyForEntry chooses native mesh keys and STEP GLB sidecars", () => {
assert.equal(meshAssetKeyForEntry({ kind: "stl" }), "stl");
assert.equal(meshAssetKeyForEntry({ kind: "3mf" }), "3mf");
assert.equal(meshAssetKeyForEntry({ kind: "glb" }), "glb");
assert.equal(meshAssetKeyForEntry({ kind: "part" }), "glb");
assert.equal(meshAssetKeyForEntry({ kind: "assembly" }), "glb");
});
test("file extension parsing handles URLs, queries, and supported render formats", () => {
assert.equal(fileExtensionFromPath("/assets/bracket.step.glb?v=1"), ".glb");
assert.equal(fileExtensionFromPath("fixtures/plate.dxf#top"), ".dxf");
assert.equal(fileExtensionFromPath("https://example.test/robot.srdf?download=1"), ".srdf");
assert.equal(renderFormatFromPath("/assets/bracket.stp"), RENDER_FORMAT.STEP);
assert.equal(renderFormatFromPath("/assets/bracket.gltf"), RENDER_FORMAT.GLB);
assert.equal(renderFormatFromPath("/assets/toolpath.gcode?download=1"), RENDER_FORMAT.GCODE);
assert.equal(renderFormatFromPath("/assets/orb.implicit.js?download=1"), RENDER_FORMAT.IMPLICIT);
assert.equal(renderFormatFromPath("/assets/orb.implicit.mjs#preview"), RENDER_FORMAT.IMPLICIT);
assert.equal(renderFormatFromPath("/assets/unknown"), "");
});
@@ -0,0 +1,505 @@
const DEFAULT_EXTRUSION_WIDTH_MM = 0.42;
const DEFAULT_TRAVEL_WIDTH_MM = 0.08;
const DEFAULT_EXTRUSION_HEIGHT_MM = 0.18;
const DEFAULT_TRAVEL_HEIGHT_MM = 0.04;
export const DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET = 60000;
export const GCODE_PREVIEW_DETAIL_MIN = 1;
export const GCODE_PREVIEW_DETAIL_MAX = 7;
export const DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL = 3;
const GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS = Object.freeze({
1: 25000,
2: 40000,
3: DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET,
4: 90000,
5: 140000,
6: 220000,
7: 320000
});
const MIN_GCODE_PREVIEW_SEGMENT_BUDGET = 1;
const MIN_SEGMENT_LENGTH_MM = 1e-5;
const GCODE_PREVIEW_Z_LIFT_MM = 0.035;
const DEFAULT_LAYER_COLORS = Object.freeze([
"#2563eb",
"#16a34a",
"#0891b2",
"#ca8a04",
"#7c3aed",
"#0f766e",
"#4f46e5",
"#65a30d"
]);
const SUPPORT_COLOR = "#94a3b8";
const TRAVEL_COLOR = "#64748b";
const FEATURE_CATEGORY_Z_BIAS_MM = Object.freeze({
support: 0.001,
adhesion: 0.002,
infill: 0.003,
calibration: 0.004,
other: 0.004,
surface: 0.005,
wall: 0.006
});
const TRAVEL_Z_BIAS_MM = 0.008;
const RIBBON_VERTICES_PER_SEGMENT = 4;
const RIBBON_INDICES_PER_SEGMENT = 6;
const RIBBON_NORMAL = Object.freeze([0, 0, 1]);
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDetailMode(value) {
return String(value || "").trim().toLowerCase() === "full" ? "full" : "adaptive";
}
function normalizePreviewDetailLevel(value) {
return clamp(
Math.round(toFiniteNumber(value, DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL)),
GCODE_PREVIEW_DETAIL_MIN,
GCODE_PREVIEW_DETAIL_MAX
);
}
function segmentBudgetForPreviewDetailLevel(value) {
const detailLevel = normalizePreviewDetailLevel(value);
return GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS[detailLevel] || DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET;
}
function coordinate(value, index) {
return toFiniteNumber(value?.[index]);
}
function emptyBounds() {
return {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity]
};
}
function finalizeBounds(bounds) {
if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [1, 1, 1]
};
}
return {
min: bounds.min,
max: bounds.max
};
}
function segmentLength(segment) {
const start = segment?.start;
const end = segment?.end;
return Math.hypot(
coordinate(end, 0) - coordinate(start, 0),
coordinate(end, 1) - coordinate(start, 1),
coordinate(end, 2) - coordinate(start, 2)
);
}
function normalizeLayerRange(value, layerCount) {
const maxLayer = Math.max(0, Math.trunc(toFiniteNumber(layerCount, 0)) - 1);
if (!Array.isArray(value) || value.length < 2) {
return [0, maxLayer];
}
const start = clamp(Math.trunc(toFiniteNumber(value[0], 0)), 0, maxLayer);
const end = clamp(Math.trunc(toFiniteNumber(value[1], maxLayer)), 0, maxLayer);
return start <= end ? [start, end] : [end, start];
}
export function normalizeGcodePreviewOptions(options = {}, layerCount = 0) {
const source = options && typeof options === "object" ? options : {};
const detailMode = normalizeDetailMode(source.detailMode);
const detailLevel = normalizePreviewDetailLevel(source.detailLevel);
const defaultSegmentBudget = segmentBudgetForPreviewDetailLevel(detailLevel);
const explicitRange = Array.isArray(source.layerRange)
? source.layerRange
: Number.isFinite(Number(source.maxLayer))
? [0, Number(source.maxLayer)]
: null;
return {
showTravel: normalizeBoolean(source.showTravel, false),
layerRange: normalizeLayerRange(explicitRange, layerCount),
detailMode,
detailLevel,
maxVisibleSegments: detailMode === "full"
? 0
: Math.max(
Math.trunc(toFiniteNumber(source.maxVisibleSegments, defaultSegmentBudget)),
MIN_GCODE_PREVIEW_SEGMENT_BUDGET
),
extrusionWidthMm: Math.max(toFiniteNumber(source.extrusionWidthMm, DEFAULT_EXTRUSION_WIDTH_MM), 0.01),
travelWidthMm: Math.max(toFiniteNumber(source.travelWidthMm, DEFAULT_TRAVEL_WIDTH_MM), 0.01),
extrusionHeightMm: Math.max(toFiniteNumber(source.extrusionHeightMm, DEFAULT_EXTRUSION_HEIGHT_MM), 0.01),
travelHeightMm: Math.max(toFiniteNumber(source.travelHeightMm, DEFAULT_TRAVEL_HEIGHT_MM), 0.01)
};
}
function layerColor(layerIndex) {
const index = Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0)));
return DEFAULT_LAYER_COLORS[index % DEFAULT_LAYER_COLORS.length];
}
function segmentGroupKey(segment) {
if (segment.kind === "travel") {
return "travel";
}
return `layer:${Math.max(0, Math.trunc(toFiniteNumber(segment.layerIndex, 0)))}`;
}
function groupLabel(key, layerIndex) {
if (key === "travel") {
return "Travel";
}
return `Layer ${Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0))) + 1}`;
}
function colorToRgb(color) {
const normalized = String(color || "").trim();
const expanded = /^#[0-9a-fA-F]{3}$/.test(normalized)
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
if (!/^#[0-9a-fA-F]{6}$/.test(expanded)) {
return [1, 1, 1];
}
return [
parseInt(expanded.slice(1, 3), 16) / 255,
parseInt(expanded.slice(3, 5), 16) / 255,
parseInt(expanded.slice(5, 7), 16) / 255
];
}
function visibleSegmentMatches(segment, options) {
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
const [minLayer, maxLayer] = options.layerRange;
if (layerIndex < minLayer || layerIndex > maxLayer) {
return false;
}
if (segment?.kind === "travel") {
return options.showTravel;
}
return segment?.kind === "extrusion";
}
function analyzeVisibleSegments(gcodeData, options) {
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
const segmentsByLayer = new Map();
let visibleSegmentCount = 0;
for (const segment of segments) {
if (!visibleSegmentMatches(segment, options) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
continue;
}
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
segmentsByLayer.set(layerIndex, (segmentsByLayer.get(layerIndex) || 0) + 1);
visibleSegmentCount += 1;
}
return {
visibleSegmentCount,
segmentsByLayer
};
}
function includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride) {
if (layerStride <= 1) {
return true;
}
return (
layerIndex === minLayer ||
layerIndex === maxLayer ||
(layerIndex - minLayer) % layerStride === 0
);
}
function countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) {
let count = 0;
for (const [layerIndex, segmentCount] of segmentsByLayer.entries()) {
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
count += segmentCount;
}
}
return count;
}
function layerStrideForBudget(segmentsByLayer, visibleSegmentCount, options) {
const budget = Math.trunc(toFiniteNumber(options?.maxVisibleSegments, 0));
if (options?.detailMode === "full" || budget <= 0 || visibleSegmentCount <= budget) {
return 1;
}
const [minLayer, maxLayer] = options.layerRange;
const visibleLayerCount = Math.max(1, maxLayer - minLayer + 1);
for (let layerStride = 2; layerStride <= visibleLayerCount; layerStride += 1) {
if (countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) <= budget) {
return layerStride;
}
}
return visibleLayerCount;
}
function countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride) {
let count = 0;
for (const layerIndex of segmentsByLayer.keys()) {
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
count += 1;
}
}
return count;
}
function updateBoundsFromCoordinates(bounds, x, y, z) {
bounds.min[0] = Math.min(bounds.min[0], x);
bounds.min[1] = Math.min(bounds.min[1], y);
bounds.min[2] = Math.min(bounds.min[2], z);
bounds.max[0] = Math.max(bounds.max[0], x);
bounds.max[1] = Math.max(bounds.max[1], y);
bounds.max[2] = Math.max(bounds.max[2], z);
}
function updateSegmentBounds(bounds, segment) {
updateBoundsFromCoordinates(
bounds,
coordinate(segment?.start, 0),
coordinate(segment?.start, 1),
coordinate(segment?.start, 2)
);
updateBoundsFromCoordinates(
bounds,
coordinate(segment?.end, 0),
coordinate(segment?.end, 1),
coordinate(segment?.end, 2)
);
}
function segmentColor(segment, key, layerIndex) {
if (key === "travel") {
return TRAVEL_COLOR;
}
if (segment?.featureCategory === "support") {
return SUPPORT_COLOR;
}
return layerColor(layerIndex);
}
function segmentZOffset(segment) {
if (segment?.kind === "travel") {
return GCODE_PREVIEW_Z_LIFT_MM + TRAVEL_Z_BIAS_MM;
}
const category = String(segment?.featureCategory || "other").trim().toLowerCase();
return GCODE_PREVIEW_Z_LIFT_MM + (FEATURE_CATEGORY_Z_BIAS_MM[category] ?? FEATURE_CATEGORY_Z_BIAS_MM.other);
}
function writeRibbonVertex({ vertices, colors, normals, bounds, offset, x, y, z, zOffset = 0, color }) {
vertices[offset] = x;
vertices[offset + 1] = y;
vertices[offset + 2] = z + zOffset;
colors[offset] = color[0];
colors[offset + 1] = color[1];
colors[offset + 2] = color[2];
normals[offset] = RIBBON_NORMAL[0];
normals[offset + 1] = RIBBON_NORMAL[1];
normals[offset + 2] = RIBBON_NORMAL[2];
updateBoundsFromCoordinates(bounds, x, y, z + zOffset);
}
function writeSegmentRibbon({ vertices, indices, colors, normals, bounds, segment, segmentIndex, width, color, zOffset }) {
const start = segment?.start;
const end = segment?.end;
const startX = coordinate(start, 0);
const startY = coordinate(start, 1);
const startZ = coordinate(start, 2);
const endX = coordinate(end, 0);
const endY = coordinate(end, 1);
const endZ = coordinate(end, 2);
const dx = endX - startX;
const dy = endY - startY;
const dz = endZ - startZ;
const length = Math.hypot(dx, dy, dz);
if (!(length > MIN_SEGMENT_LENGTH_MM)) {
return null;
}
let nx = -dy;
let ny = dx;
let normalLength = Math.hypot(nx, ny);
if (!(normalLength > MIN_SEGMENT_LENGTH_MM)) {
nx = 1;
ny = 0;
normalLength = 1;
}
const halfWidth = width / 2;
const offsetX = (nx / normalLength) * halfWidth;
const offsetY = (ny / normalLength) * halfWidth;
const vertexBase = segmentIndex * RIBBON_VERTICES_PER_SEGMENT;
const vertexOffset = vertexBase * 3;
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset,
x: startX + offsetX,
y: startY + offsetY,
z: startZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 3,
x: startX - offsetX,
y: startY - offsetY,
z: startZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 6,
x: endX - offsetX,
y: endY - offsetY,
z: endZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 9,
x: endX + offsetX,
y: endY + offsetY,
z: endZ,
zOffset,
color
});
const indexOffset = segmentIndex * RIBBON_INDICES_PER_SEGMENT;
indices[indexOffset] = vertexBase;
indices[indexOffset + 1] = vertexBase + 1;
indices[indexOffset + 2] = vertexBase + 2;
indices[indexOffset + 3] = vertexBase;
indices[indexOffset + 4] = vertexBase + 2;
indices[indexOffset + 5] = vertexBase + 3;
return true;
}
export function buildGcodePreviewMeshData(gcodeData, options = {}) {
const layers = Array.isArray(gcodeData?.layers) ? gcodeData.layers : [];
const normalized = normalizeGcodePreviewOptions(options, layers.length);
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
const { visibleSegmentCount, segmentsByLayer } = analyzeVisibleSegments(gcodeData, normalized);
const layerStride = layerStrideForBudget(segmentsByLayer, visibleSegmentCount, normalized);
const [minLayer, maxLayer] = normalized.layerRange;
const renderedSegmentCapacity = countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride);
const renderedLayerCount = countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride);
const vertices = new Float32Array(renderedSegmentCapacity * RIBBON_VERTICES_PER_SEGMENT * 3);
const indices = new Uint32Array(renderedSegmentCapacity * RIBBON_INDICES_PER_SEGMENT);
const colors = new Float32Array(vertices.length);
const normals = new Float32Array(vertices.length);
const parts = [];
const geometryBounds = emptyBounds();
const rawBounds = emptyBounds();
let currentGroup = null;
let writtenSegments = 0;
for (const segment of segments) {
if (!visibleSegmentMatches(segment, normalized) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
continue;
}
const key = segmentGroupKey(segment);
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
if (!includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
continue;
}
updateSegmentBounds(rawBounds, segment);
if (!currentGroup || currentGroup.key !== key) {
currentGroup = {
key,
layerIndex,
vertexOffset: writtenSegments * RIBBON_VERTICES_PER_SEGMENT,
triangleOffset: writtenSegments * 2,
segmentCount: 0
};
parts.push(currentGroup);
}
const added = writeSegmentRibbon({
vertices,
indices,
colors,
normals,
bounds: geometryBounds,
segment,
segmentIndex: writtenSegments,
width: segment.kind === "travel" ? normalized.travelWidthMm : normalized.extrusionWidthMm,
color: colorToRgb(segmentColor(segment, key, layerIndex)),
zOffset: segmentZOffset(segment)
});
if (added) {
currentGroup.segmentCount += 1;
writtenSegments += 1;
}
}
const finalizedParts = parts
.map((part, index) => {
const nextVertexOffset = parts[index + 1]?.vertexOffset ?? writtenSegments * RIBBON_VERTICES_PER_SEGMENT;
const nextTriangleOffset = parts[index + 1]?.triangleOffset ?? writtenSegments * 2;
return {
id: part.key === "travel" ? `gcode:travel:${index}` : `gcode:layer:${part.layerIndex}:${index}`,
label: groupLabel(part.key, part.layerIndex),
vertexOffset: part.vertexOffset,
vertexCount: Math.max(0, nextVertexOffset - part.vertexOffset),
triangleOffset: part.triangleOffset,
triangleCount: Math.max(0, nextTriangleOffset - part.triangleOffset),
bounds: finalizeBounds(rawBounds),
segmentCount: part.segmentCount
};
})
.filter((part) => part.vertexCount > 0 && part.triangleCount > 0);
const finalBounds = finalizeBounds(rawBounds);
return {
vertices,
indices,
normals,
colors,
bounds: finalBounds,
parts: finalizedParts,
has_source_colors: true,
sourceFormat: "gcode",
sourceColor: "#2563eb",
metadata: {
kind: "gcode",
previewGeometry: "ribbon",
options: normalized,
visibleSegments: visibleSegmentCount,
renderedSegments: writtenSegments,
visibleLayers: Math.max(0, maxLayer - minLayer + 1),
renderedLayers: renderedLayerCount,
layerStride,
segmentStride: 1,
decimationMode: layerStride > 1 ? "layers" : "none",
decimated: layerStride > 1,
stats: gcodeData?.stats || {},
features: Array.isArray(gcodeData?.features) ? gcodeData.features : [],
warnings: Array.isArray(gcodeData?.warnings) ? gcodeData.warnings : []
}
};
}
@@ -0,0 +1,787 @@
const GCODE_RENDER_SCHEMA_VERSION = 1;
const POSITION_EPSILON = 1e-7;
const EXTRUSION_EPSILON = 1e-7;
const ARC_CHORD_MM = 0.8;
const MIN_ARC_SEGMENTS = 6;
const MAX_ARC_SEGMENTS = 160;
const UNCLASSIFIED_FEATURE = Object.freeze({
id: "unclassified",
label: "Unclassified",
category: "other",
rawLabel: ""
});
const FEATURE_CATEGORY_ORDER = Object.freeze({
support: 0,
wall: 1,
surface: 2,
infill: 3,
adhesion: 4,
calibration: 5,
other: 6
});
const FEATURE_DEFINITIONS = Object.freeze([
{
id: "support-interface",
label: "Support interface",
category: "support",
patterns: [
/\bsupport\b.*\b(interface|roof|floor)\b/,
/\binterface\b.*\bsupport\b/
]
},
{
id: "support",
label: "Support",
category: "support",
patterns: [/\bsupport(?:s| material)?\b/]
},
{
id: "overhang-wall",
label: "Overhang wall",
category: "wall",
patterns: [/\boverhang\b.*\b(wall|perimeter)\b/]
},
{
id: "outer-wall",
label: "Outer wall",
category: "wall",
patterns: [
/\bouter\b.*\b(wall|perimeter|shell)\b/,
/\bexternal\b.*\b(perimeter|wall|shell)\b/,
/\bwall outer\b/
]
},
{
id: "inner-wall",
label: "Inner wall",
category: "wall",
patterns: [
/\binner\b.*\b(wall|perimeter|shell)\b/,
/\binternal\b.*\bperimeter\b/,
/\bwall inner\b/
]
},
{
id: "wall",
label: "Wall",
category: "wall",
patterns: [/\b(wall|perimeter|shell)\b/]
},
{
id: "bridge",
label: "Bridge",
category: "surface",
patterns: [/\bbridge\b/]
},
{
id: "top-surface",
label: "Top surface",
category: "surface",
patterns: [/\btop\b.*\b(surface|skin|shell|solid infill|infill)\b/]
},
{
id: "bottom-surface",
label: "Bottom surface",
category: "surface",
patterns: [/\bbottom\b.*\b(surface|skin|shell|solid infill|infill)\b/]
},
{
id: "surface",
label: "Surface",
category: "surface",
patterns: [/\b(skin|surface)\b/]
},
{
id: "gap-fill",
label: "Gap fill",
category: "infill",
patterns: [/\bgap\b.*\bfill\b/]
},
{
id: "internal-solid-infill",
label: "Internal solid infill",
category: "infill",
patterns: [
/\binternal\b.*\bsolid\b.*\binfill\b/,
/\bsolid\b.*\binfill\b/
]
},
{
id: "sparse-infill",
label: "Sparse infill",
category: "infill",
patterns: [
/\bsparse\b.*\binfill\b/,
/\binternal\b.*\binfill\b/,
/\bfill\b/
]
},
{
id: "infill",
label: "Infill",
category: "infill",
patterns: [/\binfill\b/]
},
{
id: "skirt",
label: "Skirt",
category: "adhesion",
patterns: [/\bskirt\b/]
},
{
id: "brim",
label: "Brim",
category: "adhesion",
patterns: [/\bbrim\b/]
},
{
id: "raft",
label: "Raft",
category: "adhesion",
patterns: [/\braft\b/]
},
{
id: "prime-tower",
label: "Prime tower",
category: "calibration",
patterns: [/\b(prime|wipe)\b.*\btower\b/]
},
{
id: "ironing",
label: "Ironing",
category: "surface",
patterns: [/\bironing\b/]
},
{
id: "flush",
label: "Flush",
category: "calibration",
patterns: [/\bflush\b/]
},
{
id: "wipe",
label: "Wipe",
category: "calibration",
patterns: [/\bwipe\b/]
},
{
id: "custom",
label: "Custom",
category: "other",
patterns: [/\bcustom\b/]
}
]);
const SUPPORTED_COMMANDS = new Set([
"G0",
"G1",
"G2",
"G3",
"G20",
"G21",
"G28",
"G90",
"G91",
"G92",
"M82",
"M83",
"M84",
"M104",
"M106",
"M107",
"M109",
"M140",
"M190"
]);
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clonePoint(point) {
return [
toFiniteNumber(point?.[0]),
toFiniteNumber(point?.[1]),
toFiniteNumber(point?.[2])
];
}
function stripComment(line) {
return String(line || "").split(";", 1)[0].trim();
}
function parseCommand(line) {
const stripped = stripComment(line).toUpperCase();
if (!stripped) {
return "";
}
const first = stripped.split(/\s+/, 1)[0];
if (/^[GMT]\d+(?:\.\d+)?$/.test(first)) {
return first.split(".", 1)[0];
}
return first;
}
function parseTokens(line) {
const stripped = stripComment(line).toUpperCase();
const tokens = {};
for (const match of stripped.matchAll(/([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))/g)) {
tokens[match[1]] = Number(match[2]);
}
return tokens;
}
function commentText(line) {
const source = String(line || "");
const commentIndex = source.indexOf(";");
return commentIndex >= 0 ? source.slice(commentIndex + 1).trim() : "";
}
function normalizeFeatureText(value) {
return String(value || "")
.replace(/^["']+|["']+$/g, "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function featureSlug(value) {
return normalizeFeatureText(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function titleCaseFeatureLabel(value) {
const normalized = normalizeFeatureText(value);
if (!normalized) {
return "Other";
}
return normalized
.toLowerCase()
.split(" ")
.map((word) => word ? `${word[0].toUpperCase()}${word.slice(1)}` : "")
.join(" ");
}
function featureFromLabel(rawLabel) {
const normalized = normalizeFeatureText(rawLabel);
const matchText = normalized.toLowerCase();
for (const definition of FEATURE_DEFINITIONS) {
if (definition.patterns.some((pattern) => pattern.test(matchText))) {
return {
id: definition.id,
label: definition.label,
category: definition.category,
rawLabel: normalized
};
}
}
const slug = featureSlug(normalized);
return {
id: slug ? `other:${slug}` : UNCLASSIFIED_FEATURE.id,
label: slug ? titleCaseFeatureLabel(normalized) : UNCLASSIFIED_FEATURE.label,
category: "other",
rawLabel: normalized
};
}
function parseFeatureMarker(line) {
const comment = commentText(line);
if (!comment) {
return null;
}
const match = comment.match(/^(?:FEATURE|TYPE|PRINT_FEATURE|TOOLPATH(?:_TYPE)?|PATH_TYPE)\s*[:=]\s*(.+)$/i);
if (!match?.[1]) {
return null;
}
return featureFromLabel(match[1]);
}
function distance3(a, b) {
const dx = toFiniteNumber(b?.[0]) - toFiniteNumber(a?.[0]);
const dy = toFiniteNumber(b?.[1]) - toFiniteNumber(a?.[1]);
const dz = toFiniteNumber(b?.[2]) - toFiniteNumber(a?.[2]);
return Math.hypot(dx, dy, dz);
}
function roundedZ(value) {
return Math.round(toFiniteNumber(value) * 1000000) / 1000000;
}
function updateBounds(bounds, point) {
const next = clonePoint(point);
for (let axis = 0; axis < 3; axis += 1) {
bounds.min[axis] = Math.min(bounds.min[axis], next[axis]);
bounds.max[axis] = Math.max(bounds.max[axis], next[axis]);
}
}
function layerKey(z) {
return roundedZ(z).toFixed(6);
}
function layerIndexForZ(layers, z) {
if (!layers.length) {
return 0;
}
const target = roundedZ(z);
let bestIndex = 0;
let bestDistance = Infinity;
for (let index = 0; index < layers.length; index += 1) {
const distance = Math.abs(toFiniteNumber(layers[index]?.z) - target);
if (distance < bestDistance) {
bestIndex = index;
bestDistance = distance;
}
}
return bestIndex;
}
function endpointForMove(position, tokens, absolutePositioning) {
return [
Object.hasOwn(tokens, "X")
? (absolutePositioning ? tokens.X : position.x + tokens.X)
: position.x,
Object.hasOwn(tokens, "Y")
? (absolutePositioning ? tokens.Y : position.y + tokens.Y)
: position.y,
Object.hasOwn(tokens, "Z")
? (absolutePositioning ? tokens.Z : position.z + tokens.Z)
: position.z
];
}
function nextExtruder(position, tokens, absoluteExtrusion) {
if (!Object.hasOwn(tokens, "E")) {
return position.e;
}
return absoluteExtrusion ? tokens.E : position.e + tokens.E;
}
function addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta = 0,
feedRate = null,
arc = false,
feature = null
}) {
const lengthMm = distance3(start, end);
if (lengthMm <= POSITION_EPSILON) {
if (extrusionDelta < -EXTRUSION_EPSILON) {
stats.retractMoves += 1;
} else if (extrusionDelta > EXTRUSION_EPSILON) {
stats.primeMoves += 1;
}
return;
}
const kind = extrusionDelta > EXTRUSION_EPSILON ? "extrusion" : "travel";
if (kind === "extrusion") {
stats.extrusionMoves += 1;
stats.extrusionMm += extrusionDelta;
} else {
stats.travelMoves += 1;
}
stats.movementCommands += 1;
stats.pathMm += lengthMm;
updateBounds(bounds, start);
updateBounds(bounds, end);
segments.push({
lineNumber,
command,
kind,
start: clonePoint(start),
end: clonePoint(end),
z: roundedZ(end[2]),
extrusionDelta,
feedRate: Number.isFinite(feedRate) ? feedRate : null,
lengthMm,
arc: Boolean(arc),
featureId: feature?.id || UNCLASSIFIED_FEATURE.id,
featureLabel: feature?.label || UNCLASSIFIED_FEATURE.label,
featureCategory: feature?.category || UNCLASSIFIED_FEATURE.category,
featureRaw: feature?.rawLabel || ""
});
}
function arcSegments({ start, end, tokens, clockwise }) {
if (!Object.hasOwn(tokens, "I") || !Object.hasOwn(tokens, "J")) {
return null;
}
const centerX = start[0] + tokens.I;
const centerY = start[1] + tokens.J;
const radius = Math.hypot(start[0] - centerX, start[1] - centerY);
if (!(radius > POSITION_EPSILON)) {
return null;
}
const startAngle = Math.atan2(start[1] - centerY, start[0] - centerX);
const endAngle = Math.atan2(end[1] - centerY, end[0] - centerX);
let sweep = endAngle - startAngle;
if (clockwise && sweep >= 0) {
sweep -= Math.PI * 2;
} else if (!clockwise && sweep <= 0) {
sweep += Math.PI * 2;
}
const chordSegments = Math.ceil(Math.abs(sweep * radius) / ARC_CHORD_MM);
const segmentCount = Math.max(MIN_ARC_SEGMENTS, Math.min(MAX_ARC_SEGMENTS, chordSegments));
const points = [clonePoint(start)];
for (let index = 1; index <= segmentCount; index += 1) {
const t = index / segmentCount;
const angle = startAngle + sweep * t;
points.push([
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle),
start[2] + (end[2] - start[2]) * t
]);
}
return points;
}
function compactBounds(bounds) {
if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [1, 1, 1]
};
}
return {
min: bounds.min.map((value) => roundedZ(value)),
max: bounds.max.map((value) => roundedZ(value))
};
}
function finalizeLayers(segments) {
const zValues = new Map();
for (const segment of segments) {
if (segment.kind !== "extrusion") {
continue;
}
const key = layerKey(segment.z);
if (!zValues.has(key)) {
zValues.set(key, roundedZ(segment.z));
}
}
if (!zValues.size) {
for (const segment of segments) {
const key = layerKey(segment.z);
if (!zValues.has(key)) {
zValues.set(key, roundedZ(segment.z));
}
}
}
const layers = [...zValues.values()]
.sort((left, right) => left - right)
.map((z, index) => ({
index,
z,
segmentCount: 0,
extrusionMoves: 0,
travelMoves: 0
}));
for (const segment of segments) {
const index = layerIndexForZ(layers, segment.z);
segment.layerIndex = index;
if (layers[index]) {
layers[index].segmentCount += 1;
if (segment.kind === "extrusion") {
layers[index].extrusionMoves += 1;
} else {
layers[index].travelMoves += 1;
}
}
}
return layers;
}
function ensureFeatureRecord(records, feature) {
const resolvedFeature = feature?.id ? feature : UNCLASSIFIED_FEATURE;
if (!records.has(resolvedFeature.id)) {
records.set(resolvedFeature.id, {
id: resolvedFeature.id,
label: resolvedFeature.label || UNCLASSIFIED_FEATURE.label,
category: resolvedFeature.category || UNCLASSIFIED_FEATURE.category,
rawLabels: new Set(),
markerCount: 0,
firstLine: null,
lastLine: null,
firstLayerIndex: null,
lastLayerIndex: null,
segmentCount: 0,
extrusionMoves: 0,
travelMoves: 0,
extrusionMm: 0,
pathMm: 0
});
}
const record = records.get(resolvedFeature.id);
if (resolvedFeature.rawLabel) {
record.rawLabels.add(resolvedFeature.rawLabel);
}
return record;
}
function layerRangeText(record) {
if (!Number.isInteger(record.firstLayerIndex) || !Number.isInteger(record.lastLayerIndex)) {
return "";
}
return record.firstLayerIndex === record.lastLayerIndex
? `Layer ${record.firstLayerIndex + 1}`
: `Layers ${record.firstLayerIndex + 1}-${record.lastLayerIndex + 1}`;
}
function finalizeFeatures(segments, markers) {
const records = new Map();
for (const marker of markers) {
const record = ensureFeatureRecord(records, marker.feature);
record.markerCount += 1;
record.firstLine = record.firstLine == null ? marker.lineNumber : Math.min(record.firstLine, marker.lineNumber);
record.lastLine = record.lastLine == null ? marker.lineNumber : Math.max(record.lastLine, marker.lineNumber);
}
for (const segment of segments) {
const record = ensureFeatureRecord(records, {
id: segment.featureId,
label: segment.featureLabel,
category: segment.featureCategory,
rawLabel: segment.featureRaw
});
record.segmentCount += 1;
record.pathMm += toFiniteNumber(segment.lengthMm);
if (Number.isInteger(segment.layerIndex)) {
record.firstLayerIndex = record.firstLayerIndex == null
? segment.layerIndex
: Math.min(record.firstLayerIndex, segment.layerIndex);
record.lastLayerIndex = record.lastLayerIndex == null
? segment.layerIndex
: Math.max(record.lastLayerIndex, segment.layerIndex);
}
if (segment.kind === "extrusion") {
record.extrusionMoves += 1;
record.extrusionMm += toFiniteNumber(segment.extrusionDelta);
} else if (segment.kind === "travel") {
record.travelMoves += 1;
}
}
return [...records.values()]
.map((record) => ({
...record,
rawLabels: [...record.rawLabels].sort((left, right) => left.localeCompare(right)),
layerRangeText: layerRangeText(record),
pathMm: roundedZ(record.pathMm),
extrusionMm: roundedZ(record.extrusionMm)
}))
.sort((left, right) => {
const categoryDelta = (FEATURE_CATEGORY_ORDER[left.category] ?? 99) - (FEATURE_CATEGORY_ORDER[right.category] ?? 99);
if (categoryDelta !== 0) {
return categoryDelta;
}
const extrusionDelta = right.extrusionMoves - left.extrusionMoves;
if (extrusionDelta !== 0) {
return extrusionDelta;
}
return left.label.localeCompare(right.label);
});
}
function warningList(warnings, unsupportedCommands) {
const result = [...warnings];
const unsupported = [...unsupportedCommands.entries()]
.sort((left, right) => left[0].localeCompare(right[0]))
.slice(0, 12)
.map(([command, line]) => `${command} line ${line}`);
if (unsupported.length) {
result.push(`Unsupported or ignored commands were left out of the preview: ${unsupported.join(", ")}.`);
}
return result;
}
export function parseGcode(text, { sourceUrl = "", fileRef = "" } = {}) {
const lines = String(text || "").split(/\r?\n/);
const segments = [];
const unsupportedCommands = new Map();
const warnings = [];
const bounds = {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity]
};
const stats = {
lines: lines.length,
nonCommentLines: 0,
movementCommands: 0,
extrusionMoves: 0,
travelMoves: 0,
retractMoves: 0,
primeMoves: 0,
temperatureCommands: 0,
extrusionMm: 0,
pathMm: 0
};
const position = {
x: 0,
y: 0,
z: 0,
e: 0
};
let absolutePositioning = true;
let absoluteExtrusion = true;
let units = "mm";
let relativeWarningAdded = false;
let inchWarningAdded = false;
let currentFeature = null;
const featureMarkers = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const lineNumber = lineIndex + 1;
const rawLine = lines[lineIndex];
const featureMarker = parseFeatureMarker(rawLine);
if (featureMarker) {
currentFeature = featureMarker;
featureMarkers.push({
lineNumber,
feature: featureMarker
});
}
const command = parseCommand(rawLine);
if (!command) {
continue;
}
stats.nonCommentLines += 1;
const tokens = parseTokens(rawLine);
if (!SUPPORTED_COMMANDS.has(command) && !/^T\d+$/.test(command)) {
unsupportedCommands.set(command, unsupportedCommands.get(command) || lineNumber);
}
if (command === "G20") {
units = "inch";
if (!inchWarningAdded) {
warnings.push("G20 inch units found; preview geometry assumes millimeters.");
inchWarningAdded = true;
}
continue;
}
if (command === "G21") {
units = "mm";
continue;
}
if (command === "G90") {
absolutePositioning = true;
continue;
}
if (command === "G91") {
absolutePositioning = false;
if (!relativeWarningAdded) {
warnings.push("Relative XYZ positioning found; preview follows relative moves after G91.");
relativeWarningAdded = true;
}
continue;
}
if (command === "M82") {
absoluteExtrusion = true;
continue;
}
if (command === "M83") {
absoluteExtrusion = false;
continue;
}
if (command === "G92") {
if (Object.hasOwn(tokens, "X")) {
position.x = tokens.X;
}
if (Object.hasOwn(tokens, "Y")) {
position.y = tokens.Y;
}
if (Object.hasOwn(tokens, "Z")) {
position.z = tokens.Z;
}
if (Object.hasOwn(tokens, "E")) {
position.e = tokens.E;
}
continue;
}
if (["M104", "M109", "M140", "M190"].includes(command)) {
stats.temperatureCommands += 1;
continue;
}
if (!["G0", "G1", "G2", "G3"].includes(command)) {
continue;
}
const start = [position.x, position.y, position.z];
const end = endpointForMove(position, tokens, absolutePositioning);
const nextE = nextExtruder(position, tokens, absoluteExtrusion);
const extrusionDelta = nextE - position.e;
const feedRate = Object.hasOwn(tokens, "F") ? tokens.F : null;
if (command === "G2" || command === "G3") {
const points = arcSegments({
start,
end,
tokens,
clockwise: command === "G2"
});
if (points) {
for (let index = 0; index < points.length - 1; index += 1) {
addSegment(segments, bounds, stats, {
lineNumber,
command,
start: points[index],
end: points[index + 1],
extrusionDelta: extrusionDelta / Math.max(points.length - 1, 1),
feedRate,
arc: true,
feature: currentFeature
});
}
} else {
warnings.push(`Line ${lineNumber}: ${command} arc is missing I/J center offsets; previewed as a straight move.`);
addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta,
feedRate,
arc: false,
feature: currentFeature
});
}
} else {
addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta,
feedRate,
feature: currentFeature
});
}
position.x = end[0];
position.y = end[1];
position.z = end[2];
position.e = nextE;
}
const layers = finalizeLayers(segments);
const features = finalizeFeatures(segments, featureMarkers);
return {
schemaVersion: GCODE_RENDER_SCHEMA_VERSION,
sourceUrl,
fileRef,
units,
bounds: compactBounds(bounds),
layers,
features,
segments,
stats,
warnings: warningList(warnings, unsupportedCommands)
};
}
@@ -0,0 +1,201 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildGcodePreviewMeshData,
GCODE_PREVIEW_DETAIL_MAX
} from "./buildPreviewMesh.js";
import { parseGcode } from "./parseGcode.js";
test("parseGcode extracts extrusion, travel, layers, and basic print stats", () => {
const data = parseGcode(`
; perimeter preview
G21
G90
M82
G92 E0
G0 X0 Y0 Z0.2 F6000
G1 X10 Y0 E0.4 F1200
G1 X10 Y10 E0.8
G0 X0 Y10 F6000
G92 E0
G1 X0 Y0 E0.4
G1 Z0.4 F600
G1 X5 Y0 E0.8
M104 S210
`, { sourceUrl: "/models/test.gcode", fileRef: "test.gcode" });
assert.equal(data.schemaVersion, 1);
assert.equal(data.sourceUrl, "/models/test.gcode");
assert.equal(data.fileRef, "test.gcode");
assert.equal(data.units, "mm");
assert.deepEqual(data.bounds, {
min: [0, 0, 0],
max: [10, 10, 0.4]
});
assert.equal(data.layers.length, 2);
assert.deepEqual(data.layers.map((layer) => layer.z), [0.2, 0.4]);
assert.equal(data.stats.movementCommands, 7);
assert.equal(data.stats.extrusionMoves, 4);
assert.equal(data.stats.travelMoves, 3);
assert.equal(data.stats.temperatureCommands, 1);
assert.equal(Math.round(data.stats.extrusionMm * 1000) / 1000, 1.6);
assert.equal(data.segments.filter((segment) => segment.kind === "extrusion").length, 4);
assert.equal(data.segments[1].layerIndex, 0);
assert.equal(data.segments.at(-1).layerIndex, 1);
});
test("parseGcode follows relative moves, samples IJ arcs, and reports ignored commands", () => {
const data = parseGcode(`
G91
M83
G1 X2 E0.05
G2 X2 Y0 E0.05 I1 J0
G4 P200
`);
assert.ok(data.stats.movementCommands > 2);
assert.equal(data.stats.extrusionMoves, data.stats.movementCommands);
assert.ok(data.segments.some((segment) => segment.arc));
assert.ok(data.warnings.some((warning) => warning.includes("Relative XYZ positioning")));
assert.ok(data.warnings.some((warning) => /G4 line \d+/.test(warning)));
});
test("parseGcode summarizes slicer feature annotations including supports", () => {
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
;TYPE:WALL-OUTER
G1 X10 Y0 E0.4
; FEATURE: Support interface
G1 X10 Y10 E0.8
;TYPE:SUPPORT
G1 X0 Y10 E1.2
; FEATURE: Sparse infill
G1 X0 Y0 E1.6
`);
const featuresById = new Map(data.features.map((feature) => [feature.id, feature]));
assert.equal(featuresById.get("outer-wall")?.label, "Outer wall");
assert.equal(featuresById.get("support-interface")?.category, "support");
assert.equal(featuresById.get("support-interface")?.extrusionMoves, 1);
assert.deepEqual(featuresById.get("support-interface")?.rawLabels, ["Support interface"]);
assert.equal(featuresById.get("support")?.extrusionMoves, 1);
assert.equal(featuresById.get("sparse-infill")?.category, "infill");
assert.deepEqual(
data.segments.filter((segment) => segment.kind === "extrusion").map((segment) => segment.featureId),
["outer-wall", "support-interface", "support", "sparse-infill"]
);
});
test("buildGcodePreviewMeshData turns visible G-code paths into layer-colored mesh data", () => {
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
;TYPE:WALL-OUTER
G1 X4 Y0 E0.2
G0 X4 Y4
G1 X0 Y4 E0.4
G1 Z0.4
G1 X0 Y0 E0.6
`);
const extrusionOnly = buildGcodePreviewMeshData(data, {
showTravel: false,
maxLayer: 0
});
assert.equal(extrusionOnly.sourceFormat, "gcode");
assert.deepEqual(extrusionOnly.metadata.options.layerRange, [0, 0]);
assert.equal(extrusionOnly.metadata.options.showTravel, false);
assert.equal(extrusionOnly.metadata.previewGeometry, "ribbon");
assert.equal(extrusionOnly.metadata.features.some((feature) => feature.id === "outer-wall"), true);
assert.equal(extrusionOnly.has_source_colors, true);
assert.ok(extrusionOnly.vertices.length > 0);
assert.ok(extrusionOnly.indices.length > 0);
assert.equal(extrusionOnly.colors.length, extrusionOnly.vertices.length);
assert.equal(extrusionOnly.normals.length, extrusionOnly.vertices.length);
assert.equal(extrusionOnly.bounds.min[2], 0.2);
assert.ok(extrusionOnly.vertices[2] > extrusionOnly.bounds.min[2]);
assert.deepEqual(extrusionOnly.parts.map((part) => part.label), ["Layer 1"]);
const withTravel = buildGcodePreviewMeshData(data, {
showTravel: true,
maxLayer: 1
});
assert.ok(withTravel.parts.some((part) => part.label === "Travel"));
assert.ok(withTravel.parts.some((part) => part.label === "Layer 2"));
assert.equal(withTravel.metadata.stats.extrusionMoves, 3);
});
test("buildGcodePreviewMeshData renders support paths with a neutral color and z bias", () => {
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
;TYPE:WALL-OUTER
G1 X4 Y0 E0.2
G1 Z0.4
G1 X4 Y4 E0.4
G1 Z0.6
;TYPE:SUPPORT
G1 X0 Y4 E0.6
`);
const preview = buildGcodePreviewMeshData(data, { detailMode: "full" });
const supportVertexOffset = 2 * 4 * 3;
const supportRgb = Array.from(preview.colors.slice(supportVertexOffset, supportVertexOffset + 3));
assert.ok(supportRgb[0] < 0.75);
assert.ok(supportRgb[1] > 0.45);
assert.ok(supportRgb[2] > 0.55);
assert.ok(preview.vertices[supportVertexOffset + 2] > 0.6);
assert.equal(preview.bounds.max[2], 0.6);
});
test("buildGcodePreviewMeshData adaptively samples whole layers unless full detail is requested", () => {
const moves = Array.from({ length: 8 }, (_, layerIndex) => `
G1 Z${((layerIndex + 1) * 0.2).toFixed(1)}
G1 X${layerIndex * 2 + 1} Y0 E${(layerIndex * 2 + 1) / 10}
G1 X${layerIndex * 2 + 2} Y0 E${(layerIndex * 2 + 2) / 10}
`).join("\n");
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
${moves}
`);
const adaptive = buildGcodePreviewMeshData(data, {
maxVisibleSegments: 6
});
assert.equal(adaptive.metadata.visibleSegments, 16);
assert.equal(adaptive.metadata.segmentStride, 1);
assert.equal(adaptive.metadata.layerStride, 4);
assert.equal(adaptive.metadata.renderedLayers, 3);
assert.equal(adaptive.metadata.renderedSegments, 6);
assert.equal(adaptive.metadata.decimationMode, "layers");
assert.equal(adaptive.metadata.decimated, true);
assert.deepEqual(adaptive.parts.map((part) => part.label), ["Layer 1", "Layer 5", "Layer 8"]);
const full = buildGcodePreviewMeshData(data, {
detailMode: "full",
maxVisibleSegments: 6
});
assert.equal(full.metadata.visibleSegments, 16);
assert.equal(full.metadata.segmentStride, 1);
assert.equal(full.metadata.layerStride, 1);
assert.equal(full.metadata.renderedSegments, 16);
assert.equal(full.metadata.decimated, false);
const maxAdaptive = buildGcodePreviewMeshData(data, {
detailLevel: 99
});
assert.equal(maxAdaptive.metadata.options.detailLevel, GCODE_PREVIEW_DETAIL_MAX);
});
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(packageRoot, relativePath), "utf8"));
}
function collectSourceFiles(directory, files = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(entryPath, files);
} else if (/\.[cm]?js$/u.test(entry.name)) {
files.push(entryPath);
}
}
return files;
}
test("cadjs does not depend on implicitjs", () => {
const manifest = readJson("package.json");
const lockfile = readJson("package-lock.json");
assert.equal(manifest.dependencies?.implicitjs, undefined);
assert.equal(lockfile.packages?.[""]?.dependencies?.implicitjs, undefined);
assert.equal(lockfile.packages?.["node_modules/implicitjs"], undefined);
const sourceFiles = collectSourceFiles(path.join(packageRoot, "src"));
for (const filePath of sourceFiles) {
const source = fs.readFileSync(filePath, "utf8");
assert.equal(
/\bfrom\s+["']implicitjs(?:\/[^"']*)?["']|export\s+\*\s+from\s+["']implicitjs(?:\/[^"']*)?["']/u.test(source),
false,
`${path.relative(packageRoot, filePath)} imports implicitjs`
);
}
});
test("viewer-only server workflow modules stay out of cadjs", () => {
for (const relativePath of [
"src/lib/cadDirectoryScanner.mjs",
"src/lib/generationStatus.mjs",
"src/lib/step/stepArtifactCompiler.mjs",
"src/lib/cadManifestStore.js",
"src/lib/cadViewerDirectorySession.mjs",
"src/lib/viewerConfig.mjs",
"src/lib/viewerServerInfo.mjs",
"src/lib/viewerServerRegistry.mjs",
]) {
assert.equal(fs.existsSync(path.join(packageRoot, relativePath)), false, relativePath);
}
});
test("viewer workspace resolution stays in viewer", () => {
const pathUtilsSource = fs.readFileSync(path.join(packageRoot, "src/lib/pathUtils.mjs"), "utf8");
assert.equal(/\bresolveWorkspaceRoot\b/u.test(pathUtilsSource), false);
});
@@ -0,0 +1,30 @@
import path from "node:path";
export function toPosixPath(value) {
return String(value || "").split(path.sep).join("/");
}
export function encodePathParam(value) {
return toPosixPath(value)
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
}
function relativePathStaysInsideRoot(relativePath) {
return relativePath === "" || (
relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
export function pathIsInside(childPath, parentPath) {
const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath));
return Boolean(relativePath) && relativePathStaysInsideRoot(relativePath);
}
export function pathIsInsideOrEqual(childPath, parentPath) {
const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath));
return relativePathStaysInsideRoot(relativePath);
}
@@ -0,0 +1,163 @@
import {
cloneCameraVector,
normalizeCameraZoom
} from "../common/camera.js";
function normalizePerspectiveMetadataValue(value) {
return String(value || "").trim();
}
export const CAMERA_PROJECTION = Object.freeze({
PERSPECTIVE: "perspective",
ORTHOGRAPHIC: "orthographic"
});
export function normalizeCameraProjection(value, fallback = CAMERA_PROJECTION.PERSPECTIVE) {
const normalizedValue = String(value || "").trim().toLowerCase();
if (normalizedValue === CAMERA_PROJECTION.ORTHOGRAPHIC) {
return CAMERA_PROJECTION.ORTHOGRAPHIC;
}
if (normalizedValue === CAMERA_PROJECTION.PERSPECTIVE) {
return CAMERA_PROJECTION.PERSPECTIVE;
}
return fallback === CAMERA_PROJECTION.ORTHOGRAPHIC
? CAMERA_PROJECTION.ORTHOGRAPHIC
: CAMERA_PROJECTION.PERSPECTIVE;
}
export function clonePerspectiveVector(vector) {
return cloneCameraVector(vector);
}
export function clonePerspectiveSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const position = clonePerspectiveVector(snapshot.position);
const target = clonePerspectiveVector(snapshot.target);
const up = clonePerspectiveVector(snapshot.up);
if (!position || !target || !up) {
return null;
}
const clonedSnapshot = {
position,
target,
up
};
if (Object.prototype.hasOwnProperty.call(snapshot, "zoom")) {
clonedSnapshot.zoom = normalizeCameraZoom(snapshot.zoom, 1);
}
if (Object.prototype.hasOwnProperty.call(snapshot, "projection")) {
clonedSnapshot.projection = normalizeCameraProjection(snapshot.projection);
}
const modelKey = normalizePerspectiveMetadataValue(snapshot.modelKey);
const sceneScaleMode = normalizePerspectiveMetadataValue(snapshot.sceneScaleMode);
const coordinateSystem = normalizePerspectiveMetadataValue(snapshot.coordinateSystem);
if (modelKey) {
clonedSnapshot.modelKey = modelKey;
}
if (sceneScaleMode) {
clonedSnapshot.sceneScaleMode = sceneScaleMode;
}
if (coordinateSystem) {
clonedSnapshot.coordinateSystem = coordinateSystem;
}
return clonedSnapshot;
}
export function annotatePerspectiveSnapshot(snapshot, { modelKey = "", sceneScaleMode = "", coordinateSystem = "" } = {}) {
const annotatedSnapshot = clonePerspectiveSnapshot(snapshot);
if (!annotatedSnapshot) {
return null;
}
const normalizedModelKey = normalizePerspectiveMetadataValue(modelKey);
const normalizedSceneScaleMode = normalizePerspectiveMetadataValue(sceneScaleMode);
const normalizedCoordinateSystem = normalizePerspectiveMetadataValue(coordinateSystem);
if (normalizedModelKey) {
annotatedSnapshot.modelKey = normalizedModelKey;
}
if (normalizedSceneScaleMode) {
annotatedSnapshot.sceneScaleMode = normalizedSceneScaleMode;
}
if (normalizedCoordinateSystem) {
annotatedSnapshot.coordinateSystem = normalizedCoordinateSystem;
}
return annotatedSnapshot;
}
export function resolvePerspectiveSnapshot(primary, fallback) {
if (typeof primary !== "undefined") {
return clonePerspectiveSnapshot(primary);
}
return clonePerspectiveSnapshot(fallback);
}
function perspectiveVectorEqual(a, b, epsilon = 1e-4) {
if (a === b) {
return true;
}
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (Math.abs((Number(a[index]) || 0) - (Number(b[index]) || 0)) > epsilon) {
return false;
}
}
return true;
}
export function perspectiveSnapshotEqual(a, b, epsilon = 1e-4) {
if (a === b) {
return true;
}
if (!a || !b) {
return !a && !b;
}
return (
perspectiveVectorEqual(a.position, b.position, epsilon) &&
perspectiveVectorEqual(a.target, b.target, epsilon) &&
perspectiveVectorEqual(a.up, b.up, epsilon) &&
Math.abs(normalizeCameraZoom(a.zoom, 1) - normalizeCameraZoom(b.zoom, 1)) <= epsilon &&
normalizeCameraProjection(a.projection) === normalizeCameraProjection(b.projection) &&
normalizePerspectiveMetadataValue(a.modelKey) === normalizePerspectiveMetadataValue(b.modelKey) &&
normalizePerspectiveMetadataValue(a.sceneScaleMode) === normalizePerspectiveMetadataValue(b.sceneScaleMode) &&
normalizePerspectiveMetadataValue(a.coordinateSystem) === normalizePerspectiveMetadataValue(b.coordinateSystem)
);
}
export function perspectiveSnapshotMatchesScene(snapshot, {
modelKey = "",
sceneScaleMode = "",
coordinateSystem = "",
requireModelKey = false,
requireSceneScaleMode = false,
requireCoordinateSystem = false
} = {}) {
const normalizedSnapshot = clonePerspectiveSnapshot(snapshot);
if (!normalizedSnapshot) {
return false;
}
const normalizedModelKey = normalizePerspectiveMetadataValue(modelKey);
if (requireModelKey && normalizedModelKey && normalizedSnapshot.modelKey !== normalizedModelKey) {
return false;
}
if (normalizedModelKey && normalizedSnapshot.modelKey && normalizedSnapshot.modelKey !== normalizedModelKey) {
return false;
}
const normalizedSceneScaleMode = normalizePerspectiveMetadataValue(sceneScaleMode);
if (requireSceneScaleMode && normalizedSceneScaleMode && normalizedSnapshot.sceneScaleMode !== normalizedSceneScaleMode) {
return false;
}
if (normalizedSceneScaleMode && normalizedSnapshot.sceneScaleMode && normalizedSnapshot.sceneScaleMode !== normalizedSceneScaleMode) {
return false;
}
const normalizedCoordinateSystem = normalizePerspectiveMetadataValue(coordinateSystem);
if (requireCoordinateSystem && normalizedCoordinateSystem && normalizedSnapshot.coordinateSystem !== normalizedCoordinateSystem) {
return false;
}
if (normalizedCoordinateSystem && normalizedSnapshot.coordinateSystem && normalizedSnapshot.coordinateSystem !== normalizedCoordinateSystem) {
return false;
}
return true;
}
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
annotatePerspectiveSnapshot,
CAMERA_PROJECTION,
clonePerspectiveSnapshot,
normalizeCameraProjection,
perspectiveSnapshotEqual,
perspectiveSnapshotMatchesScene,
resolvePerspectiveSnapshot
} from "./perspective.js";
const PERSPECTIVE_A = Object.freeze({
position: [10, 20, 30],
target: [1, 2, 3],
up: [0, 1, 0]
});
const PERSPECTIVE_B = Object.freeze({
position: [40, 50, 60],
target: [4, 5, 6],
up: [0, 0, 1],
zoom: 2.5
});
test("resolvePerspectiveSnapshot uses the fallback only when the primary value is undefined", () => {
assert.deepEqual(resolvePerspectiveSnapshot(undefined, PERSPECTIVE_A), PERSPECTIVE_A);
assert.equal(resolvePerspectiveSnapshot(null, PERSPECTIVE_A), null);
assert.deepEqual(resolvePerspectiveSnapshot(PERSPECTIVE_B, PERSPECTIVE_A), PERSPECTIVE_B);
});
test("perspective snapshots match scene metadata when present", () => {
const taggedSnapshot = annotatePerspectiveSnapshot({
...PERSPECTIVE_A,
zoom: 1.75
}, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1"
});
assert.deepEqual(taggedSnapshot, {
...PERSPECTIVE_A,
zoom: 1.75,
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1"
});
assert.equal(
perspectiveSnapshotMatchesScene(taggedSnapshot, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1"
}),
true
);
assert.equal(
perspectiveSnapshotMatchesScene(taggedSnapshot, {
modelKey: "sample_robot",
sceneScaleMode: "cad"
}),
false
);
assert.equal(
perspectiveSnapshotMatchesScene(taggedSnapshot, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-y-up-v1"
}),
false
);
assert.equal(
perspectiveSnapshotMatchesScene(PERSPECTIVE_A, {
modelKey: "sample_robot.urdf",
coordinateSystem: "cad-z-up-v1"
}),
true
);
assert.equal(
perspectiveSnapshotMatchesScene(PERSPECTIVE_A, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1",
requireModelKey: true,
requireSceneScaleMode: true,
requireCoordinateSystem: true
}),
false
);
});
test("perspective snapshots preserve and compare camera projection", () => {
assert.equal(normalizeCameraProjection("ORTHOGRAPHIC"), CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.equal(normalizeCameraProjection("unknown"), CAMERA_PROJECTION.PERSPECTIVE);
assert.equal(normalizeCameraProjection("unknown", CAMERA_PROJECTION.ORTHOGRAPHIC), CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.deepEqual(
clonePerspectiveSnapshot({
...PERSPECTIVE_A,
projection: "orthographic"
}),
{
...PERSPECTIVE_A,
projection: CAMERA_PROJECTION.ORTHOGRAPHIC
}
);
assert.equal(
perspectiveSnapshotEqual(PERSPECTIVE_A, {
...PERSPECTIVE_A,
projection: CAMERA_PROJECTION.PERSPECTIVE
}),
true
);
assert.equal(
perspectiveSnapshotEqual(PERSPECTIVE_A, {
...PERSPECTIVE_A,
projection: CAMERA_PROJECTION.ORTHOGRAPHIC
}),
false
);
});
@@ -0,0 +1,492 @@
const GLB_CAD_UNIT_SCALE = 1000;
const CAD_EDGE_BARYCENTRIC_ATTRIBUTE_NAMES = Object.freeze([
"_cad_edge_barycentric",
"_CAD_EDGE_BARYCENTRIC"
]);
const CAD_EDGE_CLASS_ATTRIBUTE_NAMES = Object.freeze([
"_cad_edge_class",
"_CAD_EDGE_CLASS"
]);
const GENERATED_STEP_DEFAULT_BASE_COLOR = Object.freeze([0.72, 0.72, 0.72, 1]);
const BASE_COLOR_EPSILON = 1e-6;
function createBoundsAccumulator() {
return {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity],
};
}
function includeBoundsPoint(bounds, x, y, z) {
if (![x, y, z].every(Number.isFinite)) {
return;
}
bounds.min[0] = Math.min(bounds.min[0], x);
bounds.min[1] = Math.min(bounds.min[1], y);
bounds.min[2] = Math.min(bounds.min[2], z);
bounds.max[0] = Math.max(bounds.max[0], x);
bounds.max[1] = Math.max(bounds.max[1], y);
bounds.max[2] = Math.max(bounds.max[2], z);
}
function boundsFromAccumulator(bounds) {
if (!bounds?.min?.every(Number.isFinite) || !bounds?.max?.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return {
min: [...bounds.min],
max: [...bounds.max],
};
}
function rawBaseColorFactor(rawMaterial) {
const value = rawMaterial?.pbrMetallicRoughness?.baseColorFactor;
return Array.isArray(value) ? value : null;
}
function colorFactorsEqual(left, right) {
if (!Array.isArray(left) || !Array.isArray(right)) {
return false;
}
return right.every((expected, index) => (
Math.abs(Number(left[index] ?? (index === 3 ? 1 : 0)) - expected) <= BASE_COLOR_EPSILON
));
}
function rawMaterialSourceColorHint(rawMaterial) {
const value = rawMaterial?.extras?.cadSourceColor;
return value === true || value === false ? value : null;
}
function materialSourceColorHint(material) {
const value = material?.userData?.cadSourceColor;
return value === true || value === false ? value : null;
}
function isGeneratedStepDefaultMaterial(rawMaterial, material) {
return (
colorFactorsEqual(rawBaseColorFactor(rawMaterial), GENERATED_STEP_DEFAULT_BASE_COLOR) &&
material?.color?.getHexString?.() === "dddddd"
);
}
function colorFromMaterial(material, useSourceColors, {
rawMaterial = null,
stepTopology = false
} = {}) {
if (!useSourceColors || !material?.color) {
return null;
}
const sourceColorHint = materialSourceColorHint(material) ?? rawMaterialSourceColorHint(rawMaterial);
if (sourceColorHint === false) {
return null;
}
if (stepTopology && sourceColorHint !== true && isGeneratedStepDefaultMaterial(rawMaterial, material)) {
return null;
}
return {
rgb: [material.color.r, material.color.g, material.color.b],
hex: `#${material.color.getHexString()}`,
opacity: materialOpacity(material, rawMaterial),
};
}
function materialOpacity(material, rawMaterial = null) {
const materialValue = Number(material?.opacity);
if (Number.isFinite(materialValue)) {
return Math.min(Math.max(materialValue, 0), 1);
}
const rawAlpha = rawBaseColorFactor(rawMaterial)?.[3];
const rawValue = Number(rawAlpha);
return Number.isFinite(rawValue) ? Math.min(Math.max(rawValue, 0), 1) : 1;
}
function materialForGroup(material, group) {
if (Array.isArray(material)) {
const materialIndex = Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
return material[materialIndex] || material[0] || null;
}
return material || null;
}
function materialIndexForGroup(group) {
return Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
}
function rawMaterialForGroup(rawMaterials, group) {
if (!Array.isArray(rawMaterials) || rawMaterials.length <= 0) {
return null;
}
return rawMaterials[materialIndexForGroup(group)] || rawMaterials[0] || null;
}
function isBuild123dAxisCorrectionMatrix(matrix) {
const elements = matrix?.elements;
if (!Array.isArray(elements) && !(elements instanceof Float32Array)) {
return false;
}
const expected = [
1, 0, 0, 0,
0, 0, -1, 0,
0, 1, 0, 0,
0, 0, 0, 1,
];
return expected.every((value, index) => Math.abs(Number(elements[index]) - value) < 1e-6);
}
function buildGlbCadRootCorrection(THREE, scene) {
scene?.updateWorldMatrix?.(true, true);
const children = Array.isArray(scene?.children) ? scene.children : [];
if (children.length !== 1 || !isBuild123dAxisCorrectionMatrix(children[0]?.matrixWorld)) {
return null;
}
return new THREE.Matrix4().copy(children[0].matrixWorld).invert();
}
function cadOccurrenceIdForObject(object) {
let current = object || null;
while (current) {
const rawOccurrenceId = String(current.userData?.cadOccurrenceId || "").trim();
if (rawOccurrenceId) {
return rawOccurrenceId;
}
current = current.parent || null;
}
const objectName = String(object?.name || "").trim();
return /^o\d+(?:\.\d+)*$/.test(objectName) ? objectName : "";
}
function sceneHasCadOccurrenceIds(scene) {
let found = false;
scene?.traverse?.((object) => {
if (found) {
return;
}
found = Boolean(String(object?.userData?.cadOccurrenceId || "").trim());
});
return found;
}
function cadVectorFromGlbVector(vector, convertYUpToCad) {
const x = vector.x * GLB_CAD_UNIT_SCALE;
const y = vector.y * GLB_CAD_UNIT_SCALE;
const z = vector.z * GLB_CAD_UNIT_SCALE;
return convertYUpToCad
? { x, y: -z, z: y }
: { x, y, z };
}
function sourceIndexForSlot(indexAttribute, slot) {
return indexAttribute ? indexAttribute.getX(slot) : slot;
}
function geometryAttributeByName(geometry, names) {
for (const name of names) {
const attribute = geometry?.getAttribute?.(name);
if (attribute) {
return attribute;
}
}
return null;
}
function isValidTriangleSource(positions, indexAttribute, sourceStart) {
for (let offset = 0; offset < 3; offset += 1) {
const sourceIndex = sourceIndexForSlot(indexAttribute, sourceStart + offset);
if (sourceIndex < 0 || sourceIndex >= positions.count) {
return false;
}
}
return true;
}
function countPrimitiveOutputVertices(positions, indexAttribute, sourceStart, triangleVertexCount) {
let vertexCount = 0;
for (let localIndex = 0; localIndex < triangleVertexCount; localIndex += 3) {
if (isValidTriangleSource(positions, indexAttribute, sourceStart + localIndex)) {
vertexCount += 3;
}
}
return vertexCount;
}
function inspectGlbPrimitive(
THREE,
mesh,
group,
material,
useSourceColors,
rootCorrection,
convertYUpToCad,
primitiveIndex = 0,
partIndex = 0,
{ rawMaterial = null, stepTopology = false } = {}
) {
const geometry = mesh?.geometry;
const positions = geometry?.getAttribute?.("position");
if (!positions || positions.itemSize !== 3 || positions.count <= 0) {
return null;
}
mesh.updateWorldMatrix?.(true, false);
const matrixWorld = mesh.matrixWorld
? (
rootCorrection
? new THREE.Matrix4().multiplyMatrices(rootCorrection, mesh.matrixWorld)
: new THREE.Matrix4().copy(mesh.matrixWorld)
)
: null;
const normalMatrix = matrixWorld ? new THREE.Matrix3().getNormalMatrix(matrixWorld) : null;
const normals = geometry.getAttribute("normal");
const surfaceEdgeBarycentric = geometryAttributeByName(geometry, CAD_EDGE_BARYCENTRIC_ATTRIBUTE_NAMES);
const surfaceEdgeClass = geometryAttributeByName(geometry, CAD_EDGE_CLASS_ATTRIBUTE_NAMES);
const indexAttribute = geometry.getIndex?.();
const sourceStart = Math.max(0, Math.floor(Number(group?.start || 0)));
const availableCount = indexAttribute?.count || positions.count;
const rawCount = Math.floor(Number(group?.count || (availableCount - sourceStart)));
const sourceCount = Math.max(0, Math.min(rawCount, availableCount - sourceStart));
const triangleVertexCount = sourceCount - (sourceCount % 3);
if (triangleVertexCount <= 0) {
return null;
}
const vertexCount = countPrimitiveOutputVertices(positions, indexAttribute, sourceStart, triangleVertexCount);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return null;
}
const color = colorFromMaterial(material, useSourceColors, {
rawMaterial,
stepTopology
});
const cadOccurrenceId = cadOccurrenceIdForObject(mesh);
const label = String(cadOccurrenceId || mesh?.name || mesh?.parent?.name || `glb:${partIndex}`).trim();
const id = cadOccurrenceId || `glb:${partIndex}`;
return {
mesh,
positions,
normals,
surfaceEdgeBarycentric,
surfaceEdgeClass,
indexAttribute,
sourceStart,
triangleVertexCount,
matrixWorld,
normalMatrix,
convertYUpToCad,
id,
occurrenceId: id,
primitiveIndex: Math.max(0, Math.floor(Number(primitiveIndex) || 0)),
name: label || id,
label: label || id,
color: color?.hex || "",
opacity: color ? color.opacity : 1,
hasSourceColors: Boolean(color),
vertexCount,
triangleCount,
};
}
function writeGlbPrimitive(THREE, descriptor, output, offsets) {
const partBounds = createBoundsAccumulator();
const positionVector = new THREE.Vector3();
const normalVector = new THREE.Vector3();
const vertexOffset = offsets.vertexOffset;
const triangleOffset = Math.floor(offsets.indexOffset / 3);
let localVertexCount = 0;
for (let localIndex = 0; localIndex < descriptor.triangleVertexCount; localIndex += 3) {
if (!isValidTriangleSource(
descriptor.positions,
descriptor.indexAttribute,
descriptor.sourceStart + localIndex
)) {
continue;
}
for (let triangleOffsetIndex = 0; triangleOffsetIndex < 3; triangleOffsetIndex += 1) {
const sourceSlot = descriptor.sourceStart + localIndex + triangleOffsetIndex;
const sourceIndex = sourceIndexForSlot(descriptor.indexAttribute, sourceSlot);
const outputVertexIndex = vertexOffset + localVertexCount;
const outputComponentIndex = outputVertexIndex * 3;
positionVector.set(
descriptor.positions.getX(sourceIndex),
descriptor.positions.getY(sourceIndex),
descriptor.positions.getZ(sourceIndex)
);
if (descriptor.matrixWorld) {
positionVector.applyMatrix4(descriptor.matrixWorld);
}
const cadPosition = cadVectorFromGlbVector(positionVector, descriptor.convertYUpToCad);
const x = cadPosition.x;
const y = cadPosition.y;
const z = cadPosition.z;
output.vertices[outputComponentIndex] = x;
output.vertices[outputComponentIndex + 1] = y;
output.vertices[outputComponentIndex + 2] = z;
includeBoundsPoint(partBounds, x, y, z);
includeBoundsPoint(output.bounds, x, y, z);
if (descriptor.normals?.itemSize === 3 && sourceIndex < descriptor.normals.count) {
normalVector.set(
descriptor.normals.getX(sourceIndex),
descriptor.normals.getY(sourceIndex),
descriptor.normals.getZ(sourceIndex)
);
if (descriptor.normalMatrix) {
normalVector.applyMatrix3(descriptor.normalMatrix).normalize();
}
const cadNormal = descriptor.convertYUpToCad
? { x: normalVector.x, y: -normalVector.z, z: normalVector.y }
: normalVector;
output.normals[outputComponentIndex] = cadNormal.x;
output.normals[outputComponentIndex + 1] = cadNormal.y;
output.normals[outputComponentIndex + 2] = cadNormal.z;
}
if (output.surfaceEdgeBarycentric && descriptor.surfaceEdgeBarycentric?.itemSize === 3 && sourceIndex < descriptor.surfaceEdgeBarycentric.count) {
output.surfaceEdgeBarycentric[outputComponentIndex] = descriptor.surfaceEdgeBarycentric.getX(sourceIndex);
output.surfaceEdgeBarycentric[outputComponentIndex + 1] = descriptor.surfaceEdgeBarycentric.getY(sourceIndex);
output.surfaceEdgeBarycentric[outputComponentIndex + 2] = descriptor.surfaceEdgeBarycentric.getZ(sourceIndex);
}
if (output.surfaceEdgeClass && descriptor.surfaceEdgeClass?.itemSize === 3 && sourceIndex < descriptor.surfaceEdgeClass.count) {
output.surfaceEdgeClass[outputComponentIndex] = descriptor.surfaceEdgeClass.getX(sourceIndex);
output.surfaceEdgeClass[outputComponentIndex + 1] = descriptor.surfaceEdgeClass.getY(sourceIndex);
output.surfaceEdgeClass[outputComponentIndex + 2] = descriptor.surfaceEdgeClass.getZ(sourceIndex);
}
output.indices[offsets.indexOffset + localVertexCount] = outputVertexIndex;
localVertexCount += 1;
}
}
const triangleCount = Math.floor(localVertexCount / 3);
const part = {
id: descriptor.id,
occurrenceId: descriptor.occurrenceId,
primitiveIndex: descriptor.primitiveIndex,
name: descriptor.name,
label: descriptor.label,
nodeType: "part",
color: descriptor.color,
opacity: descriptor.opacity,
hasSourceColors: descriptor.hasSourceColors,
bounds: boundsFromAccumulator(partBounds),
vertexOffset,
vertexCount: localVertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
};
offsets.vertexOffset += localVertexCount;
offsets.indexOffset += triangleCount * 3;
return part;
}
function buildMeshDataFromGltf(THREE, gltf) {
const declaredMaterials = Array.isArray(gltf?.parser?.json?.materials) && gltf.parser.json.materials.length > 0;
const rawMaterials = Array.isArray(gltf?.parser?.json?.materials) ? gltf.parser.json.materials : [];
const rootCorrection = buildGlbCadRootCorrection(THREE, gltf?.scene);
const hasStepTopology = !!gltf?.parser?.json?.extensions?.STEP_topology;
const convertYUpToCad = !hasStepTopology && !sceneHasCadOccurrenceIds(gltf?.scene) && !rootCorrection;
const descriptors = [];
const colorSet = new Set();
let totalVertexCount = 0;
let totalIndexCount = 0;
let hasSurfaceEdgeAttributes = false;
const nextPrimitiveIndexByOccurrence = new Map();
gltf?.scene?.traverse?.((object) => {
if (!object?.isMesh || !object.geometry) {
return;
}
const occurrenceId = cadOccurrenceIdForObject(object) || String(object?.name || `glb:${descriptors.length}`).trim();
const primitiveIndexBase = nextPrimitiveIndexByOccurrence.get(occurrenceId) || 0;
const groups = Array.isArray(object.geometry.groups) && object.geometry.groups.length
? object.geometry.groups
: [null];
groups.forEach((group, primitiveIndex) => {
const descriptor = inspectGlbPrimitive(
THREE,
object,
group,
materialForGroup(object.material, group),
declaredMaterials,
rootCorrection,
convertYUpToCad,
primitiveIndexBase + primitiveIndex,
descriptors.length,
{
rawMaterial: rawMaterialForGroup(rawMaterials, group),
stepTopology: hasStepTopology
}
);
if (!descriptor) {
return;
}
descriptors.push(descriptor);
totalVertexCount += descriptor.vertexCount;
totalIndexCount += descriptor.triangleCount * 3;
hasSurfaceEdgeAttributes ||= Boolean(descriptor.surfaceEdgeBarycentric && descriptor.surfaceEdgeClass);
if (descriptor.color) {
colorSet.add(descriptor.color.toLowerCase());
}
});
nextPrimitiveIndexByOccurrence.set(occurrenceId, primitiveIndexBase + groups.length);
});
const vertices = new Float32Array(totalVertexCount * 3);
const indices = new Uint32Array(totalIndexCount);
const normals = new Float32Array(totalVertexCount * 3);
const surfaceEdgeBarycentric = hasSurfaceEdgeAttributes
? new Float32Array(totalVertexCount * 3)
: null;
const surfaceEdgeClass = hasSurfaceEdgeAttributes
? new Uint8Array(totalVertexCount * 3)
: null;
const parts = [];
const output = {
vertices,
indices,
normals,
surfaceEdgeBarycentric,
surfaceEdgeClass,
bounds: createBoundsAccumulator(),
};
const offsets = {
vertexOffset: 0,
indexOffset: 0,
};
for (const descriptor of descriptors) {
parts.push(writeGlbPrimitive(THREE, descriptor, output, offsets));
}
const colors = new Float32Array(0);
return {
vertices,
indices,
normals,
surfaceEdgeBarycentric: surfaceEdgeBarycentric || new Float32Array(0),
surfaceEdgeClass: surfaceEdgeClass || new Uint8Array(0),
colors,
edge_indices: new Uint32Array(0),
bounds: boundsFromAccumulator(output.bounds),
parts,
has_source_colors: colorSet.size > 0,
sourceColor: colorSet.size === 1 ? [...colorSet][0] : "",
};
}
function parseGlb(GLTFLoader, buffer) {
const loader = new GLTFLoader();
return new Promise((resolve, reject) => {
loader.parse(buffer, "", resolve, reject);
});
}
export async function buildMeshDataFromGlbBuffer(buffer) {
const [THREE, { GLTFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/GLTFLoader.js"),
]);
const gltf = await parseGlb(GLTFLoader, buffer);
return buildMeshDataFromGltf(THREE, gltf);
}
@@ -0,0 +1,546 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildMeshDataFromGlbBuffer } from "./glbMeshData.js";
function pad4(buffer, padByte = 0) {
const padding = (4 - (buffer.length % 4)) % 4;
return padding ? Buffer.concat([buffer, Buffer.alloc(padding, padByte)]) : buffer;
}
function floatBuffer(values) {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
return buffer;
}
function uint32Buffer(values) {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeUInt32LE(value, index * 4));
return buffer;
}
function makeOccurrenceGlb({
cadExtras = true,
stepTopology = false,
materialBaseColor = null,
materialExtras = null
} = {}) {
const positions = floatBuffer([
0, 0, 0,
0.001, 0, 0,
0, 0.001, 0
]);
const indices = uint32Buffer([0, 1, 2]);
const binary = pad4(Buffer.concat([positions, indices]));
const primitive = {
attributes: {
POSITION: 0
},
indices: 1,
mode: 4,
...(Array.isArray(materialBaseColor) ? { material: 0 } : {})
};
const gltf = {
asset: {
version: "2.0"
},
...(stepTopology
? {
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {}
}
}
: {}),
scenes: [
{
nodes: [0]
}
],
nodes: [
{
name: "leaf",
mesh: 0,
...(cadExtras ? { extras: { cadOccurrenceId: "o1.2" } } : {})
}
],
meshes: [
{
primitives: [primitive]
}
],
...(Array.isArray(materialBaseColor)
? {
materials: [
{
pbrMetallicRoughness: {
baseColorFactor: materialBaseColor
},
...(materialExtras ? { extras: materialExtras } : {})
}
]
}
: {}),
buffers: [
{
byteLength: binary.length
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positions.length,
target: 34962
},
{
buffer: 0,
byteOffset: positions.length,
byteLength: indices.length,
target: 34963
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 3,
type: "VEC3",
min: [0, 0, 0],
max: [0.001, 0.001, 0]
},
{
bufferView: 1,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [0],
max: [2]
}
]
};
const json = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + json.length + 8 + binary.length, 8);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binary.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
const glb = Buffer.concat([header, jsonHeader, json, binHeader, binary]);
return glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength);
}
function makeTwoPrimitiveOccurrenceGlb({
stepTopology = false,
materialBaseColors = [
[1, 0, 0, 1],
[0, 0, 1, 1]
],
materialExtras = []
} = {}) {
const positions = floatBuffer([
0, 0, 0,
0.001, 0, 0,
0, 0.001, 0,
0.002, 0, 0,
0.003, 0, 0,
0.002, 0.001, 0
]);
const firstIndices = uint32Buffer([0, 1, 2]);
const secondIndices = uint32Buffer([3, 4, 5]);
const binary = pad4(Buffer.concat([positions, firstIndices, secondIndices]));
const secondIndexOffset = positions.length + firstIndices.length;
const gltf = {
asset: {
version: "2.0"
},
...(stepTopology
? {
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {}
}
}
: {}),
scenes: [
{
nodes: [0]
}
],
nodes: [
{
name: "leaf",
mesh: 0,
extras: {
cadOccurrenceId: "o1.2"
}
}
],
meshes: [
{
primitives: [
{
attributes: {
POSITION: 0
},
indices: 1,
material: 0,
mode: 4
},
{
attributes: {
POSITION: 0
},
indices: 2,
material: 1,
mode: 4
}
]
}
],
materials: materialBaseColors.map((baseColorFactor, index) => ({
pbrMetallicRoughness: {
baseColorFactor
},
...(materialExtras[index] ? { extras: materialExtras[index] } : {})
})),
buffers: [
{
byteLength: binary.length
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positions.length,
target: 34962
},
{
buffer: 0,
byteOffset: positions.length,
byteLength: firstIndices.length,
target: 34963
},
{
buffer: 0,
byteOffset: secondIndexOffset,
byteLength: secondIndices.length,
target: 34963
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 6,
type: "VEC3",
min: [0, 0, 0],
max: [0.003, 0.001, 0]
},
{
bufferView: 1,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [0],
max: [2]
},
{
bufferView: 2,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [3],
max: [5]
}
]
};
const json = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + json.length + 8 + binary.length, 8);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binary.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
const glb = Buffer.concat([header, jsonHeader, json, binHeader, binary]);
return glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength);
}
function makeIndexedMaterialOccurrenceGlb({
stepTopology = false,
primitiveMaterial = 1,
materialBaseColors = [
[0.72, 0.72, 0.72, 1],
[1, 0, 0, 1]
],
materialExtras = [
{ cadSourceColor: false },
{ cadSourceColor: true }
]
} = {}) {
const positions = floatBuffer([
0, 0, 0,
0.001, 0, 0,
0, 0.001, 0
]);
const indices = uint32Buffer([0, 1, 2]);
const binary = pad4(Buffer.concat([positions, indices]));
const gltf = {
asset: {
version: "2.0"
},
...(stepTopology
? {
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {}
}
}
: {}),
scenes: [
{
nodes: [0]
}
],
nodes: [
{
name: "leaf",
mesh: 0,
extras: {
cadOccurrenceId: "o1.2"
}
}
],
meshes: [
{
primitives: [
{
attributes: {
POSITION: 0
},
indices: 1,
material: primitiveMaterial,
mode: 4
}
]
}
],
materials: materialBaseColors.map((baseColorFactor, index) => ({
pbrMetallicRoughness: {
baseColorFactor
},
...(materialExtras[index] ? { extras: materialExtras[index] } : {})
})),
buffers: [
{
byteLength: binary.length
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positions.length,
target: 34962
},
{
buffer: 0,
byteOffset: positions.length,
byteLength: indices.length,
target: 34963
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 3,
type: "VEC3",
min: [0, 0, 0],
max: [0.001, 0.001, 0]
},
{
bufferView: 1,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [0],
max: [2]
}
]
};
const json = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + json.length + 8 + binary.length, 8);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binary.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
const glb = Buffer.concat([header, jsonHeader, json, binHeader, binary]);
return glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength);
}
test("GLB mesh data preserves cadOccurrenceId node extras", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb());
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].id, "o1.2");
assert.equal(meshData.parts[0].occurrenceId, "o1.2");
assert.equal(meshData.parts[0].primitiveIndex, 0);
assert.deepEqual(Array.from(meshData.vertices, (value) => Object.is(value, -0) ? 0 : value), [
0, 0, 0,
1, 0, 0,
0, 1, 0
]);
});
test("native GLB mesh data converts Y-up meters to CAD Z-up millimeters", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({ cadExtras: false }));
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].id, "glb:0");
assert.equal(meshData.parts[0].label, "leaf");
assert.deepEqual(Array.from(meshData.vertices, (value) => Object.is(value, -0) ? 0 : value), [
0, 0, 0,
1, 0, 0,
0, 0, 1
]);
});
test("STEP GLB mesh data ignores generated default material colors", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [0.72, 0.72, 0.72, 1]
}));
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceColor, "");
assert.deepEqual([...meshData.colors], []);
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[0].hasSourceColors, false);
});
test("STEP GLB mesh data preserves neutral source material colors", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeTwoPrimitiveOccurrenceGlb({
stepTopology: true,
materialBaseColors: [
[0, 0, 0, 1],
[1, 1, 1, 1]
]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "");
assert.deepEqual(meshData.parts.map((part) => part.color), ["#000000", "#ffffff"]);
assert.deepEqual(meshData.parts.map((part) => part.hasSourceColors), [true, true]);
});
test("STEP GLB mesh data honors explicit non-source material metadata", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [0.05, 0.05, 0.05, 1],
materialExtras: { cadSourceColor: false }
}));
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceColor, "");
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[0].hasSourceColors, false);
});
test("STEP GLB mesh data honors explicit source material metadata", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [0.72, 0.72, 0.72, 1],
materialExtras: { cadSourceColor: true }
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#dddddd");
assert.equal(meshData.parts[0].color, "#dddddd");
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves loaded material metadata on single-primitive meshes", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeIndexedMaterialOccurrenceGlb({
stepTopology: true
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#ff0000");
assert.equal(meshData.parts[0].color, "#ff0000");
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves non-gray material colors", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [1, 0, 0, 1]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#ff0000");
assert.equal(meshData.colors.length, 0);
assert.equal(meshData.parts[0].color, "#ff0000");
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves source material opacity", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [1, 0, 0, 0.2]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.parts[0].color, "#ff0000");
assert.equal(meshData.parts[0].opacity, 0.2);
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves colored primitives alongside generated default materials", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeTwoPrimitiveOccurrenceGlb({
stepTopology: true,
materialBaseColors: [
[0.72, 0.72, 0.72, 1],
[0.05, 0.33, 0.86, 1]
]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#3f9bef");
assert.equal(meshData.colors.length, 0);
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[0].hasSourceColors, false);
assert.equal(meshData.parts[1].color, "#3f9bef");
assert.equal(meshData.parts[1].hasSourceColors, true);
});
test("GLB mesh data assigns stable primitive indexes per occurrence", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeTwoPrimitiveOccurrenceGlb());
assert.equal(meshData.parts.length, 2);
assert.deepEqual(meshData.parts.map((part) => part.occurrenceId), ["o1.2", "o1.2"]);
assert.deepEqual(meshData.parts.map((part) => part.primitiveIndex), [0, 1]);
assert.deepEqual(meshData.parts.map((part) => part.triangleCount), [1, 1]);
});
@@ -0,0 +1,61 @@
import { buildMeshDataFromGlbBuffer } from "./glbMeshData.js";
import { meshDataTransferList } from "./meshTransfer.js";
const activeControllers = new Map();
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
async function loadArrayBuffer(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
self.addEventListener("message", async (event) => {
const message = event.data || {};
const id = message.id;
if (!id) {
return;
}
if (message.type === "cancel") {
activeControllers.get(id)?.abort();
activeControllers.delete(id);
return;
}
if (message.type !== "loadGlb") {
return;
}
const controller = new AbortController();
activeControllers.set(id, controller);
try {
const buffer = await loadArrayBuffer(message.url, controller.signal);
const meshData = await buildMeshDataFromGlbBuffer(buffer);
if (controller.signal.aborted) {
return;
}
self.postMessage(
{ id, ok: true, meshData },
meshDataTransferList(meshData)
);
} catch (error) {
if (!controller.signal.aborted) {
self.postMessage({
id,
ok: false,
error: {
name: error?.name || "Error",
message: error instanceof Error ? error.message : String(error)
}
});
}
} finally {
activeControllers.delete(id);
}
});
@@ -0,0 +1,94 @@
let glbWorker = null;
let nextRequestId = 1;
const pendingRequests = new Map();
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function glbWorkerSupported() {
return typeof Worker === "function" && typeof URL === "function";
}
function rejectPendingRequests(error) {
for (const request of pendingRequests.values()) {
request.cleanup();
request.reject(error);
}
pendingRequests.clear();
}
function ensureGlbWorker() {
if (!glbWorkerSupported()) {
return null;
}
if (glbWorker) {
return glbWorker;
}
try {
glbWorker = new Worker(new URL("./glbMeshWorker.js", import.meta.url), { type: "module" });
} catch {
glbWorker = null;
return null;
}
glbWorker.addEventListener("message", (event) => {
const message = event.data || {};
const request = pendingRequests.get(message.id);
if (!request) {
return;
}
pendingRequests.delete(message.id);
request.cleanup();
if (message.ok) {
request.resolve(message.meshData);
return;
}
const error = new Error(message.error?.message || "Failed to load GLB mesh in worker.");
error.name = message.error?.name || "Error";
request.reject(error);
});
glbWorker.addEventListener("error", (event) => {
const error = new Error(event?.message || "GLB mesh worker failed.");
rejectPendingRequests(error);
glbWorker?.terminate?.();
glbWorker = null;
});
return glbWorker;
}
export function loadGlbMeshDataInWorker(url, { signal } = {}) {
const worker = ensureGlbWorker();
if (!worker) {
return null;
}
if (signal?.aborted) {
return Promise.reject(makeAbortError());
}
const id = nextRequestId;
nextRequestId += 1;
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener?.("abort", abort);
};
const abort = () => {
pendingRequests.delete(id);
cleanup();
worker.postMessage({ type: "cancel", id });
reject(makeAbortError());
};
pendingRequests.set(id, {
resolve,
reject,
cleanup
});
signal?.addEventListener?.("abort", abort, { once: true });
worker.postMessage({ type: "loadGlb", id, url });
});
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadGlbMeshDataInWorker } from "./glbMeshWorkerClient.js";
import { meshDataTransferList } from "./meshTransfer.js";
test("GLB mesh worker client falls back when Worker is unavailable", () => {
assert.equal(loadGlbMeshDataInWorker("/mesh.glb"), null);
});
test("mesh transfer list includes unique non-empty typed-array buffers", () => {
const shared = new ArrayBuffer(16);
const transferList = meshDataTransferList({
vertices: new Float32Array(shared),
normals: new Float32Array(shared),
indices: new Uint32Array([0, 1, 2]),
colors: new Float32Array(0),
surfaceEdgeBarycentric: new Float32Array([1, 0, 0]),
surfaceEdgeClass: new Uint8Array([1, 0, 0])
});
assert.equal(transferList.length, 4);
assert.equal(transferList[0], shared);
});
@@ -0,0 +1,68 @@
import {
entryAssetBytes,
entryMeshAssetBytes
} from "../entryAssets.js";
import {
entrySourceFormat,
RENDER_FORMAT
} from "../fileFormats.js";
export const LARGE_STEP_GLB_BYTES = 32 * 1024 * 1024;
export const LARGE_MESH_TRIANGLE_COUNT = 1_000_000;
export const LARGE_MESH_TYPED_ARRAY_BYTES = 160 * 1024 * 1024;
function typedArrayBytes(value) {
return ArrayBuffer.isView(value) ? Number(value.byteLength) || 0 : 0;
}
export function entryStepGlbBytes(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.STEP
? entryAssetBytes(entry, "glb")
: 0;
}
export function hasStepGlbByteCost(entry) {
return entryStepGlbBytes(entry) > 0;
}
export function isLargeStepGlbEntry(entry) {
return entryStepGlbBytes(entry) >= LARGE_STEP_GLB_BYTES;
}
export function isLargeNativeGlbEntry(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.GLB &&
entryMeshAssetBytes(entry) >= LARGE_STEP_GLB_BYTES;
}
export function shouldUseGlbMeshWorkerForEntry(entry) {
return isLargeStepGlbEntry(entry) || isLargeNativeGlbEntry(entry);
}
export function estimateMeshRenderCost(meshData) {
const indicesTriangleCount = Math.floor((Number(meshData?.indices?.length) || 0) / 3);
const partTriangleCount = Array.isArray(meshData?.parts)
? meshData.parts.reduce((sum, part) => {
const triangleCount = Number(part?.triangleCount);
return sum + (Number.isFinite(triangleCount) && triangleCount > 0 ? triangleCount : 0);
}, 0)
: 0;
return {
triangleCount: Math.max(indicesTriangleCount, partTriangleCount),
typedArrayBytes: [
meshData?.vertices,
meshData?.indices,
meshData?.normals,
meshData?.colors,
meshData?.edge_indices,
meshData?.surfaceEdgeBarycentric,
meshData?.surfaceEdgeClass,
meshData?.guide_line_segments
].reduce((sum, value) => sum + typedArrayBytes(value), 0)
};
}
export function isLargeMeshData(meshData) {
const cost = estimateMeshRenderCost(meshData);
return cost.triangleCount >= LARGE_MESH_TRIANGLE_COUNT ||
cost.typedArrayBytes >= LARGE_MESH_TYPED_ARRAY_BYTES;
}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
estimateMeshRenderCost,
isLargeMeshData,
isLargeNativeGlbEntry,
isLargeStepGlbEntry,
LARGE_MESH_TRIANGLE_COUNT,
LARGE_MESH_TYPED_ARRAY_BYTES,
LARGE_STEP_GLB_BYTES,
shouldUseGlbMeshWorkerForEntry
} from "./meshCost.js";
test("large STEP GLBs are classified from catalog asset bytes", () => {
const smallStep = {
kind: "part",
url: "/parts/.small.step.glb",
hash: "small-hash",
bytes: LARGE_STEP_GLB_BYTES - 1
};
const largeStep = {
kind: "part",
url: "/parts/.large.step.glb",
hash: "large-hash",
bytes: LARGE_STEP_GLB_BYTES
};
const largeNativeGlb = {
kind: "glb",
url: "/meshes/large.glb",
hash: "large-glb-hash",
bytes: LARGE_STEP_GLB_BYTES
};
assert.equal(isLargeStepGlbEntry(smallStep), false);
assert.equal(isLargeStepGlbEntry(largeStep), true);
assert.equal(isLargeNativeGlbEntry(largeNativeGlb), true);
assert.equal(shouldUseGlbMeshWorkerForEntry(largeStep), true);
assert.equal(shouldUseGlbMeshWorkerForEntry(largeNativeGlb), true);
});
test("mesh render cost uses triangle count and typed-array bytes", () => {
const meshData = {
vertices: new Float32Array(9),
normals: new Float32Array(9),
colors: new Float32Array(0),
indices: new Uint32Array(3),
edge_indices: new Uint32Array(2),
surfaceEdgeBarycentric: new Float32Array(9),
surfaceEdgeClass: new Uint8Array(9),
parts: [{ triangleCount: LARGE_MESH_TRIANGLE_COUNT }]
};
assert.deepEqual(estimateMeshRenderCost(meshData), {
triangleCount: LARGE_MESH_TRIANGLE_COUNT,
typedArrayBytes: 137
});
assert.equal(isLargeMeshData(meshData), true);
assert.equal(isLargeMeshData({
vertices: new Uint8Array(LARGE_MESH_TYPED_ARRAY_BYTES),
indices: new Uint32Array(3)
}), true);
});
@@ -0,0 +1,78 @@
import {
renderFormatFromPath,
RENDER_FORMAT
} from "../fileFormats.js";
import {
loadRender3Mf,
loadRenderGlb,
loadRenderStl,
peekRender3Mf,
peekRenderGlb,
peekRenderStl
} from "../renderAssetClient.js";
function meshRenderFormat(format) {
return format === RENDER_FORMAT.STL || format === RENDER_FORMAT.THREE_MF || format === RENDER_FORMAT.GLB
? format
: "";
}
function renderFormatFromMeshAssetUrl(url) {
const directFormat = meshRenderFormat(renderFormatFromPath(url, {
baseUrl: typeof window !== "undefined" ? window.location.href : ""
}));
if (directFormat) {
return directFormat;
}
try {
const parsedUrl = new URL(url, typeof window !== "undefined" ? window.location.href : "http://localhost/");
for (const parameterName of ["file", "path"]) {
const parameterValue = parsedUrl.searchParams.get(parameterName);
const parameterFormat = meshRenderFormat(renderFormatFromPath(parameterValue || "", {
baseUrl: parsedUrl.href
}));
if (parameterFormat) {
return parameterFormat;
}
}
} catch {
// Fall through to the caller-provided fallback.
}
return "";
}
export function meshFormatFromUrl(url) {
return renderFormatFromMeshAssetUrl(url) || RENDER_FORMAT.GLB;
}
function normalizeMeshFallback(fallback) {
return fallback === RENDER_FORMAT.STL || fallback === RENDER_FORMAT.THREE_MF || fallback === RENDER_FORMAT.GLB
? fallback
: RENDER_FORMAT.GLB;
}
export function resolveMeshFormatFromUrl(url, { fallback = RENDER_FORMAT.GLB } = {}) {
return renderFormatFromMeshAssetUrl(url) || normalizeMeshFallback(fallback);
}
export function peekRenderMeshByUrl(url, options = {}) {
const format = resolveMeshFormatFromUrl(url, options);
if (format === RENDER_FORMAT.STL) {
return peekRenderStl(url);
}
if (format === RENDER_FORMAT.THREE_MF) {
return peekRender3Mf(url);
}
return peekRenderGlb(url);
}
export async function loadRenderMeshByUrl(url, options = {}) {
const format = resolveMeshFormatFromUrl(url, options);
if (format === RENDER_FORMAT.STL) {
return loadRenderStl(url, options);
}
if (format === RENDER_FORMAT.THREE_MF) {
return loadRender3Mf(url, options);
}
return loadRenderGlb(url, options);
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { RENDER_FORMAT } from "../fileFormats.js";
import {
meshFormatFromUrl,
resolveMeshFormatFromUrl
} from "./meshLoaders.js";
test("meshFormatFromUrl routes native mesh URLs to the correct loader family", () => {
assert.equal(meshFormatFromUrl("/assets/part.stl"), RENDER_FORMAT.STL);
assert.equal(meshFormatFromUrl("/assets/part.3mf?download=1"), RENDER_FORMAT.THREE_MF);
assert.equal(meshFormatFromUrl("/assets/part.glb"), RENDER_FORMAT.GLB);
assert.equal(meshFormatFromUrl("/assets/part.gltf#scene"), RENDER_FORMAT.GLB);
});
test("meshFormatFromUrl routes CAD asset URLs using the file query extension", () => {
assert.equal(
meshFormatFromUrl("/__cad/asset?file=%2Fworkspace%2Frobots%2Ftom%2F3MF%2Fsts3250.3mf&v=abc123"),
RENDER_FORMAT.THREE_MF
);
assert.equal(
meshFormatFromUrl("/__cad/asset?file=%2Fworkspace%2Frobots%2Ftom%2Fmeshes%2Ftool.glb"),
RENDER_FORMAT.GLB
);
});
test("meshFormatFromUrl keeps the headless GLB fallback by default", () => {
assert.equal(meshFormatFromUrl("/assets/link-mesh.obj"), RENDER_FORMAT.GLB);
assert.equal(meshFormatFromUrl("/assets/link-mesh"), RENDER_FORMAT.GLB);
});
test("resolveMeshFormatFromUrl allows viewer consumers to preserve STL fallback paths", () => {
assert.equal(
resolveMeshFormatFromUrl("/assets/link-mesh.obj", { fallback: RENDER_FORMAT.STL }),
RENDER_FORMAT.STL
);
assert.equal(
resolveMeshFormatFromUrl("/__cad/asset?file=%2Fworkspace%2Frobots%2Ftom%2F3MF%2Fsts3250.3mf", { fallback: RENDER_FORMAT.STL }),
RENDER_FORMAT.THREE_MF
);
});
@@ -0,0 +1,13 @@
export function meshDataTransferList(meshData) {
const buffers = [
meshData?.vertices?.buffer,
meshData?.indices?.buffer,
meshData?.normals?.buffer,
meshData?.colors?.buffer,
meshData?.edge_indices?.buffer,
meshData?.surfaceEdgeBarycentric?.buffer,
meshData?.surfaceEdgeClass?.buffer,
meshData?.guide_line_segments?.buffer
].filter((buffer) => buffer instanceof ArrayBuffer && buffer.byteLength > 0);
return [...new Set(buffers)];
}
@@ -0,0 +1,85 @@
function buildBoundsFromGeometry(geometry) {
geometry.computeBoundingBox();
const min = geometry.boundingBox?.min;
const max = geometry.boundingBox?.max;
if (!min || !max) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return {
min: [min.x, min.y, min.z],
max: [max.x, max.y, max.z],
};
}
const STL_NORMAL_CREASE_ANGLE = Math.PI / 4;
function buildDisplayGeometryFromStlGeometry(geometry, toCreasedNormals) {
const displayGeometry = geometry.clone();
displayGeometry.deleteAttribute?.("normal");
const normalGeometry = typeof toCreasedNormals === "function"
? toCreasedNormals(displayGeometry, STL_NORMAL_CREASE_ANGLE)
: displayGeometry;
if (normalGeometry !== displayGeometry) {
displayGeometry.dispose?.();
}
if (!normalGeometry.getAttribute("normal")) {
normalGeometry.computeVertexNormals?.();
}
return normalGeometry;
}
function buildGeometryIndices(geometry, vertexCount) {
const indexAttribute = geometry.getIndex?.();
if (indexAttribute?.count > 0) {
return new Uint32Array(indexAttribute.array);
}
const indices = new Uint32Array(vertexCount);
for (let index = 0; index < vertexCount; index += 1) {
indices[index] = index;
}
return indices;
}
export function buildMeshDataFromStlGeometry(geometry, { toCreasedNormals } = {}) {
const displayGeometry = buildDisplayGeometryFromStlGeometry(geometry, toCreasedNormals);
const positions = geometry.getAttribute("position");
const displayPositions = displayGeometry.getAttribute("position");
const displayNormals = displayGeometry.getAttribute("normal");
try {
if (!positions || positions.itemSize !== 3 || !displayPositions || displayPositions.itemSize !== 3) {
throw new Error("STL geometry is missing vertex positions");
}
const vertexCount = displayPositions.count;
return {
vertices: new Float32Array(displayPositions.array),
indices: buildGeometryIndices(displayGeometry, vertexCount),
normals: displayNormals?.itemSize === 3 ? new Float32Array(displayNormals.array) : new Float32Array(displayPositions.array.length),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromGeometry(geometry),
parts: [],
has_source_colors: false,
sourceColor: "",
sourceFormat: "stl",
};
} finally {
displayGeometry.dispose?.();
}
}
export async function buildMeshDataFromStlBuffer(buffer) {
const [{ STLLoader }, { toCreasedNormals }] = await Promise.all([
import("three/examples/jsm/loaders/STLLoader.js"),
import("three/examples/jsm/utils/BufferGeometryUtils.js"),
]);
const loader = new STLLoader();
const geometry = loader.parse(buffer);
try {
return buildMeshDataFromStlGeometry(geometry, { toCreasedNormals });
} finally {
geometry.dispose?.();
}
}
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { toCreasedNormals } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { buildMeshDataFromStlGeometry } from "./stlMeshData.js";
test("buildMeshDataFromStlGeometry recomputes crease-aware STL display normals", () => {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute([
0, 0, 0,
1, 0, 0,
1, 1, 0,
0, 0, 0,
1, 1, 0,
0, 1, 0
], 3)
);
geometry.setAttribute(
"normal",
new THREE.Float32BufferAttribute([
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1
], 3)
);
const meshData = buildMeshDataFromStlGeometry(geometry, { toCreasedNormals });
assert.equal(meshData.vertices.length, 18);
assert.equal(meshData.indices.length, 6);
assert.deepEqual([...meshData.normals.slice(0, 3)], [0, 0, 1]);
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceFormat, "stl");
});
@@ -0,0 +1,61 @@
import { buildMeshDataFromStlBuffer } from "./stlMeshData.js";
import { meshDataTransferList } from "./meshTransfer.js";
const activeControllers = new Map();
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
async function loadArrayBuffer(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
self.addEventListener("message", async (event) => {
const message = event.data || {};
const id = message.id;
if (!id) {
return;
}
if (message.type === "cancel") {
activeControllers.get(id)?.abort();
activeControllers.delete(id);
return;
}
if (message.type !== "loadStl") {
return;
}
const controller = new AbortController();
activeControllers.set(id, controller);
try {
const buffer = await loadArrayBuffer(message.url, controller.signal);
const meshData = await buildMeshDataFromStlBuffer(buffer);
if (controller.signal.aborted) {
return;
}
self.postMessage(
{ id, ok: true, meshData },
meshDataTransferList(meshData)
);
} catch (error) {
if (!controller.signal.aborted) {
self.postMessage({
id,
ok: false,
error: {
name: error?.name || "Error",
message: error instanceof Error ? error.message : String(error)
}
});
}
} finally {
activeControllers.delete(id);
}
});
@@ -0,0 +1,94 @@
let stlWorker = null;
let nextRequestId = 1;
const pendingRequests = new Map();
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function stlWorkerSupported() {
return typeof Worker === "function" && typeof URL === "function";
}
function rejectPendingRequests(error) {
for (const request of pendingRequests.values()) {
request.cleanup();
request.reject(error);
}
pendingRequests.clear();
}
function ensureStlWorker() {
if (!stlWorkerSupported()) {
return null;
}
if (stlWorker) {
return stlWorker;
}
try {
stlWorker = new Worker(new URL("./stlMeshWorker.js", import.meta.url), { type: "module" });
} catch {
stlWorker = null;
return null;
}
stlWorker.addEventListener("message", (event) => {
const message = event.data || {};
const request = pendingRequests.get(message.id);
if (!request) {
return;
}
pendingRequests.delete(message.id);
request.cleanup();
if (message.ok) {
request.resolve(message.meshData);
return;
}
const error = new Error(message.error?.message || "Failed to load STL mesh in worker.");
error.name = message.error?.name || "Error";
request.reject(error);
});
stlWorker.addEventListener("error", (event) => {
const error = new Error(event?.message || "STL mesh worker failed.");
rejectPendingRequests(error);
stlWorker?.terminate?.();
stlWorker = null;
});
return stlWorker;
}
export function loadStlMeshDataInWorker(url, { signal } = {}) {
const worker = ensureStlWorker();
if (!worker) {
return null;
}
if (signal?.aborted) {
return Promise.reject(makeAbortError());
}
const id = nextRequestId;
nextRequestId += 1;
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener?.("abort", abort);
};
const abort = () => {
pendingRequests.delete(id);
cleanup();
worker.postMessage({ type: "cancel", id });
reject(makeAbortError());
};
pendingRequests.set(id, {
resolve,
reject,
cleanup
});
signal?.addEventListener?.("abort", abort, { once: true });
worker.postMessage({ type: "loadStl", id, url });
});
}
@@ -0,0 +1,597 @@
const GENERATED_DEFAULT_MATERIAL_NAMES = new Set(["default"]);
const GENERATED_DEFAULT_MATERIAL_COLORS = new Set(["#fafafb", "#b6c4ce"]);
const WORKBENCH_DEFAULT_MATERIAL_COLOR = Object.freeze({
rgb: [0.4677837961, 0.5520114015, 0.6172065624],
hex: "#b6c4ce",
});
function buildBoundsFromVertices(vertices) {
if (!vertices?.length) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let index = 0; index + 2 < vertices.length; index += 3) {
const x = Number(vertices[index]);
const y = Number(vertices[index + 1]);
const z = Number(vertices[index + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
continue;
}
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return { min, max };
}
function normalize3MfPackagePath(value = "", basePath = "") {
const rawValue = String(value || "").trim().replace(/\\/g, "/");
if (!rawValue) {
return "";
}
if (rawValue.startsWith("/")) {
return rawValue.replace(/^\/+/, "");
}
const baseParts = String(basePath || "").split("/");
baseParts.pop();
const parts = [...baseParts, ...rawValue.split("/")];
const normalized = [];
for (const part of parts) {
if (!part || part === ".") {
continue;
}
if (part === "..") {
normalized.pop();
continue;
}
normalized.push(part);
}
return normalized.join("/");
}
function elementChildren(node, localName) {
return Array.from(node?.children || []).filter((child) => child.localName === localName);
}
function firstElementChild(node, localName) {
return elementChildren(node, localName)[0] || null;
}
function attributeByLocalName(node, localName) {
if (!node?.attributes) {
return "";
}
const direct = node.getAttribute?.(localName);
if (direct) {
return direct;
}
for (const attribute of Array.from(node.attributes)) {
if (attribute.localName === localName || String(attribute.name || "").split(":").pop() === localName) {
return attribute.value || "";
}
}
return "";
}
function parseNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function parseIndex(value) {
const numericValue = Number(value);
return Number.isInteger(numericValue) && numericValue >= 0 ? numericValue : -1;
}
function parse3MfXml(text, label) {
if (typeof DOMParser !== "function") {
throw new Error("3MF XML parsing is unavailable in this environment");
}
const document = new DOMParser().parseFromString(text, "application/xml");
const parseError = document.querySelector?.("parsererror");
if (parseError) {
throw new Error(`Invalid 3MF XML: ${label}`);
}
return document;
}
function parse3MfTransform(THREE, value) {
const transformValues = String(value || "")
.trim()
.split(/\s+/)
.map((item) => Number(item));
if (transformValues.length !== 12 || transformValues.some((item) => !Number.isFinite(item))) {
return null;
}
const matrix = new THREE.Matrix4();
matrix.set(
transformValues[0], transformValues[3], transformValues[6], transformValues[9],
transformValues[1], transformValues[4], transformValues[7], transformValues[10],
transformValues[2], transformValues[5], transformValues[8], transformValues[11],
0, 0, 0, 1
);
return matrix;
}
function parse3MfMeshNode(meshNode) {
const vertices = [];
const verticesNode = firstElementChild(meshNode, "vertices");
for (const vertexNode of elementChildren(verticesNode, "vertex")) {
vertices.push([
parseNumber(vertexNode.getAttribute("x")),
parseNumber(vertexNode.getAttribute("y")),
parseNumber(vertexNode.getAttribute("z")),
]);
}
const triangles = [];
const trianglesNode = firstElementChild(meshNode, "triangles");
for (const triangleNode of elementChildren(trianglesNode, "triangle")) {
const triangle = [
parseIndex(triangleNode.getAttribute("v1")),
parseIndex(triangleNode.getAttribute("v2")),
parseIndex(triangleNode.getAttribute("v3")),
];
if (triangle.every((index) => index >= 0)) {
triangles.push(triangle);
}
}
return { vertices, triangles };
}
function parse3MfModelPart(THREE, xmlText, modelPath) {
const document = parse3MfXml(xmlText, modelPath);
const modelNode = document.documentElement?.localName === "model"
? document.documentElement
: document.querySelector?.("model");
if (!modelNode) {
throw new Error(`3MF model part is missing a model node: ${modelPath}`);
}
const resourcesNode = firstElementChild(modelNode, "resources");
const objects = new Map();
for (const objectNode of elementChildren(resourcesNode, "object")) {
const id = String(objectNode.getAttribute("id") || "").trim();
if (!id) {
continue;
}
const name = String(objectNode.getAttribute("name") || objectNode.getAttribute("partnumber") || "").trim();
const meshNode = firstElementChild(objectNode, "mesh");
const componentsNode = firstElementChild(objectNode, "components");
const components = [];
for (const componentNode of elementChildren(componentsNode, "component")) {
const objectId = String(componentNode.getAttribute("objectid") || "").trim();
if (!objectId) {
continue;
}
components.push({
objectId,
path: normalize3MfPackagePath(attributeByLocalName(componentNode, "path"), modelPath),
transform: parse3MfTransform(THREE, componentNode.getAttribute("transform")),
});
}
objects.set(id, {
id,
name,
mesh: meshNode ? parse3MfMeshNode(meshNode) : null,
components,
});
}
const build = [];
const buildNode = firstElementChild(modelNode, "build");
for (const itemNode of elementChildren(buildNode, "item")) {
const objectId = String(itemNode.getAttribute("objectid") || "").trim();
if (!objectId) {
continue;
}
build.push({
objectId,
transform: parse3MfTransform(THREE, itemNode.getAttribute("transform")),
});
}
return {
path: modelPath,
objects,
build,
};
}
function parse3MfRelationships(xmlText, relsPath) {
const document = parse3MfXml(xmlText, relsPath);
return Array.from(document.querySelectorAll?.("Relationship") || [])
.map((node) => ({
target: normalize3MfPackagePath(node.getAttribute("Target")),
type: String(node.getAttribute("Type") || "").trim(),
}))
.filter((relationship) => relationship.target);
}
function colorFromMaterial(material) {
if (!material?.color) {
return null;
}
const hex = `#${material.color.getHexString()}`.toLowerCase();
const materialName = String(material.name || "").trim().toLowerCase();
const generatedDefault = GENERATED_DEFAULT_MATERIAL_NAMES.has(materialName) &&
GENERATED_DEFAULT_MATERIAL_COLORS.has(hex);
if (generatedDefault) {
return {
rgb: WORKBENCH_DEFAULT_MATERIAL_COLOR.rgb,
hex: WORKBENCH_DEFAULT_MATERIAL_COLOR.hex,
generatedDefault: true,
};
}
return {
rgb: [material.color.r, material.color.g, material.color.b],
hex,
generatedDefault: false,
};
}
function materialForGroup(material, group) {
if (Array.isArray(material)) {
const materialIndex = Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
return material[materialIndex] || material[0] || null;
}
return material || null;
}
function appendMeshPrimitive(THREE, accumulator, mesh, group, material) {
const geometry = mesh?.geometry;
const positions = geometry?.getAttribute?.("position");
if (!positions || positions.itemSize !== 3 || positions.count <= 0) {
return;
}
if (!geometry.getAttribute("normal")) {
geometry.computeVertexNormals?.();
}
mesh.updateWorldMatrix?.(true, false);
const matrixWorld = mesh.matrixWorld || null;
const normalMatrix = matrixWorld ? new THREE.Matrix3().getNormalMatrix(matrixWorld) : null;
const normals = geometry.getAttribute("normal");
const indexAttribute = geometry.getIndex?.();
const sourceStart = Math.max(0, Math.floor(Number(group?.start || 0)));
const availableCount = indexAttribute?.count || positions.count;
const rawCount = Math.floor(Number(group?.count || (availableCount - sourceStart)));
const sourceCount = Math.max(0, Math.min(rawCount, availableCount - sourceStart));
const triangleVertexCount = sourceCount - (sourceCount % 3);
if (triangleVertexCount <= 0) {
return;
}
const color = colorFromMaterial(material);
const positionVector = new THREE.Vector3();
const normalVector = new THREE.Vector3();
const vertexOffset = Math.floor(accumulator.vertices.length / 3);
const triangleOffset = Math.floor(accumulator.indices.length / 3);
const partVertices = [];
for (let localIndex = 0; localIndex < triangleVertexCount; localIndex += 1) {
const sourceSlot = sourceStart + localIndex;
const sourceIndex = indexAttribute ? indexAttribute.getX(sourceSlot) : sourceSlot;
if (sourceIndex < 0 || sourceIndex >= positions.count) {
continue;
}
const outputIndex = Math.floor(partVertices.length / 3);
positionVector.set(
positions.getX(sourceIndex),
positions.getY(sourceIndex),
positions.getZ(sourceIndex)
);
if (matrixWorld) {
positionVector.applyMatrix4(matrixWorld);
}
accumulator.vertices.push(positionVector.x, positionVector.y, positionVector.z);
partVertices.push(positionVector.x, positionVector.y, positionVector.z);
if (normals?.itemSize === 3 && sourceIndex < normals.count) {
normalVector.set(normals.getX(sourceIndex), normals.getY(sourceIndex), normals.getZ(sourceIndex));
if (normalMatrix) {
normalVector.applyMatrix3(normalMatrix).normalize();
}
accumulator.normals.push(normalVector.x, normalVector.y, normalVector.z);
} else {
accumulator.normals.push(0, 0, 0);
}
if (color) {
accumulator.colors.push(color.rgb[0], color.rgb[1], color.rgb[2]);
}
accumulator.indices.push(vertexOffset + outputIndex);
}
const vertexCount = Math.floor(partVertices.length / 3);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return;
}
const label = String(mesh?.name || mesh?.parent?.name || `3mf:${accumulator.parts.length}`).trim();
const id = `3mf:${accumulator.parts.length}`;
accumulator.parts.push({
id,
occurrenceId: id,
name: label || id,
label: label || id,
nodeType: "part",
color: color && !color.generatedDefault ? color.hex : "",
bounds: buildBoundsFromVertices(partVertices),
vertexOffset,
vertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
});
if (color?.hex && !color.generatedDefault) {
accumulator.colorSet.add(color.hex.toLowerCase());
}
}
export function buildMeshDataFromThreeMfGroup(THREE, group) {
const accumulator = {
vertices: [],
indices: [],
normals: [],
colors: [],
parts: [],
colorSet: new Set(),
};
group?.updateWorldMatrix?.(true, true);
group?.traverse?.((object) => {
if (object?.isMesh && object.geometry) {
const groups = Array.isArray(object.geometry.groups) && object.geometry.groups.length
? object.geometry.groups
: [null];
for (const geometryGroup of groups) {
appendMeshPrimitive(THREE, accumulator, object, geometryGroup, materialForGroup(object.material, geometryGroup));
}
}
});
const vertices = new Float32Array(accumulator.vertices);
const rawColors = accumulator.colors.length === accumulator.vertices.length
? new Float32Array(accumulator.colors)
: new Float32Array(0);
const hasSourceColors = rawColors.length === vertices.length && rawColors.length > 0 && accumulator.colorSet.size > 0;
return {
vertices,
indices: new Uint32Array(accumulator.indices),
normals: new Float32Array(accumulator.normals),
colors: hasSourceColors ? rawColors : new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromVertices(vertices),
parts: accumulator.parts,
has_source_colors: hasSourceColors,
sourceColor: accumulator.colorSet.size === 1 ? [...accumulator.colorSet][0] : "",
};
}
function appendParsedMeshPart(THREE, accumulator, meshData, matrix, label) {
if (!meshData?.vertices?.length || !meshData?.triangles?.length) {
return;
}
const vertexOffset = Math.floor(accumulator.vertices.length / 3);
const triangleOffset = Math.floor(accumulator.indices.length / 3);
const positionVector = new THREE.Vector3();
const transformedTriangle = [
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
];
const edgeA = new THREE.Vector3();
const edgeB = new THREE.Vector3();
const normal = new THREE.Vector3();
const partVertices = [];
for (const triangle of meshData.triangles) {
const sourceVertices = triangle.map((index) => meshData.vertices[index]);
if (sourceVertices.some((vertex) => !vertex)) {
continue;
}
for (let index = 0; index < 3; index += 1) {
const vertex = sourceVertices[index];
transformedTriangle[index].set(vertex[0], vertex[1], vertex[2]).applyMatrix4(matrix);
}
edgeA.subVectors(transformedTriangle[1], transformedTriangle[0]);
edgeB.subVectors(transformedTriangle[2], transformedTriangle[0]);
normal.crossVectors(edgeA, edgeB).normalize();
if (!Number.isFinite(normal.x) || !Number.isFinite(normal.y) || !Number.isFinite(normal.z)) {
normal.set(0, 0, 0);
}
for (const transformedVertex of transformedTriangle) {
positionVector.copy(transformedVertex);
const outputIndex = Math.floor(partVertices.length / 3);
accumulator.vertices.push(positionVector.x, positionVector.y, positionVector.z);
partVertices.push(positionVector.x, positionVector.y, positionVector.z);
accumulator.normals.push(normal.x, normal.y, normal.z);
accumulator.indices.push(vertexOffset + outputIndex);
}
}
const vertexCount = Math.floor(partVertices.length / 3);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return;
}
const id = `3mf:${accumulator.parts.length}`;
const partLabel = String(label || id).trim() || id;
accumulator.parts.push({
id,
occurrenceId: id,
name: partLabel,
label: partLabel,
nodeType: "part",
color: "",
bounds: buildBoundsFromVertices(partVertices),
vertexOffset,
vertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
});
}
function findObjectInAnyModel(models, objectId) {
for (const [modelPath, model] of models) {
if (model.objects.has(objectId)) {
return { modelPath, objectData: model.objects.get(objectId) };
}
}
return null;
}
function appendParsed3MfObject(THREE, accumulator, models, modelPath, objectId, matrix, stack = []) {
const stackKey = `${modelPath}#${objectId}`;
if (stack.includes(stackKey)) {
throw new Error(`3MF component cycle detected at ${stackKey}`);
}
const model = models.get(modelPath);
let objectData = model?.objects?.get(objectId) || null;
let resolvedModelPath = modelPath;
if (!objectData) {
const fallback = findObjectInAnyModel(models, objectId);
objectData = fallback?.objectData || null;
resolvedModelPath = fallback?.modelPath || modelPath;
}
if (!objectData) {
throw new Error(`3MF object ${objectId} is missing from ${modelPath}`);
}
if (objectData.mesh) {
appendParsedMeshPart(
THREE,
accumulator,
objectData.mesh,
matrix,
objectData.name || `${resolvedModelPath}#${objectData.id}`
);
return;
}
for (const component of objectData.components || []) {
const componentPath = component.path || resolvedModelPath;
const componentMatrix = matrix.clone();
if (component.transform) {
componentMatrix.multiply(component.transform);
}
appendParsed3MfObject(
THREE,
accumulator,
models,
componentPath,
component.objectId,
componentMatrix,
[...stack, stackKey]
);
}
}
function finalizeParsed3MfMesh(accumulator) {
const vertices = new Float32Array(accumulator.vertices);
return {
vertices,
indices: new Uint32Array(accumulator.indices),
normals: new Float32Array(accumulator.normals),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromVertices(vertices),
parts: accumulator.parts,
has_source_colors: false,
sourceColor: "",
};
}
async function buildMeshDataFrom3MfPackageFallback(THREE, buffer) {
const fflate = await import("three/examples/jsm/libs/fflate.module.js");
const textDecoder = new TextDecoder();
const zipEntries = fflate.unzipSync(new Uint8Array(buffer));
const textEntry = (entryPath) => {
const normalizedPath = normalize3MfPackagePath(entryPath);
const payload = zipEntries[normalizedPath];
return payload ? textDecoder.decode(payload) : "";
};
const modelPaths = Object.keys(zipEntries)
.filter((entryPath) => /^3D\/.*\.model$/i.test(entryPath))
.sort((left, right) => {
const leftRoot = /\/3dmodel\.model$/i.test(left) ? 1 : 0;
const rightRoot = /\/3dmodel\.model$/i.test(right) ? 1 : 0;
return leftRoot - rightRoot || left.localeCompare(right);
});
if (!modelPaths.length) {
throw new Error("3MF package does not contain any 3D model parts");
}
const models = new Map();
for (const modelPath of modelPaths) {
const xmlText = textEntry(modelPath);
if (xmlText) {
models.set(modelPath, parse3MfModelPart(THREE, xmlText, modelPath));
}
}
const relationshipsText = textEntry("_rels/.rels");
const rootModelPath = relationshipsText
? parse3MfRelationships(relationshipsText, "_rels/.rels")
.find((relationship) => /\.model$/i.test(relationship.target))?.target
: "";
const buildModelPath = models.has(rootModelPath) ? rootModelPath : modelPaths.find((modelPath) => models.get(modelPath)?.build?.length) || modelPaths[0];
const buildModel = models.get(buildModelPath);
if (!buildModel) {
throw new Error("3MF package does not contain a readable build model");
}
const identity = new THREE.Matrix4();
const accumulator = {
vertices: [],
indices: [],
normals: [],
parts: [],
};
const buildItems = buildModel.build.length
? buildModel.build
: Array.from(buildModel.objects.keys()).map((objectId) => ({ objectId, transform: null }));
for (const buildItem of buildItems) {
const matrix = buildItem.transform ? buildItem.transform.clone() : identity.clone();
appendParsed3MfObject(THREE, accumulator, models, buildModelPath, buildItem.objectId, matrix);
}
const meshData = finalizeParsed3MfMesh(accumulator);
if (!meshData.indices.length) {
throw new Error("3MF package did not produce any triangles");
}
return meshData;
}
export async function buildMeshDataFrom3MfBuffer(buffer) {
const [THREE, { ThreeMFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/3MFLoader.js"),
]);
const loader = new ThreeMFLoader();
try {
return buildMeshDataFromThreeMfGroup(THREE, loader.parse(buffer));
} catch (error) {
try {
return await buildMeshDataFrom3MfPackageFallback(THREE, buffer);
} catch {
throw error;
}
}
}
@@ -0,0 +1,272 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMeshDataFromThreeMfGroup } from "./threeMfMeshData.js";
class FakeVector3 {
set(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
return this;
}
applyMatrix4() {
return this;
}
applyMatrix3() {
return this;
}
normalize() {
return this;
}
}
class FakeMatrix3 {
getNormalMatrix() {
return this;
}
}
function fakeAttribute(values) {
return {
itemSize: 3,
count: values.length / 3,
getX: (index) => values[index * 3],
getY: (index) => values[index * 3 + 1],
getZ: (index) => values[index * 3 + 2],
};
}
function assertArrayAlmostEqual(actual, expected, epsilon = 1e-6) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < actual.length; index += 1) {
assert.ok(
Math.abs(Number(actual[index]) - Number(expected[index])) <= epsilon,
`index ${index}: expected ${expected[index]}, got ${actual[index]}`
);
}
}
test("buildMeshDataFromThreeMfGroup normalizes 3MF meshes to viewer mesh data", () => {
const mesh = {
isMesh: true,
name: "fixture",
material: null,
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0]);
}
if (name === "normal") {
return fakeAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assert.deepEqual([...meshData.vertices], [0, 0, 0, 1, 0, 0, 0, 1, 0]);
assert.deepEqual([...meshData.indices], [0, 1, 2]);
assert.deepEqual([...meshData.normals], [0, 0, 1, 0, 0, 1, 0, 0, 1]);
assert.deepEqual([...meshData.colors], []);
assert.deepEqual(meshData.bounds, { min: [0, 0, 0], max: [1, 1, 0] });
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].name, "fixture");
assert.equal(meshData.has_source_colors, false);
});
test("buildMeshDataFromThreeMfGroup preserves 3MF material colors", () => {
const mesh = {
isMesh: true,
name: "colored fixture",
material: {
color: {
r: 0.25,
g: 0.5,
b: 0.75,
getHexString: () => "4080bf",
},
},
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0]);
}
if (name === "normal") {
return fakeAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assert.deepEqual([...meshData.colors], [0.25, 0.5, 0.75, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75]);
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#4080bf");
assert.equal(meshData.parts[0].color, "#4080bf");
});
test("buildMeshDataFromThreeMfGroup treats generated default material as uncolored", () => {
const mesh = {
isMesh: true,
name: "default fixture",
material: {
name: "Default",
color: {
r: 0.4677837961,
g: 0.5520114015,
b: 0.6172065624,
getHexString: () => "b6c4ce",
},
},
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0]);
}
if (name === "normal") {
return fakeAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assert.deepEqual([...meshData.colors], []);
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceColor, "");
assert.equal(meshData.parts[0].color, "");
});
test("buildMeshDataFromThreeMfGroup keeps authored colors when mixed with generated defaults", () => {
const defaultRgb = [0.4677837961, 0.5520114015, 0.6172065624];
const mesh = {
isMesh: true,
name: "mixed fixture",
material: [
{
name: "Default",
color: {
r: defaultRgb[0],
g: defaultRgb[1],
b: defaultRgb[2],
getHexString: () => "b6c4ce",
},
},
{
name: "red",
color: {
r: 1,
g: 0,
b: 0,
getHexString: () => "ff0000",
},
},
],
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
groups: [
{ start: 0, count: 3, materialIndex: 0 },
{ start: 3, count: 3, materialIndex: 1 },
],
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([
0, 0, 0,
1, 0, 0,
0, 1, 0,
1, 0, 0,
1, 1, 0,
0, 1, 0,
]);
}
if (name === "normal") {
return fakeAttribute([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assertArrayAlmostEqual([...meshData.colors], [
...defaultRgb,
...defaultRgb,
...defaultRgb,
1, 0, 0,
1, 0, 0,
1, 0, 0,
]);
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#ff0000");
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[1].color, "#ff0000");
});
@@ -0,0 +1,618 @@
import { parseDxf } from "./dxf/parseDxf.js";
import { parseGcode } from "./gcode/parseGcode.js";
import {
STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
STEP_EDGE_CLASS_ATTRIBUTE,
STEP_TOPOLOGY_EXTENSION,
STEP_TOPOLOGY_SCHEMA_VERSION,
isCurrentStepTopologySchemaVersion
} from "../common/stepTopology.mjs";
import { buildMeshDataFromGlbBuffer } from "./render/glbMeshData.js";
import { buildMeshDataFromStlBuffer } from "./render/stlMeshData.js";
import { buildMeshDataFrom3MfBuffer } from "./render/threeMfMeshData.js";
import { loadGlbMeshDataInWorker } from "./render/glbMeshWorkerClient.js";
import { loadStlMeshDataInWorker } from "./render/stlMeshWorkerClient.js";
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const jsonCache = new Map();
const textCache = new Map();
const arrayBufferCache = new Map();
const arrayBufferPendingCache = new Map();
const glbCache = new Map();
const stlCache = new Map();
const threeMfCache = new Map();
const selectorCache = new Map();
const displayEdgeCache = new Map();
const topologyIndexCache = new Map();
const dxfCache = new Map();
const gcodeCache = new Map();
const urdfCache = new Map();
const srdfCache = new Map();
const sdfCache = new Map();
const GIT_LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1";
const GIT_LFS_POINTER_SCAN_BYTES = 512;
async function fetchJson(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.json();
}
async function fetchText(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.text();
}
async function fetchArrayBuffer(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
async function loadCached(cache, key, loader, { cachePending = true } = {}) {
if (!key) {
throw new Error("Missing asset cache key");
}
if (cache.has(key)) {
const cached = cache.get(key);
if (cachePending || typeof cached?.then !== "function") {
return cached;
}
}
if (!cachePending) {
const payload = await loader();
cache.set(key, payload);
return payload;
}
let pending;
pending = loader().catch((error) => {
if (cache.get(key) === pending) {
cache.delete(key);
}
throw error;
});
cache.set(key, pending);
return pending;
}
function peekCached(cache, key) {
const value = cache.get(key);
return value && typeof value.then !== "function" ? value : null;
}
function finalizeCached(cache, key, value) {
cache.set(key, value);
return value;
}
export function isAbortError(error) {
return error instanceof DOMException && error.name === "AbortError";
}
export function gitLfsPointerDetailsFromBuffer(buffer) {
const byteLength = Number(buffer?.byteLength || 0);
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return null;
}
const preview = new TextDecoder("utf-8").decode(
new Uint8Array(buffer, 0, Math.min(byteLength, GIT_LFS_POINTER_SCAN_BYTES))
);
if (!preview.startsWith(GIT_LFS_POINTER_PREFIX)) {
return null;
}
const oid = preview.match(/^oid sha256:([0-9a-f]{64})\r?$/m)?.[1] || "";
const sizeText = preview.match(/^size ([0-9]+)\r?$/m)?.[1] || "";
const size = sizeText ? Number(sizeText) : null;
return {
oid,
size: Number.isSafeInteger(size) ? size : null,
};
}
function displayPathFromUrl(url) {
const rawUrl = String(url || "").trim();
if (!rawUrl) {
return "(unknown)";
}
try {
const baseUrl = typeof window !== "undefined" && window.location?.href
? window.location.href
: "http://localhost/";
const parsed = new URL(rawUrl, baseUrl);
return decodeURIComponent(parsed.pathname || rawUrl);
} catch {
return rawUrl.split(/[?#]/)[0] || rawUrl;
}
}
export function assertNotGitLfsPointer(buffer, url, assetLabel = "Render asset") {
const pointer = gitLfsPointerDetailsFromBuffer(buffer);
if (!pointer) {
return;
}
const sizeText = pointer.size !== null ? ` Expected LFS object size: ${pointer.size} bytes.` : "";
const oidText = pointer.oid ? ` sha256:${pointer.oid}.` : "";
throw new Error(
`${assetLabel} is a Git LFS pointer, not downloaded mesh data: ${displayPathFromUrl(url)}.${oidText}${sizeText} Fetch the LFS object for this file and reload the viewer.`
);
}
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function withConsumerAbort(promise, signal) {
if (!signal) {
return promise;
}
if (signal.aborted) {
return Promise.reject(makeAbortError());
}
return new Promise((resolve, reject) => {
const cleanup = () => {
signal.removeEventListener?.("abort", abort);
};
const abort = () => {
cleanup();
reject(makeAbortError());
};
signal.addEventListener?.("abort", abort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
}
);
});
}
export async function loadRenderJson(url, { signal } = {}) {
const payload = await loadCached(jsonCache, url, () => fetchJson(url, { signal }), { cachePending: !signal });
return finalizeCached(jsonCache, url, payload);
}
export function peekRenderJson(url) {
return peekCached(jsonCache, url);
}
export async function loadRenderText(url, { signal } = {}) {
const payload = await loadCached(textCache, url, () => fetchText(url, { signal }), { cachePending: !signal });
return finalizeCached(textCache, url, payload);
}
export function peekRenderText(url) {
return peekCached(textCache, url);
}
export async function loadRenderArrayBuffer(url, { signal } = {}) {
if (!url) {
throw new Error("Missing asset cache key");
}
if (signal?.aborted) {
throw makeAbortError();
}
const cached = peekCached(arrayBufferCache, url);
if (cached) {
return cached;
}
let pending = arrayBufferPendingCache.get(url);
if (!pending) {
pending = fetchArrayBuffer(url)
.then((payload) => finalizeCached(arrayBufferCache, url, payload))
.finally(() => {
if (arrayBufferPendingCache.get(url) === pending) {
arrayBufferPendingCache.delete(url);
}
});
arrayBufferPendingCache.set(url, pending);
}
return withConsumerAbort(pending, signal);
}
export function peekRenderArrayBuffer(url) {
return peekCached(arrayBufferCache, url);
}
export async function loadRenderGlb(url, { signal, preferWorker = false } = {}) {
const meshData = await loadCached(glbCache, url, async () => {
if (preferWorker) {
const workerMeshData = loadGlbMeshDataInWorker(url, { signal });
if (workerMeshData) {
try {
return await workerMeshData;
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
}
}
}
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "GLB render asset");
return buildMeshDataFromGlbBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(glbCache, url, meshData);
}
export function peekRenderGlb(url) {
return peekCached(glbCache, url);
}
export async function loadRenderStl(url, { signal } = {}) {
const meshData = await loadCached(stlCache, url, async () => {
const workerMeshData = loadStlMeshDataInWorker(url, { signal });
if (workerMeshData) {
try {
return await workerMeshData;
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
throw error;
}
}
}
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "STL render asset");
return buildMeshDataFromStlBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(stlCache, url, meshData);
}
export function peekRenderStl(url) {
return peekCached(stlCache, url);
}
export async function loadRender3Mf(url, { signal } = {}) {
const meshData = await loadCached(threeMfCache, url, async () => {
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "3MF render asset");
return buildMeshDataFrom3MfBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(threeMfCache, url, meshData);
}
export function peekRender3Mf(url) {
return peekCached(threeMfCache, url);
}
function parseGlbContainer(arrayBuffer) {
const data = new DataView(arrayBuffer);
if (data.byteLength < 20 || data.getUint32(0, true) !== 0x46546c67 || data.getUint32(4, true) !== 2) {
throw new Error("Invalid GLB topology container");
}
const totalLength = Math.min(data.getUint32(8, true), data.byteLength);
let offset = 12;
let json = null;
let bin = null;
while (offset + 8 <= totalLength) {
const chunkLength = data.getUint32(offset, true);
const chunkType = data.getUint32(offset + 4, true);
offset += 8;
if (offset + chunkLength > totalLength) {
throw new Error("Invalid GLB chunk length");
}
if (chunkType === 0x4e4f534a) {
json = JSON.parse(new TextDecoder("utf-8").decode(arrayBuffer.slice(offset, offset + chunkLength)).trim());
} else if (chunkType === 0x004e4942) {
bin = {
buffer: arrayBuffer,
byteOffset: offset,
byteLength: chunkLength,
};
}
offset += chunkLength;
}
if (!json || !bin) {
throw new Error("GLB topology requires JSON and BIN chunks");
}
return { json, bin };
}
function glbBufferViewRange(gltf, bin, viewIndex) {
const index = Number(viewIndex);
const view = Array.isArray(gltf?.bufferViews) ? gltf.bufferViews[index] : null;
if (!Number.isInteger(index) || !view || Number(view.buffer || 0) !== 0) {
return null;
}
const byteOffset = bin.byteOffset + Number(view.byteOffset || 0);
const byteLength = Number(view.byteLength || 0);
if (!Number.isFinite(byteOffset) || !Number.isFinite(byteLength) || byteLength < 0) {
return null;
}
if (byteOffset < bin.byteOffset || byteOffset + byteLength > bin.byteOffset + bin.byteLength) {
return null;
}
return { byteOffset, byteLength };
}
function buildTypedView(glb, view) {
if (!isObject(view)) {
return null;
}
const range = glbBufferViewRange(glb.json, glb.bin, view.bufferView);
if (!range) {
return null;
}
const count = Number(view.count || 0);
const relativeOffset = Number(view.byteOffset || 0);
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(relativeOffset) || relativeOffset < 0) {
return null;
}
const byteOffset = range.byteOffset + relativeOffset;
if (view.dtype === "float32") {
return new Float32Array(glb.bin.buffer, byteOffset, count);
}
if (view.dtype === "uint32") {
return new Uint32Array(glb.bin.buffer, byteOffset, count);
}
return null;
}
function buildSelectorBuffers(manifest, glb) {
const views = manifest?.buffers?.views;
if (!isObject(views)) {
return {};
}
const output = {};
for (const [name, view] of Object.entries(views)) {
const typed = buildTypedView(glb, view);
if (typed) {
output[name] = typed;
}
}
return output;
}
function glbPrimitivesHaveSurfaceEdgeAttributes(glb) {
const meshes = Array.isArray(glb?.json?.meshes) ? glb.json.meshes : [];
let primitiveCount = 0;
for (const mesh of meshes) {
for (const primitive of Array.isArray(mesh?.primitives) ? mesh.primitives : []) {
primitiveCount += 1;
const attributes = primitive?.attributes || {};
if (
attributes[STEP_EDGE_BARYCENTRIC_ATTRIBUTE] === undefined ||
attributes[STEP_EDGE_CLASS_ATTRIBUTE] === undefined
) {
return false;
}
}
}
return primitiveCount > 0;
}
function stepTopologyExtension(glb) {
const extension = glb.json?.extensions?.[STEP_TOPOLOGY_EXTENSION];
if (!isObject(extension)) {
throw new Error(`GLB is missing ${STEP_TOPOLOGY_EXTENSION}`);
}
if (!isCurrentStepTopologySchemaVersion(extension.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} schemaVersion ${extension.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (extension.edgeView === undefined || extension.edgeView === null) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView is not available`);
}
if (!glbPrimitivesHaveSurfaceEdgeAttributes(glb)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} requires ${STEP_EDGE_BARYCENTRIC_ATTRIBUTE} and ${STEP_EDGE_CLASS_ATTRIBUTE} on every STEP mesh primitive`);
}
return extension;
}
function parseJsonBufferView(glb, viewIndex, encoding = "utf-8") {
const range = glbBufferViewRange(glb.json, glb.bin, viewIndex);
if (!range) {
return null;
}
const bytes = new Uint8Array(glb.bin.buffer, range.byteOffset, range.byteLength);
return JSON.parse(new TextDecoder(String(encoding || "utf-8")).decode(bytes));
}
function topologyIndexFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.indexView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} indexView is invalid`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} index schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
return manifest;
}
function selectorBundleFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.selectorView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} selectorView is not available`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} selector schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (manifest?.buffers?.littleEndian === false) {
throw new Error("Big-endian selector buffers are not supported");
}
return {
manifest,
buffers: buildSelectorBuffers(manifest, glb),
};
}
function displayEdgeBundleFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.edgeView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView is not available`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} edgeView schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (String(manifest.profile || "") !== "surface-edges") {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView has unsupported profile ${manifest.profile || "unknown"}`);
}
if (manifest?.buffers?.littleEndian === false) {
throw new Error("Big-endian edgeView buffers are not supported");
}
return {
manifest,
buffers: buildSelectorBuffers(manifest, glb),
};
}
export async function loadRenderTopologyIndex(glbUrl, { signal } = {}) {
const manifest = await loadCached(topologyIndexCache, glbUrl, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB topology asset");
return topologyIndexFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(topologyIndexCache, glbUrl, manifest);
}
export function peekRenderTopologyIndex(glbUrl) {
return peekCached(topologyIndexCache, glbUrl);
}
export async function loadRenderSelectorBundle(glbUrl, { signal } = {}) {
const cacheKey = glbUrl;
const bundle = await loadCached(selectorCache, cacheKey, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB selector topology asset");
return selectorBundleFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(selectorCache, cacheKey, bundle);
}
export function peekRenderSelectorBundle(glbUrl) {
return peekCached(selectorCache, glbUrl);
}
export async function loadRenderDisplayEdgeBundle(glbUrl, { signal } = {}) {
const cacheKey = glbUrl;
const bundle = await loadCached(displayEdgeCache, cacheKey, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB display edge topology asset");
return displayEdgeBundleFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(displayEdgeCache, cacheKey, bundle);
}
export function peekRenderDisplayEdgeBundle(glbUrl) {
return peekCached(displayEdgeCache, glbUrl);
}
export async function loadRenderDxf(url, { signal } = {}) {
const payload = await loadCached(dxfCache, url, async () => {
const dxfText = await loadRenderText(url, { signal });
return parseDxf(dxfText, { fileRef: "", sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(dxfCache, url, payload);
}
export function peekRenderDxf(url) {
return peekCached(dxfCache, url);
}
export async function loadRenderGcode(url, { signal } = {}) {
const payload = await loadCached(gcodeCache, url, async () => {
const gcodeText = await loadRenderText(url, { signal });
return parseGcode(gcodeText, { fileRef: "", sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(gcodeCache, url, payload);
}
export function peekRenderGcode(url) {
return peekCached(gcodeCache, url);
}
function urdfCacheKey(url) {
return String(url || "");
}
export async function loadRenderUrdf(url, { signal } = {}) {
const cacheKey = urdfCacheKey(url);
const payload = await loadCached(urdfCache, cacheKey, async () => {
const [xmlText, { parseUrdf }] = await Promise.all([
loadRenderText(url, { signal }),
import("./urdf/parseUrdf.js"),
]);
return parseUrdf(xmlText, { sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(urdfCache, cacheKey, payload);
}
export function peekRenderUrdf(url) {
return peekCached(urdfCache, urdfCacheKey(url));
}
function srdfCacheKey(srdfUrl, urdfUrl = "") {
return [srdfUrl, urdfUrl].filter(Boolean).join("::");
}
export async function loadRenderSrdf(srdfUrl, { signal, urdfUrl = "" } = {}) {
const cacheKey = srdfCacheKey(srdfUrl, urdfUrl);
const payload = await loadCached(srdfCache, cacheKey, async () => {
const [srdfText, urdfData, { parseSrdf, motionFromSrdf }] = await Promise.all([
loadRenderText(srdfUrl, { signal }),
loadRenderUrdf(urdfUrl, { signal }),
import("./urdf/parseSrdf.js"),
]);
const srdfData = parseSrdf(srdfText, { sourceUrl: srdfUrl, urdfData });
return {
srdfData,
urdfData: {
...urdfData,
motion: motionFromSrdf(srdfData),
srdf: srdfData
}
};
}, { cachePending: !signal });
return finalizeCached(srdfCache, cacheKey, payload);
}
export function peekRenderSrdf(srdfUrl, { urdfUrl = "" } = {}) {
return peekCached(srdfCache, srdfCacheKey(srdfUrl, urdfUrl));
}
function sdfCacheKey(url) {
return String(url || "");
}
export async function loadRenderSdf(url, { signal } = {}) {
const cacheKey = sdfCacheKey(url);
const payload = await loadCached(sdfCache, cacheKey, async () => {
const [xmlText, { parseSdf }] = await Promise.all([
loadRenderText(url, { signal }),
import("./urdf/parseSdf.js"),
]);
return parseSdf(xmlText, { sourceUrl: url });
}, { cachePending: !signal });
return finalizeCached(sdfCache, cacheKey, payload);
}
export function peekRenderSdf(url) {
return peekCached(sdfCache, sdfCacheKey(url));
}
@@ -0,0 +1,379 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
STEP_TOPOLOGY_SCHEMA_VERSION
} from "../common/stepTopology.mjs";
import {
gitLfsPointerDetailsFromBuffer,
loadRender3Mf,
loadRenderArrayBuffer,
loadRenderGcode,
loadRenderGlb,
loadRenderJson,
loadRenderSdf,
loadRenderDisplayEdgeBundle,
loadRenderSelectorBundle,
loadRenderTopologyIndex,
peekRenderGcode,
peekRenderSdf
} from "./renderAssetClient.js";
class FakeElement {
constructor(tagName, attributes = {}, children = [], text = "") {
this.nodeType = 1;
this.tagName = tagName;
this.localName = String(tagName || "").split(":").pop();
this._attributes = { ...attributes };
this.childNodes = children;
this._text = String(text || "");
}
getAttribute(name) {
return Object.hasOwn(this._attributes, name) ? this._attributes[name] : null;
}
get textContent() {
return `${this._text}${this.childNodes.map((child) => String(child?.textContent || "")).join("")}`;
}
}
class FakeDocument {
constructor(documentElement) {
this.documentElement = documentElement;
}
querySelector(selector) {
return selector === "parsererror" ? null : null;
}
}
function el(tagName, attributes = {}, children = [], text = "") {
return new FakeElement(tagName, attributes, children, text);
}
function pad4(buffer, byte = 0) {
const padding = (4 - (buffer.length % 4)) % 4;
return padding ? Buffer.concat([buffer, Buffer.alloc(padding, byte)]) : buffer;
}
function topologyGlb(manifest, buffers = {}, { schemaVersion = STEP_TOPOLOGY_SCHEMA_VERSION } = {}) {
const bufferViews = [];
let binary = Buffer.alloc(0);
function addBufferView(payload) {
binary = pad4(binary);
const byteOffset = binary.length;
binary = Buffer.concat([binary, payload]);
const index = bufferViews.length;
bufferViews.push({ buffer: 0, byteOffset, byteLength: payload.length });
return index;
}
const selectorManifest = JSON.parse(JSON.stringify(manifest));
selectorManifest.schemaVersion = schemaVersion;
selectorManifest.profile = "selector";
selectorManifest.buffers = { littleEndian: true, views: {} };
for (const [name, { dtype, payload, count, itemSize }] of Object.entries(buffers)) {
selectorManifest.buffers.views[name] = {
dtype,
bufferView: addBufferView(payload),
byteOffset: 0,
byteLength: payload.length,
count,
itemSize,
};
}
const edgeManifest = {
schemaVersion,
profile: "surface-edges",
stepHash: manifest.stepHash || "",
classCodes: { none: 0, feature: 1, tangent: 2, seam: 3, degenerate: 4, boundary: 5, nonManifold: 6, unknown: 7 },
primitiveAttributes: {
barycentric: "_CAD_EDGE_BARYCENTRIC",
class: "_CAD_EDGE_CLASS",
},
halfEdgeColumns: ["edgeRow", "faceRow", "occurrenceRow", "primitiveIndex", "triangleIndex", "side", "classCode"],
halfEdgesView: "surfaceHalfEdges",
buffers: {
littleEndian: true,
views: Object.fromEntries(
Object.entries(selectorManifest.buffers.views)
.filter(([name]) => name === "surfaceHalfEdges")
)
}
};
const indexManifest = {
schemaVersion,
profile: "index",
entryKind: manifest.entryKind || "part",
cadRef: manifest.cadRef,
stats: manifest.stats || {},
tables: manifest.tables?.occurrenceColumns ? { occurrenceColumns: manifest.tables.occurrenceColumns } : {},
occurrences: manifest.occurrences || [],
...(manifest.assembly ? { assembly: manifest.assembly } : {}),
};
const indexView = addBufferView(Buffer.from(JSON.stringify(indexManifest), "utf8"));
const edgeView = addBufferView(Buffer.from(JSON.stringify(edgeManifest), "utf8"));
const selectorView = addBufferView(Buffer.from(JSON.stringify(selectorManifest), "utf8"));
binary = pad4(binary);
const gltf = {
asset: { version: "2.0" },
buffers: [{ byteLength: binary.length }],
bufferViews,
meshes: [{
primitives: [{
attributes: {
_CAD_EDGE_BARYCENTRIC: 0,
_CAD_EDGE_CLASS: 1,
},
}],
}],
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {
schemaVersion,
entryKind: indexManifest.entryKind,
indexView,
edgeView,
selectorView,
encoding: "utf-8",
},
},
};
const jsonChunk = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
const jsonHeader = Buffer.alloc(8);
const binHeader = Buffer.alloc(8);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + jsonChunk.length + 8 + binary.length, 8);
jsonHeader.writeUInt32LE(jsonChunk.length, 0);
jsonHeader.write("JSON", 4, "latin1");
binHeader.writeUInt32LE(binary.length, 0);
binHeader.write("BIN\0", 4, "latin1");
return Buffer.concat([header, jsonHeader, jsonChunk, binHeader, binary]);
}
function abortError() {
return new DOMException("The operation was aborted.", "AbortError");
}
function gitLfsPointerBuffer({ oid = "a".repeat(64), size = 12345 } = {}) {
return Buffer.from([
"version https://git-lfs.github.com/spec/v1",
`oid sha256:${oid}`,
`size ${size}`,
""
].join("\n"));
}
test("abortable loads do not reuse a stale pending cache entry", async (t) => {
const originalFetch = globalThis.fetch;
const requests = [];
const url = `/asset-${Date.now()}-${Math.random()}.json`;
globalThis.fetch = async (requestUrl, { signal } = {}) => new Promise((resolve, reject) => {
const request = { requestUrl, resolve, reject, signal };
requests.push(request);
if (signal?.aborted) {
reject(abortError());
return;
}
signal?.addEventListener("abort", () => reject(abortError()), { once: true });
});
t.after(() => {
globalThis.fetch = originalFetch;
});
const firstController = new AbortController();
const firstLoad = loadRenderJson(url, { signal: firstController.signal });
assert.equal(requests.length, 1);
firstController.abort();
const secondController = new AbortController();
const secondLoad = loadRenderJson(url, { signal: secondController.signal });
assert.equal(requests.length, 2);
requests[1].resolve(new Response(JSON.stringify({ ok: true }), { status: 200 }));
await assert.rejects(firstLoad, { name: "AbortError" });
assert.deepEqual(await secondLoad, { ok: true });
});
test("array buffer loads share pending fetches while aborting only the consumer", async (t) => {
const originalFetch = globalThis.fetch;
const requests = [];
const url = `/asset-${Date.now()}-${Math.random()}.glb`;
const payload = new Uint8Array([1, 2, 3, 4]).buffer;
globalThis.fetch = async (requestUrl) => new Promise((resolve) => {
requests.push({ requestUrl, resolve });
});
t.after(() => {
globalThis.fetch = originalFetch;
});
const firstController = new AbortController();
const secondController = new AbortController();
const firstLoad = loadRenderArrayBuffer(url, { signal: firstController.signal });
const secondLoad = loadRenderArrayBuffer(url, { signal: secondController.signal });
assert.equal(requests.length, 1);
firstController.abort();
requests[0].resolve(new Response(payload, { status: 200 }));
await assert.rejects(firstLoad, { name: "AbortError" });
assert.deepEqual([...new Uint8Array(await secondLoad)], [1, 2, 3, 4]);
});
test("Git LFS pointer stubs are detected before mesh parsing", async (t) => {
const originalFetch = globalThis.fetch;
const oid = "b".repeat(64);
const pointer = gitLfsPointerBuffer({ oid, size: 24992 });
const glbUrl = `/asset-${Date.now()}-${Math.random()}.glb`;
const threeMfUrl = `/asset-${Date.now()}-${Math.random()}.3mf`;
assert.deepEqual(gitLfsPointerDetailsFromBuffer(pointer.buffer.slice(pointer.byteOffset, pointer.byteOffset + pointer.byteLength)), {
oid,
size: 24992,
});
globalThis.fetch = async (requestUrl) => {
assert.ok([glbUrl, threeMfUrl].includes(String(requestUrl)));
return new Response(pointer, { status: 200 });
};
t.after(() => {
globalThis.fetch = originalFetch;
});
await assert.rejects(
loadRenderGlb(glbUrl),
/GLB render asset is a Git LFS pointer, not downloaded mesh data/
);
await assert.rejects(
loadRender3Mf(threeMfUrl),
/3MF render asset is a Git LFS pointer, not downloaded mesh data/
);
});
test("selector bundles decode STEP_topology bufferViews from GLB", async (t) => {
const originalFetch = globalThis.fetch;
const glbUrl = `/topology-${Date.now()}-${Math.random()}.glb`;
const edgeIds = Buffer.alloc(8);
edgeIds.writeUInt32LE(7, 0);
edgeIds.writeUInt32LE(11, 4);
const surfaceHalfEdges = Buffer.alloc(28);
surfaceHalfEdges.writeUInt32LE(7, 0);
surfaceHalfEdges.writeUInt32LE(11, 4);
const requests = [];
const glb = topologyGlb(
{ cadRef: "fixtures/box", tables: {}, occurrences: [], shapes: [], faces: [], edges: [] },
{
edgeIds: { dtype: "uint32", payload: edgeIds, count: 2, itemSize: 4 },
surfaceHalfEdges: { dtype: "uint32", payload: surfaceHalfEdges, count: 7, itemSize: 4 }
}
);
globalThis.fetch = async (requestUrl) => {
requests.push(String(requestUrl));
assert.equal(String(requestUrl), glbUrl);
return new Response(glb, { status: 200 });
};
t.after(() => {
globalThis.fetch = originalFetch;
});
const bundle = await loadRenderSelectorBundle(glbUrl);
assert.deepEqual(Array.from(bundle.buffers.edgeIds), [7, 11]);
const displayBundle = await loadRenderDisplayEdgeBundle(glbUrl);
assert.deepEqual(Array.from(displayBundle.buffers.surfaceHalfEdges.slice(0, 2)), [7, 11]);
assert.equal(displayBundle.manifest.profile, "surface-edges");
assert.equal((await loadRenderTopologyIndex(glbUrl)).cadRef, "fixtures/box");
assert.deepEqual(requests, [glbUrl]);
});
test("STEP_topology client rejects old schema artifacts", async (t) => {
const originalFetch = globalThis.fetch;
const glbUrl = `/old-topology-${Date.now()}-${Math.random()}.glb`;
const glb = topologyGlb(
{ cadRef: "fixtures/old", tables: {}, occurrences: [], shapes: [], faces: [], edges: [] },
{},
{ schemaVersion: STEP_TOPOLOGY_SCHEMA_VERSION - 1 }
);
globalThis.fetch = async () => new Response(glb, { status: 200 });
t.after(() => {
globalThis.fetch = originalFetch;
});
await assert.rejects(
loadRenderTopologyIndex(glbUrl),
new RegExp(`Unsupported STEP_topology schemaVersion ${STEP_TOPOLOGY_SCHEMA_VERSION - 1}`)
);
});
test("SDF robot descriptions load through the render cache", async (t) => {
const originalFetch = globalThis.fetch;
const originalDomParser = globalThis.DOMParser;
const url = `/robot-${Date.now()}-${Math.random()}.sdf`;
let fetchCount = 0;
globalThis.DOMParser = class FakeDomParser {
parseFromString() {
return new FakeDocument(el("sdf", { version: "1.12" }, [
el("model", { name: "sample_robot" }, [
el("link", { name: "base_link" })
])
]));
}
};
globalThis.fetch = async (requestUrl) => {
fetchCount += 1;
assert.equal(String(requestUrl), url);
return new Response("<sdf />", { status: 200 });
};
t.after(() => {
globalThis.fetch = originalFetch;
globalThis.DOMParser = originalDomParser;
});
const first = await loadRenderSdf(url);
const second = await loadRenderSdf(url);
assert.equal(first.robotName, "sample_robot");
assert.equal(first.rootLink, "base_link");
assert.equal(second, first);
assert.equal(peekRenderSdf(url), first);
assert.equal(fetchCount, 1);
});
test("G-code toolpaths load and parse through the render cache", async (t) => {
const originalFetch = globalThis.fetch;
const url = `/toolpath-${Date.now()}-${Math.random()}.gcode`;
let fetchCount = 0;
globalThis.fetch = async (requestUrl) => {
fetchCount += 1;
assert.equal(String(requestUrl), url);
return new Response("G21\nG90\nM82\nG0 X0 Y0 Z0.2\nG1 X5 Y0 E0.2\n", { status: 200 });
};
t.after(() => {
globalThis.fetch = originalFetch;
});
const first = await loadRenderGcode(url);
const second = await loadRenderGcode(url);
assert.equal(first.layers.length, 1);
assert.equal(first.stats.extrusionMoves, 1);
assert.equal(second, first);
assert.equal(peekRenderGcode(url), first);
assert.equal(fetchCount, 1);
});
@@ -0,0 +1,893 @@
import { buildCadRefToken } from "../cadRefs.js";
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toRows(manifest, rowKey, columnsKey) {
const columns = manifest?.tables?.[columnsKey];
const rows = manifest?.[rowKey];
if (!Array.isArray(columns) || !Array.isArray(rows)) {
return [];
}
return rows
.filter(Array.isArray)
.map((row) => Object.fromEntries(columns.map((column, index) => [column, row[index]])));
}
function relationArray(manifest, buffers, relationKey, viewKey) {
const direct = manifest?.relations?.[relationKey];
if (Array.isArray(direct) || ArrayBuffer.isView(direct)) {
return direct;
}
const viewName = manifest?.relations?.[viewKey];
if (typeof viewName === "string" && buffers?.[viewName]) {
return buffers[viewName];
}
return [];
}
function typedBufferView(manifest, buffers, manifestSectionKey, viewKey) {
const viewName = manifest?.[manifestSectionKey]?.[viewKey];
if (typeof viewName === "string" && buffers?.[viewName]) {
return buffers[viewName];
}
return new Uint32Array(0);
}
function selectorPrefix(singleOccurrenceId, selector) {
if (!singleOccurrenceId || !selector.startsWith(`${singleOccurrenceId}.`)) {
return selector;
}
const suffix = selector.slice(singleOccurrenceId.length + 1);
return suffix.startsWith("s") || suffix.startsWith("f") || suffix.startsWith("e") ? suffix : selector;
}
function selectorTypeLabel(selectorType) {
if (selectorType === "occurrence") {
return "Occurrence";
}
if (selectorType === "shape") {
return "Shape";
}
if (selectorType === "face") {
return "Face";
}
return "Edge";
}
function transformPoint(transform, point) {
if (!Array.isArray(point) || point.length < 3) {
return point;
}
if (!Array.isArray(transform) || transform.length < 16) {
return [Number(point[0]), Number(point[1]), Number(point[2])];
}
const x = Number(point[0]);
const y = Number(point[1]);
const z = Number(point[2]);
return [
(transform[0] * x) + (transform[1] * y) + (transform[2] * z) + transform[3],
(transform[4] * x) + (transform[5] * y) + (transform[6] * z) + transform[7],
(transform[8] * x) + (transform[9] * y) + (transform[10] * z) + transform[11],
];
}
function normalizeVector(vector) {
const x = Number(vector?.[0] || 0);
const y = Number(vector?.[1] || 0);
const z = Number(vector?.[2] || 0);
const magnitude = Math.hypot(x, y, z);
if (magnitude <= 1e-9) {
return null;
}
return [x / magnitude, y / magnitude, z / magnitude];
}
function transformVector(transform, vector) {
if (!Array.isArray(vector) || vector.length < 3 || !Array.isArray(transform) || transform.length < 16) {
return normalizeVector(vector || []);
}
return normalizeVector([
(transform[0] * vector[0]) + (transform[1] * vector[1]) + (transform[2] * vector[2]),
(transform[4] * vector[0]) + (transform[5] * vector[1]) + (transform[6] * vector[2]),
(transform[8] * vector[0]) + (transform[9] * vector[1]) + (transform[10] * vector[2]),
]);
}
function transformBBox(transform, bbox) {
if (!isObject(bbox)) {
return bbox;
}
const min = Array.isArray(bbox.min) ? bbox.min : [0, 0, 0];
const max = Array.isArray(bbox.max) ? bbox.max : [0, 0, 0];
const corners = [
[min[0], min[1], min[2]],
[min[0], min[1], max[2]],
[min[0], max[1], min[2]],
[min[0], max[1], max[2]],
[max[0], min[1], min[2]],
[max[0], min[1], max[2]],
[max[0], max[1], min[2]],
[max[0], max[1], max[2]],
].map((point) => transformPoint(transform, point));
const xs = corners.map((point) => point[0]);
const ys = corners.map((point) => point[1]);
const zs = corners.map((point) => point[2]);
return {
min: [Math.min(...xs), Math.min(...ys), Math.min(...zs)],
max: [Math.max(...xs), Math.max(...ys), Math.max(...zs)],
};
}
function transformParams(transform, params) {
if (!isObject(params)) {
return params;
}
const pointKeys = new Set(["origin", "center", "location"]);
const vectorKeys = new Set(["axis", "direction", "normal"]);
return Object.fromEntries(Object.entries(params).map(([key, value]) => {
if (pointKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformPoint(transform, value)];
}
if (vectorKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformVector(transform, value)];
}
return [key, value];
}));
}
function transformPointList(transform, points) {
if (!Array.isArray(points)) {
return points;
}
return points.map((point) => (
Array.isArray(point) && point.length >= 3 ? transformPoint(transform, point) : point
));
}
function transformLoopMetadata(transform, loopsMeta) {
if (!Array.isArray(loopsMeta)) {
return loopsMeta;
}
return loopsMeta.map((loop) => (
isObject(loop) && Array.isArray(loop.points)
? { ...loop, points: transformPointList(transform, loop.points) }
: loop
));
}
function transformSurface(transform, surface) {
if (!isObject(surface)) {
return surface;
}
const pointKeys = new Set(["origin", "center", "location"]);
const vectorKeys = new Set(["axis", "direction", "normal", "xDir", "yDir"]);
return Object.fromEntries(Object.entries(surface).map(([key, value]) => {
if (pointKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformPoint(transform, value)];
}
if (vectorKeys.has(key) && Array.isArray(value) && value.length === 3) {
return [key, transformVector(transform, value)];
}
return [key, value];
}));
}
function transformPositions(values, transform) {
if (!(values instanceof Float32Array) || !Array.isArray(transform) || transform.length < 16) {
return values;
}
const next = new Float32Array(values.length);
for (let index = 0; index < values.length; index += 3) {
const point = transformPoint(transform, [values[index], values[index + 1], values[index + 2]]);
next[index] = point[0];
next[index + 1] = point[1];
next[index + 2] = point[2];
}
return next;
}
function transformPickData(pickData, transform) {
if (!isObject(pickData) || !Array.isArray(transform) || transform.length < 16) {
return pickData;
}
return {
...pickData,
bbox: pickData.bbox ? transformBBox(transform, pickData.bbox) : pickData.bbox,
center: Array.isArray(pickData.center) ? transformPoint(transform, pickData.center) : pickData.center,
normal: Array.isArray(pickData.normal) ? transformVector(transform, pickData.normal) : pickData.normal,
params: pickData.params ? transformParams(transform, pickData.params) : pickData.params,
points: transformPointList(transform, pickData.points),
loops: Array.isArray(pickData.loops)
? pickData.loops.map((loop) => transformPointList(transform, loop))
: pickData.loops,
loopsMeta: transformLoopMetadata(transform, pickData.loopsMeta),
surface: transformSurface(transform, pickData.surface),
centroid: Array.isArray(pickData.centroid) ? transformPoint(transform, pickData.centroid) : pickData.centroid,
transform,
};
}
function referenceIdForRow(displaySelector, selectorType, partId) {
if (partId) {
return `topology|${partId}|${selectorType}|${displaySelector}`;
}
return displaySelector;
}
function referenceSummary(selectorType, row) {
if (selectorType === "occurrence") {
return String(row.name || row.sourceName || row.id || "").trim();
}
if (selectorType === "shape") {
const name = String(row.name || row.sourceName || "").trim();
const detail = `${row.kind || "shape"}${row.volume ? ` volume=${row.volume}` : row.area ? ` area=${row.area}` : ""}`;
return name ? `${name} ${detail}` : detail;
}
if (selectorType === "face") {
return `${row.surfaceType || "face"} area=${row.area ?? 0}`;
}
return `${row.curveType || "edge"} length=${row.length ?? 0}`;
}
function sourceOccurrenceMatchesFilter(sourceOccurrenceId, filterOccurrenceId) {
const sourceId = String(sourceOccurrenceId || "").trim();
const filterId = String(filterOccurrenceId || "").trim();
return !filterId || sourceId === filterId || sourceId.startsWith(`${filterId}.`);
}
function remapSourceOccurrenceId(sourceOccurrenceId, remapOccurrencePrefix) {
const sourceId = String(sourceOccurrenceId || "").trim();
if (!sourceId || !remapOccurrencePrefix || typeof remapOccurrencePrefix !== "object") {
return "";
}
if (!sourceOccurrenceMatchesFilter(sourceId, remapOccurrencePrefix.sourceOccurrenceId)) {
return "";
}
const sourceRootId = String(remapOccurrencePrefix.sourceRootOccurrenceId || "").trim();
const targetRootId = String(remapOccurrencePrefix.targetRootOccurrenceId || "").trim();
if (!sourceRootId || !targetRootId) {
return "";
}
if (sourceId === sourceRootId) {
return targetRootId;
}
const sourceRootPrefix = `${sourceRootId}.`;
if (sourceId.startsWith(sourceRootPrefix)) {
return `${targetRootId}.${sourceId.slice(sourceRootPrefix.length)}`;
}
return "";
}
function selectorForRow(selectorType, row, rowIndex, singleOccurrenceId, remapOccurrenceId = "", remapOccurrencePrefix = null) {
if (!row || !Number.isFinite(Number(rowIndex))) {
return "";
}
if (remapOccurrencePrefix && typeof remapOccurrencePrefix === "object") {
const sourceOccurrenceId = selectorType === "occurrence"
? String(row?.id || "").trim()
: String(row?.occurrenceId || "").trim();
const occurrenceId = remapSourceOccurrenceId(sourceOccurrenceId, remapOccurrencePrefix);
if (!occurrenceId) {
return "";
}
if (selectorType === "occurrence") {
return occurrenceId;
}
const selectorKind = selectorType === "shape"
? "s"
: selectorType === "face"
? "f"
: "e";
return `${occurrenceId}.${selectorKind}${rowIndex + 1}`;
}
const occurrenceId = String(remapOccurrenceId || "").trim();
if (occurrenceId) {
if (selectorType === "occurrence") {
return occurrenceId;
}
const selectorKind = selectorType === "shape"
? "s"
: selectorType === "face"
? "f"
: "e";
return `${occurrenceId}.${selectorKind}${rowIndex + 1}`;
}
return selectorPrefix(singleOccurrenceId, String(row?.id || "").trim());
}
function selectorWithRemappedOccurrence(selector, singleOccurrenceId, remapOccurrenceId = "", remapOccurrencePrefix = null) {
const normalizedSelector = String(selector || "").trim();
if (!normalizedSelector) {
return "";
}
const match = normalizedSelector.match(/^(.*)\.([sfe]\d+)$/i);
if (!match) {
return selectorPrefix(singleOccurrenceId, normalizedSelector);
}
const occurrenceId = String(match[1] || "").trim();
const selectorToken = String(match[2] || "").trim();
if (!occurrenceId || !selectorToken) {
return selectorPrefix(singleOccurrenceId, normalizedSelector);
}
if (remapOccurrencePrefix && typeof remapOccurrencePrefix === "object") {
const remappedOccurrenceId = remapSourceOccurrenceId(occurrenceId, remapOccurrencePrefix);
return remappedOccurrenceId ? `${remappedOccurrenceId}.${selectorToken}` : "";
}
const targetOccurrenceId = String(remapOccurrenceId || "").trim();
if (targetOccurrenceId) {
return `${targetOccurrenceId}.${selectorToken}`;
}
return selectorPrefix(singleOccurrenceId, normalizedSelector);
}
function occurrenceIdForReference(selectorType, row, singleOccurrenceId, remapOccurrenceId = "", remapOccurrencePrefix = null) {
if (selectorType === "occurrence") {
return "";
}
const occurrenceId = String(row?.occurrenceId || "").trim();
if (!occurrenceId) {
return "";
}
if (remapOccurrencePrefix && typeof remapOccurrencePrefix === "object") {
return remapSourceOccurrenceId(occurrenceId, remapOccurrencePrefix);
}
return String(remapOccurrenceId || "").trim() || selectorPrefix(singleOccurrenceId, occurrenceId);
}
function buildAdjacencySelectors(row, relationRows, targetRows, singleOccurrenceId, idKey, startKey, countKey, targetSelectorType, remapOccurrenceId, remapOccurrencePrefix) {
const start = Number(row?.[startKey] || 0);
const count = Number(row?.[countKey] || 0);
const selectors = [];
const end = Math.min(relationRows?.length || 0, start + count);
for (let index = start; index < end; index += 1) {
const rowIndex = relationRows[index];
const targetRowIndex = Number(rowIndex);
const targetRow = targetRows[targetRowIndex];
const selector = selectorForRow(
targetSelectorType,
targetRow,
targetRowIndex,
singleOccurrenceId,
remapOccurrenceId,
remapOccurrencePrefix
) || String(targetRow?.[idKey] || "").trim();
if (selector) {
selectors.push(selector);
}
}
return selectors;
}
function buildReference({
selectorType,
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform,
relationRows,
targetRows,
targetKey,
startKey,
countKey,
remapOccurrenceId = "",
remapOccurrencePrefix = null,
targetSelectorType = "",
}) {
const normalizedSelector = selectorForRow(selectorType, row, rowIndex, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix);
const displaySelector = normalizedSelector;
const id = referenceIdForRow(displaySelector, selectorType, partId);
const summary = referenceSummary(selectorType, row);
const copyText = buildCadRefToken({ cadPath: copyCadPath, selector: displaySelector });
const adjacentSelectors = relationRows && targetRows
? buildAdjacencySelectors(
row,
relationRows,
targetRows,
singleOccurrenceId,
targetKey,
startKey,
countKey,
targetSelectorType,
remapOccurrenceId,
remapOccurrencePrefix
)
: [];
return {
id,
selectorType,
normalizedSelector,
displaySelector,
label: `${selectorTypeLabel(selectorType)} ${displaySelector}`,
summary,
shortSummary: summary,
copyText,
partId,
occurrenceId: occurrenceIdForReference(selectorType, row, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix),
shapeId: selectorWithRemappedOccurrence(row.shapeId, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix),
rowIndex,
pickData: {
selectorType,
rowIndex,
name: row.name || null,
sourceName: row.sourceName || null,
kind: row.kind || null,
bbox: row.bbox || null,
surfaceType: row.surfaceType || null,
center: row.center || null,
normal: row.normal || null,
params: row.params || null,
triangleStart: row.triangleStart ?? 0,
triangleCount: row.triangleCount ?? 0,
segmentStart: row.segmentStart ?? 0,
segmentCount: row.segmentCount ?? 0,
adjacentSelectors,
transform: selectorTransform || null,
},
};
}
function buildLeafOccurrenceIds(shapes) {
return [...new Set(
shapes
.map((row) => String(row.occurrenceId || "").trim())
.filter(Boolean)
)].sort();
}
function transformRows(rows, transform) {
if (!Array.isArray(transform) || transform.length < 16) {
return rows;
}
return rows.map((row) => ({
...row,
transform: Array.isArray(row.transform) ? row.transform : transform,
bbox: row.bbox ? transformBBox(transform, row.bbox) : row.bbox,
center: Array.isArray(row.center) ? transformPoint(transform, row.center) : row.center,
normal: Array.isArray(row.normal) ? transformVector(transform, row.normal) : row.normal,
params: row.params ? transformParams(transform, row.params) : row.params,
}));
}
function transformRow(row, transform) {
if (!row || !Array.isArray(transform) || transform.length < 16) {
return row;
}
return {
...row,
bbox: row.bbox ? transformBBox(transform, row.bbox) : row.bbox,
center: Array.isArray(row.center) ? transformPoint(transform, row.center) : row.center,
normal: Array.isArray(row.normal) ? transformVector(transform, row.normal) : row.normal,
params: row.params ? transformParams(transform, row.params) : row.params,
};
}
function applySequentialRelationStarts(rows, relationSpecs) {
const specs = Array.isArray(relationSpecs?.[0]) ? relationSpecs : [relationSpecs];
const nextStarts = specs.map(() => 0);
return rows.map((row) => {
const nextRow = { ...row };
specs.forEach(([startKey, countKey], specIndex) => {
const count = Math.max(0, Number(row?.[countKey] || 0));
nextRow[startKey] = nextStarts[specIndex];
nextRow[countKey] = count;
nextStarts[specIndex] += count;
});
return nextRow;
});
}
export function buildSelectorRuntime(bundle, {
copyCadPath = "",
partId = "",
transform = null,
remapOccurrenceId = "",
remapOccurrencePrefix = null,
} = {}) {
const manifest = bundle?.manifest || {};
const buffers = bundle?.buffers || {};
const faceRelations = relationArray(manifest, buffers, "faceEdgeRows", "faceEdgeRowsView");
const edgeRelations = relationArray(manifest, buffers, "edgeFaceRows", "edgeFaceRowsView");
const occurrences = transformRows(toRows(manifest, "occurrences", "occurrenceColumns"), transform);
const shapes = transformRows(toRows(manifest, "shapes", "shapeColumns"), transform);
const faces = applySequentialRelationStarts(
transformRows(toRows(manifest, "faces", "faceColumns"), transform),
[["edgeStart", "edgeCount"]]
);
const edges = applySequentialRelationStarts(
transformRows(toRows(manifest, "edges", "edgeColumns"), transform),
[["faceStart", "faceCount"]]
);
const leafOccurrenceIds = buildLeafOccurrenceIds(shapes);
const singleOccurrenceId = leafOccurrenceIds.length === 1 ? leafOccurrenceIds[0] : "";
const selectorBuffers = {
facePositions: transformPositions(buffers.facePositions, transform),
faceIndices: buffers.faceIndices || new Uint32Array(0),
faceIds: buffers.faceIds || new Uint32Array(0),
faceRuns: typedBufferView(manifest, buffers, "faceProxy", "runsView"),
faceRunColumns: Array.isArray(manifest?.faceProxy?.runColumns) ? manifest.faceProxy.runColumns : [],
edgePositions: transformPositions(buffers.edgePositions, transform),
edgeIndices: buffers.edgeIndices || new Uint32Array(0),
edgeIds: buffers.edgeIds || new Uint32Array(0),
faceEdgeRows: faceRelations,
edgeFaceRows: edgeRelations,
};
const references = [];
references.push(...occurrences.map((row, rowIndex) => buildReference({
selectorType: "occurrence",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
remapOccurrenceId,
remapOccurrencePrefix,
})));
references.push(...shapes.map((row, rowIndex) => buildReference({
selectorType: "shape",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
remapOccurrenceId,
remapOccurrencePrefix,
})));
references.push(...faces.map((row, rowIndex) => buildReference({
selectorType: "face",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
relationRows: faceRelations,
targetRows: edges,
targetKey: "id",
startKey: "edgeStart",
countKey: "edgeCount",
remapOccurrenceId,
remapOccurrencePrefix,
targetSelectorType: "edge",
})));
references.push(...edges.map((row, rowIndex) => buildReference({
selectorType: "edge",
row,
rowIndex,
singleOccurrenceId,
copyCadPath,
partId,
selectorTransform: transform,
relationRows: edgeRelations,
targetRows: faces,
targetKey: "id",
startKey: "faceStart",
countKey: "faceCount",
remapOccurrenceId,
remapOccurrencePrefix,
targetSelectorType: "face",
})));
const visibleReferences = references.filter((reference) => String(reference?.normalizedSelector || "").trim());
const referenceMap = new Map(visibleReferences.map((reference) => [reference.id, reference]));
const referenceByNormalizedSelector = new Map(
visibleReferences.map((reference) => [reference.normalizedSelector, reference])
);
const referenceByDisplaySelector = new Map(
visibleReferences.map((reference) => [reference.displaySelector, reference])
);
const faceReferenceByRowIndex = new Map(
visibleReferences
.filter((reference) => reference.selectorType === "face")
.map((reference) => [reference.rowIndex, reference])
);
const edgeReferenceByRowIndex = new Map(
visibleReferences
.filter((reference) => reference.selectorType === "edge")
.map((reference) => [reference.rowIndex, reference])
);
const occurrenceIdByRowIndex = new Map(
occurrences.map((row, rowIndex) => [
rowIndex,
selectorForRow("occurrence", row, rowIndex, singleOccurrenceId, remapOccurrenceId, remapOccurrencePrefix) || String(row?.id || "").trim()
])
);
return {
schemaVersion: Number(manifest.schemaVersion || 1),
surfaceEdgeRendering: Boolean(manifest?.capabilities?.surfaceEdgeRendering),
capabilities: manifest.capabilities || null,
cadPath: copyCadPath || String(manifest.cadRef || "").trim(),
stepHash: String(manifest.stepHash || ""),
bbox: transform ? transformBBox(transform, manifest.bbox || {}) : manifest.bbox,
occurrences,
shapes,
faces,
edges,
vertices: [],
references: visibleReferences,
referenceMap,
referenceByNormalizedSelector,
referenceByDisplaySelector,
faceReferenceByRowIndex,
edgeReferenceByRowIndex,
vertexReferenceByRowIndex: new Map(),
occurrenceIdByRowIndex,
faceReferenceMap: new Map(visibleReferences.filter((reference) => reference.selectorType === "face").map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(visibleReferences.filter((reference) => reference.selectorType === "edge").map((reference) => [reference.id, reference])),
vertexReferenceMap: new Map(),
singleOccurrenceId,
proxy: selectorBuffers,
};
}
export function buildDisplayEdgeRuntime(bundle, {
transform = null,
} = {}) {
const manifest = bundle?.manifest || {};
if (String(manifest.profile || "") === "surface-edges") {
const edgeRendering = manifest.edgeRendering && typeof manifest.edgeRendering === "object"
? manifest.edgeRendering
: {};
return {
schemaVersion: Number(manifest.schemaVersion || 1),
surfaceEdgeRendering: true,
edgeRendering: {
visibilityClasses: Array.isArray(edgeRendering.visibilityClasses)
? edgeRendering.visibilityClasses.map((item) => String(item || "").trim()).filter(Boolean)
: [],
generatedVisibilityClasses: Array.isArray(edgeRendering.generatedVisibilityClasses)
? edgeRendering.generatedVisibilityClasses.map((item) => String(item || "").trim()).filter(Boolean)
: [],
visibilityClassCounts: edgeRendering.visibilityClassCounts || {},
generatedVisibilityClassCounts: edgeRendering.generatedVisibilityClassCounts || {},
},
stepHash: String(manifest.stepHash || ""),
bbox: transform ? transformBBox(transform, manifest.bbox || {}) : manifest.bbox,
edges: [],
proxy: {
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0),
edgeIds: new Uint32Array(0),
},
};
}
const buffers = bundle?.buffers || {};
const edgeProxy = manifest?.edgeProxy || {};
const edgePositionView = String(edgeProxy.positionsView || "edgePositions");
const edgeIndexView = String(edgeProxy.indicesView || "edgeIndices");
const edgeIdView = String(edgeProxy.edgeIdsView || "edgeIds");
const edgePositions = buffers[edgePositionView] instanceof Float32Array
? buffers[edgePositionView]
: new Float32Array(0);
const edgeIndices = buffers[edgeIndexView] instanceof Uint32Array
? buffers[edgeIndexView]
: new Uint32Array(0);
const edgeIds = buffers[edgeIdView] instanceof Uint32Array
? buffers[edgeIdView]
: new Uint32Array(0);
return {
schemaVersion: Number(manifest.schemaVersion || 1),
stepHash: String(manifest.stepHash || ""),
bbox: transform ? transformBBox(transform, manifest.bbox || {}) : manifest.bbox,
edges: transformRows(toRows(manifest, "edges", "edgeColumns"), transform),
proxy: {
edgePositions: transformPositions(edgePositions, transform),
edgeIndices,
edgeIds,
},
};
}
function normalizedTransformEntries(value) {
const entries = value instanceof Map
? [...value.entries()]
: isObject(value)
? Object.entries(value)
: [];
return entries
.map(([key, transform]) => [
String(key || "").trim(),
Array.isArray(transform) && transform.length >= 16
? transform.slice(0, 16).map((component) => Number(component))
: null
])
.filter(([key, transform]) => key && transform && transform.every(Number.isFinite));
}
function transformForOccurrenceId(transformEntries, occurrenceId) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
const modelTransform = transformEntries.find(([key]) => key === "__model__" || key === "*" || key === "__all__")?.[1] || null;
let best = null;
for (const [partId, transform] of transformEntries) {
if (partId === "__model__" || partId === "*" || partId === "__all__") {
continue;
}
if (
normalizedOccurrenceId &&
(
normalizedOccurrenceId === partId ||
normalizedOccurrenceId.startsWith(`${partId}.`)
) &&
(!best || partId.length > best[0].length)
) {
best = [partId, transform];
}
}
return best?.[1] || modelTransform;
}
function rowOccurrenceId(row, selectorType) {
return selectorType === "occurrence"
? String(row?.id || "").trim()
: String(row?.occurrenceId || row?.partId || "").trim();
}
function transformRowsByOccurrence(rows, selectorType, transformEntries) {
const transforms = [];
const nextRows = (Array.isArray(rows) ? rows : []).map((row) => {
const transform = transformForOccurrenceId(transformEntries, rowOccurrenceId(row, selectorType));
transforms.push(transform);
return transform ? transformRow(row, transform) : row;
});
return { rows: nextRows, transforms };
}
function transformReference(reference, transform) {
if (!transform) {
return reference;
}
return {
...reference,
pickData: transformPickData(reference.pickData, transform),
};
}
function transformIndexedProxy({ positions, indices, ids, rowTransforms, elementSize }) {
if (!(positions instanceof Float32Array) || !(indices instanceof Uint32Array) || !indices.length || !(ids instanceof Uint32Array) || !ids.length) {
return { positions, indices, ids };
}
const elementCount = Math.floor(indices.length / elementSize);
const nextPositions = new Float32Array(elementCount * elementSize * 3);
const nextIndices = new Uint32Array(elementCount * elementSize);
const nextIds = new Uint32Array(elementCount);
let writeVertex = 0;
for (let elementIndex = 0; elementIndex < elementCount; elementIndex += 1) {
const rowIndex = Number(ids[elementIndex]);
const transform = Number.isInteger(rowIndex) ? rowTransforms[rowIndex] || null : null;
nextIds[elementIndex] = Number.isInteger(rowIndex) ? rowIndex : 0;
for (let vertex = 0; vertex < elementSize; vertex += 1) {
const sourceVertex = Number(indices[(elementIndex * elementSize) + vertex]);
const sourceOffset = sourceVertex * 3;
const nextOffset = writeVertex * 3;
const point = [
positions[sourceOffset],
positions[sourceOffset + 1],
positions[sourceOffset + 2],
];
const transformedPoint = transform ? transformPoint(transform, point) : point;
nextPositions[nextOffset] = transformedPoint[0];
nextPositions[nextOffset + 1] = transformedPoint[1];
nextPositions[nextOffset + 2] = transformedPoint[2];
nextIndices[(elementIndex * elementSize) + vertex] = writeVertex;
writeVertex += 1;
}
}
return {
positions: nextPositions,
indices: nextIndices,
ids: nextIds,
};
}
export function buildTransformedSelectorRuntime(selectorRuntime, transformByPartId = null) {
const transformEntries = normalizedTransformEntries(transformByPartId);
if (!selectorRuntime || !transformEntries.length) {
return selectorRuntime || null;
}
const occurrencesResult = transformRowsByOccurrence(selectorRuntime.occurrences, "occurrence", transformEntries);
const shapesResult = transformRowsByOccurrence(selectorRuntime.shapes, "shape", transformEntries);
const facesResult = transformRowsByOccurrence(selectorRuntime.faces, "face", transformEntries);
const edgesResult = transformRowsByOccurrence(selectorRuntime.edges, "edge", transformEntries);
const referenceTransforms = {
occurrence: occurrencesResult.transforms,
shape: shapesResult.transforms,
face: facesResult.transforms,
edge: edgesResult.transforms,
vertex: [],
};
const references = (Array.isArray(selectorRuntime.references) ? selectorRuntime.references : [])
.map((reference) => transformReference(
reference,
referenceTransforms[reference?.selectorType]?.[Number(reference?.rowIndex)] || null
));
const referenceMap = new Map(references.map((reference) => [reference.id, reference]));
const referenceByNormalizedSelector = new Map(references.map((reference) => [reference.normalizedSelector, reference]));
const referenceByDisplaySelector = new Map(references.map((reference) => [reference.displaySelector, reference]));
const faceReferenceByRowIndex = new Map(
references
.filter((reference) => reference.selectorType === "face")
.map((reference) => [reference.rowIndex, reference])
);
const edgeReferenceByRowIndex = new Map(
references
.filter((reference) => reference.selectorType === "edge")
.map((reference) => [reference.rowIndex, reference])
);
const proxy = selectorRuntime.proxy || {};
const faceProxy = transformIndexedProxy({
positions: proxy.facePositions,
indices: proxy.faceIndices,
ids: proxy.faceIds,
rowTransforms: facesResult.transforms,
elementSize: 3,
});
const edgeProxy = transformIndexedProxy({
positions: proxy.edgePositions,
indices: proxy.edgeIndices,
ids: proxy.edgeIds,
rowTransforms: edgesResult.transforms,
elementSize: 2,
});
return {
...selectorRuntime,
bbox: selectorRuntime.bbox ? transformBBox(transformEntries.find(([key]) => key === "__model__" || key === "*" || key === "__all__")?.[1], selectorRuntime.bbox) : selectorRuntime.bbox,
occurrences: occurrencesResult.rows,
shapes: shapesResult.rows,
faces: facesResult.rows,
edges: edgesResult.rows,
references,
referenceMap,
referenceByNormalizedSelector,
referenceByDisplaySelector,
faceReferenceByRowIndex,
edgeReferenceByRowIndex,
faceReferenceMap: new Map(references.filter((reference) => reference.selectorType === "face").map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(references.filter((reference) => reference.selectorType === "edge").map((reference) => [reference.id, reference])),
proxy: {
...proxy,
facePositions: faceProxy.positions,
faceIndices: faceProxy.indices,
faceIds: faceProxy.ids,
edgePositions: edgeProxy.positions,
edgeIndices: edgeProxy.indices,
edgeIds: edgeProxy.ids,
},
};
}
export function buildTransformedDisplayEdgeRuntime(displayEdgeRuntime, transformByPartId = null) {
const transformEntries = normalizedTransformEntries(transformByPartId);
if (!displayEdgeRuntime || !transformEntries.length) {
return displayEdgeRuntime || null;
}
const edgesResult = transformRowsByOccurrence(displayEdgeRuntime.edges, "edge", transformEntries);
const proxy = displayEdgeRuntime.proxy || {};
const edgeProxy = transformIndexedProxy({
positions: proxy.edgePositions,
indices: proxy.edgeIndices,
ids: proxy.edgeIds,
rowTransforms: edgesResult.transforms,
elementSize: 2,
});
const modelTransform = transformEntries.find(([key]) => key === "__model__" || key === "*" || key === "__all__")?.[1] || null;
return {
...displayEdgeRuntime,
bbox: modelTransform && displayEdgeRuntime.bbox ? transformBBox(modelTransform, displayEdgeRuntime.bbox) : displayEdgeRuntime.bbox,
edges: edgesResult.rows,
proxy: {
...proxy,
edgePositions: edgeProxy.positions,
edgeIndices: edgeProxy.indices,
edgeIds: edgeProxy.ids,
},
};
}
@@ -0,0 +1,300 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildDisplayEdgeRuntime,
buildSelectorRuntime,
buildTransformedDisplayEdgeRuntime,
buildTransformedSelectorRuntime
} from "./runtime.js";
test("buildSelectorRuntime remaps source part rows onto an assembly occurrence", () => {
const bundle = {
manifest: {
cadRef: "parts/source",
tables: {
occurrenceColumns: ["id", "path", "name", "sourceName", "parentId", "transform", "bbox", "shapeStart", "shapeCount", "faceStart", "faceCount", "edgeStart", "edgeCount"],
shapeColumns: ["id", "occurrenceId", "ordinal", "kind", "bbox", "center", "area", "volume", "faceStart", "faceCount", "edgeStart", "edgeCount"],
faceColumns: ["id", "occurrenceId", "shapeId", "ordinal", "surfaceType", "area", "center", "normal", "bbox", "edgeStart", "edgeCount", "relevance", "flags", "params", "triangleStart", "triangleCount"],
edgeColumns: ["id", "occurrenceId", "shapeId", "ordinal", "curveType", "length", "center", "bbox", "faceStart", "faceCount", "relevance", "flags", "params", "segmentStart", "segmentCount"],
},
occurrences: [
["o1", "1", null, null, null, null, null, 0, 2, 0, 2, 0, 0],
["o1.1", "1.1", null, null, "o1", null, null, 0, 1, 0, 1, 0, 0],
["o1.2", "1.2", null, null, "o1", null, null, 1, 1, 1, 1, 0, 0]
],
shapes: [
["o1.1.s1", "o1.1", 1, "solid", null, null, 1, 1, 0, 1, 0, 0],
["o1.2.s1", "o1.2", 1, "solid", null, null, 1, 1, 1, 1, 0, 0]
],
faces: [
["o1.1.f1", "o1.1", "o1.1.s1", 1, "plane", 1, [0, 0, 0], [0, 0, 1], null, 0, 0, 0, 0, {}, 0, 0],
["o1.2.f1", "o1.2", "o1.2.s1", 1, "plane", 1, [1, 0, 0], [0, 0, 1], null, 0, 0, 0, 0, {}, 0, 0]
],
edges: []
},
buffers: {}
};
const runtime = buildSelectorRuntime(bundle, {
copyCadPath: "parts/root",
partId: "o1.5",
remapOccurrenceId: "o1.5"
});
const faces = runtime.references.filter((reference) => reference.selectorType === "face");
assert.deepEqual(faces.map((reference) => reference.displaySelector), ["o1.5.f1", "o1.5.f2"]);
assert.equal(faces[1].copyText, "#o1.5.f2");
assert.equal(faces[1].pickData.surfaceType, "plane");
});
test("buildSelectorRuntime remaps native occurrence prefixes onto assembly descendants", () => {
const bundle = {
manifest: {
cadRef: "parts/native",
tables: {
occurrenceColumns: ["id", "path", "name", "sourceName", "parentId", "transform", "bbox", "shapeStart", "shapeCount", "faceStart", "faceCount", "edgeStart", "edgeCount"],
shapeColumns: ["id", "occurrenceId", "ordinal", "kind", "bbox", "center", "area", "volume", "faceStart", "faceCount", "edgeStart", "edgeCount"],
faceColumns: ["id", "occurrenceId", "shapeId", "ordinal", "surfaceType", "area", "center", "normal", "bbox", "edgeStart", "edgeCount", "relevance", "flags", "params", "triangleStart", "triangleCount"],
edgeColumns: ["id", "occurrenceId", "shapeId", "ordinal", "curveType", "length", "center", "bbox", "faceStart", "faceCount", "relevance", "flags", "params", "segmentStart", "segmentCount"],
},
occurrences: [
["o1", "1", null, null, null, null, null, 0, 2, 0, 2, 0, 0],
["o1.1", "1.1", null, null, "o1", null, null, 0, 1, 0, 1, 0, 0],
["o1.2", "1.2", null, null, "o1", null, null, 1, 1, 1, 1, 0, 0]
],
shapes: [
["o1.1.s1", "o1.1", 1, "solid", null, null, 1, 1, 0, 1, 0, 0],
["o1.2.s1", "o1.2", 1, "solid", null, null, 1, 1, 1, 1, 0, 0]
],
faces: [
["o1.1.f1", "o1.1", "o1.1.s1", 1, "plane", 1, [0, 0, 0], [0, 0, 1], null, 0, 0, 0, 0, {}, 0, 0],
["o1.2.f1", "o1.2", "o1.2.s1", 1, "plane", 1, [1, 0, 0], [0, 0, 1], null, 0, 0, 0, 0, {}, 0, 0]
],
edges: []
},
buffers: {}
};
const runtime = buildSelectorRuntime(bundle, {
copyCadPath: "assemblies/root",
partId: "o9.4",
remapOccurrencePrefix: {
sourceRootOccurrenceId: "o1",
targetRootOccurrenceId: "o9.4.1",
sourceOccurrenceId: "o1.2"
}
});
const faces = runtime.references.filter((reference) => reference.selectorType === "face");
assert.deepEqual(faces.map((reference) => reference.displaySelector), ["o9.4.1.2.f2"]);
assert.equal(runtime.occurrenceIdByRowIndex.get(0), "o1");
assert.equal(runtime.occurrenceIdByRowIndex.get(1), "o1.1");
assert.equal(runtime.occurrenceIdByRowIndex.get(2), "o9.4.1.2");
});
test("buildSelectorRuntime uses STEP topology shape names in shape references", () => {
const bundle = {
manifest: {
cadRef: "parts/labeled",
tables: {
occurrenceColumns: ["id", "path", "name", "sourceName", "parentId", "transform", "bbox", "shapeStart", "shapeCount", "faceStart", "faceCount", "edgeStart", "edgeCount"],
shapeColumns: ["id", "occurrenceId", "ordinal", "kind", "name", "sourceName", "bbox", "center", "area", "volume", "faceStart", "faceCount", "edgeStart", "edgeCount"],
faceColumns: ["id", "occurrenceId", "shapeId", "ordinal", "surfaceType", "area", "center", "normal", "bbox", "edgeStart", "edgeCount", "relevance", "flags", "params", "triangleStart", "triangleCount"],
edgeColumns: ["id", "occurrenceId", "shapeId", "ordinal", "curveType", "length", "center", "bbox", "faceStart", "faceCount", "relevance", "flags", "params", "segmentStart", "segmentCount"],
},
occurrences: [
["o1", "1", "base:front_left", "base", null, null, null, 0, 1, 0, 0, 0, 0]
],
shapes: [
["o1.s1", "o1", 1, "solid", "base:front_left", "base", null, null, 24, 12, 0, 0, 0, 0]
],
faces: [],
edges: []
},
buffers: {}
};
const runtime = buildSelectorRuntime(bundle, { copyCadPath: "parts/labeled" });
const shape = runtime.references.find((reference) => reference.selectorType === "shape");
assert.equal(shape.summary, "base:front_left solid volume=12");
assert.equal(shape.copyText, "#s1");
assert.equal(shape.pickData.name, "base:front_left");
assert.equal(shape.pickData.sourceName, "base");
});
test("buildSelectorRuntime exposes v1 GLB face runs from selector buffers", () => {
const bundle = {
manifest: {
cadRef: "parts/source",
faceProxy: {
source: "model.glb",
runsView: "faceRuns",
runColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"]
},
tables: {
occurrenceColumns: ["id", "path", "name", "sourceName", "parentId", "transform", "bbox", "shapeStart", "shapeCount", "faceStart", "faceCount", "edgeStart", "edgeCount"],
shapeColumns: ["id", "occurrenceId", "ordinal", "kind", "bbox", "center", "area", "volume", "faceStart", "faceCount", "edgeStart", "edgeCount"],
faceColumns: ["id", "occurrenceId", "shapeId", "ordinal", "surfaceType", "area", "center", "normal", "bbox", "edgeStart", "edgeCount", "relevance", "flags", "params", "triangleStart", "triangleCount"],
edgeColumns: ["id", "occurrenceId", "shapeId", "ordinal", "curveType", "length", "center", "bbox", "faceStart", "faceCount", "relevance", "flags", "params", "segmentStart", "segmentCount"],
},
occurrences: [
["o1", "1", null, null, null, null, null, 0, 1, 0, 1, 0, 0]
],
shapes: [
["o1.s1", "o1", 1, "solid", null, null, 1, 1, 0, 1, 0, 0]
],
faces: [
["o1.f1", "o1", "o1.s1", 1, "plane", 1, [0, 0, 0], [0, 0, 1], null, 0, 0, 0, 0, {}, 2, 4]
],
edges: []
},
buffers: {
faceRuns: new Uint32Array([0, 1, 2, 4, 0])
}
};
const runtime = buildSelectorRuntime(bundle);
assert.deepEqual(Array.from(runtime.proxy.faceRuns), [0, 1, 2, 4, 0]);
assert.deepEqual(runtime.proxy.faceRunColumns, ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"]);
assert.equal(runtime.occurrenceIdByRowIndex.get(0), "o1");
});
test("buildTransformedSelectorRuntime applies part transforms to rows and proxy geometry", () => {
const bundle = {
manifest: {
cadRef: "parts/source",
tables: {
occurrenceColumns: ["id", "path", "name", "sourceName", "parentId", "transform", "bbox", "shapeStart", "shapeCount", "faceStart", "faceCount", "edgeStart", "edgeCount"],
shapeColumns: ["id", "occurrenceId", "ordinal", "kind", "bbox", "center", "area", "volume", "faceStart", "faceCount", "edgeStart", "edgeCount"],
faceColumns: ["id", "occurrenceId", "shapeId", "ordinal", "surfaceType", "area", "center", "normal", "bbox", "edgeStart", "edgeCount", "relevance", "flags", "params", "triangleStart", "triangleCount"],
edgeColumns: ["id", "occurrenceId", "shapeId", "ordinal", "curveType", "length", "center", "bbox", "faceStart", "faceCount", "relevance", "flags", "params", "segmentStart", "segmentCount"],
},
occurrences: [
["o1", "1", null, null, null, null, { min: [0, 0, 0], max: [1, 1, 0] }, 0, 1, 0, 1, 0, 1]
],
shapes: [
["o1.s1", "o1", 1, "solid", { min: [0, 0, 0], max: [1, 1, 0] }, [0.5, 0.5, 0], 1, 1, 0, 1, 0, 1]
],
faces: [
["o1.f1", "o1", "o1.s1", 1, "plane", 1, [0.5, 0.5, 0], [0, 0, 1], { min: [0, 0, 0], max: [1, 1, 0] }, 0, 1, 0, 0, {}, 0, 1]
],
edges: [
["o1.e1", "o1", "o1.s1", 1, "line", 1, [0.5, 0, 0], { min: [0, 0, 0], max: [1, 0, 0] }, 0, 1, 0, 0, {}, 0, 1]
],
relations: {
faceEdgeRows: [0],
edgeFaceRows: [0]
}
},
buffers: {
facePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
faceIndices: new Uint32Array([0, 1, 2]),
faceIds: new Uint32Array([0]),
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0
]),
edgeIndices: new Uint32Array([0, 1]),
edgeIds: new Uint32Array([0])
}
};
const runtime = buildSelectorRuntime(bundle);
const transformed = buildTransformedSelectorRuntime(runtime, new Map([
["o1", [
1, 0, 0, 10,
0, 1, 0, 20,
0, 0, 1, 30,
0, 0, 0, 1
]]
]));
assert.deepEqual(transformed.faces[0].center, [10.5, 20.5, 30]);
assert.deepEqual(transformed.edges[0].center, [10.5, 20, 30]);
assert.deepEqual(transformed.references.find((reference) => reference.selectorType === "face").pickData.center, [10.5, 20.5, 30]);
assert.deepEqual(Array.from(transformed.proxy.facePositions), [
10, 20, 30,
11, 20, 30,
10, 21, 30
]);
assert.deepEqual(Array.from(transformed.proxy.faceIndices), [0, 1, 2]);
assert.deepEqual(Array.from(transformed.proxy.faceIds), [0]);
assert.deepEqual(Array.from(transformed.proxy.edgePositions), [
10, 20, 30,
11, 20, 30
]);
assert.deepEqual(Array.from(transformed.proxy.edgeIndices), [0, 1]);
assert.deepEqual(Array.from(transformed.proxy.edgeIds), [0]);
});
test("buildDisplayEdgeRuntime keeps compact edge rows without selector references", () => {
const runtime = buildDisplayEdgeRuntime({
manifest: {
schemaVersion: 2,
stepHash: "abc",
bbox: { min: [0, 0, 0], max: [1, 0, 0] },
tables: {
edgeColumns: ["occurrenceId", "segmentStart", "segmentCount", "visibilityClass"]
},
edges: [["o1", 0, 1, "feature"]],
edgeProxy: {
positionsView: "edgePositions",
indicesView: "edgeIndices",
edgeIdsView: "edgeIds"
}
},
buffers: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1]),
edgeIds: new Uint32Array([0])
}
});
assert.equal(runtime.stepHash, "abc");
assert.deepEqual(runtime.edges, [{ occurrenceId: "o1", segmentStart: 0, segmentCount: 1, visibilityClass: "feature" }]);
assert.deepEqual(Array.from(runtime.proxy.edgeIndices), [0, 1]);
assert.equal(runtime.referenceMap, undefined);
});
test("buildTransformedDisplayEdgeRuntime applies occurrence transforms to edge proxy geometry", () => {
const runtime = buildDisplayEdgeRuntime({
manifest: {
schemaVersion: 2,
tables: {
edgeColumns: ["occurrenceId", "segmentStart", "segmentCount"]
},
edges: [["o1", 0, 1]],
edgeProxy: {
positionsView: "edgePositions",
indicesView: "edgeIndices",
edgeIdsView: "edgeIds"
}
},
buffers: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1]),
edgeIds: new Uint32Array([0])
}
});
const transformed = buildTransformedDisplayEdgeRuntime(runtime, new Map([
["o1", [
1, 0, 0, 10,
0, 1, 0, 20,
0, 0, 1, 30,
0, 0, 0, 1
]]
]));
assert.deepEqual(Array.from(transformed.proxy.edgePositions), [
10, 20, 30,
11, 20, 30
]);
assert.deepEqual(Array.from(transformed.proxy.edgeIndices), [0, 1]);
assert.deepEqual(Array.from(transformed.proxy.edgeIds), [0]);
});
@@ -0,0 +1 @@
export * from "../../common/stepModule.js";
@@ -0,0 +1,375 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import * as THREE from "three";
import {
normalizeStepModuleDefinition,
normalizeStepModuleParameterValues,
resolveStepModuleFeatures,
stepModuleRuntimeAtZeroState
} from "./stepModule.js";
import {
normalizeStepParameterRenderValues,
stepParameterRenderFrameProgress,
stepParameterRenderFrameValues
} from "../../common/stepParameters.js";
import {
createStepModuleEffectsApi,
displayTransformForPart
} from "../../common/stepModuleEffects.js";
test("STEP modules normalize controls, defaults, and animation metadata", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
parameters: {
open: { type: "number", min: 0, max: 90, default: 120 },
visible: { type: "boolean", default: true },
color: { type: "color", default: "#f97316" },
mode: { type: "select", options: ["mesh", "ghost"], default: "ghost" }
},
animations: {
spin: { duration: 3, loop: false }
}
}
});
assert.deepEqual(definition.defaultParameterValues, {
open: 90,
visible: true,
color: "#f97316",
mode: "ghost"
});
assert.equal(definition.parameters[3].type, "enum");
assert.equal(definition.animations[0].duration, 3);
assert.equal(definition.animations[0].loop, false);
assert.deepEqual(normalizeStepModuleParameterValues(definition, {
open: -5,
visible: false,
color: "orange",
mode: "unknown"
}), {
open: 0,
visible: false,
color: "#ffffff",
mode: "mesh"
});
});
test("STEP module zero state uses normalized defaults and animation rest", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
parameters: {
drive: { type: "number", min: 0, max: 360, default: 0 },
compression: { type: "number", min: 0, max: 1, default: 0.5 },
tint: { type: "color", default: "#F97316" },
visible: { type: "boolean", default: true }
}
}
});
assert.equal(stepModuleRuntimeAtZeroState(definition, {
parameterValues: {
drive: 0.0000004,
compression: 0.5,
tint: "#f97316",
visible: true
},
animationState: { playing: false, elapsedSec: 0 }
}), true);
assert.equal(stepModuleRuntimeAtZeroState(definition, {
parameterValues: {
drive: 360,
compression: 0.5,
tint: "#f97316",
visible: true
},
animationState: { playing: false, elapsedSec: 0 }
}), false);
assert.equal(stepModuleRuntimeAtZeroState(definition, {
parameterValues: definition.defaultParameterValues,
animationState: { playing: false, elapsedSec: 0.01 }
}), false);
assert.equal(stepModuleRuntimeAtZeroState(definition, {
parameterValues: definition.defaultParameterValues,
animationState: { playing: true, elapsedSec: 0 }
}), false);
});
test("STEP module features resolve CAD occurrence refs to render part ids and bounds", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
features: {
planet: {
ref: "#o1.4"
}
}
}
});
const features = resolveStepModuleFeatures(definition, {
meshData: {
parts: [
{
id: "o1.4.1",
occurrenceId: "o1.4.1",
bounds: { min: [20, -10, 0], max: [60, 10, 8] }
}
]
}
});
assert.deepEqual(features.planet.partIds, ["o1.4.1"]);
assert.deepEqual(features.planet.center, [40, 0, 4]);
assert.equal(features.planet.missing, false);
});
test("STEP module local feature refs resolve against the sidecar parent CAD path", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
features: {
lid: {
ref: "#o1.2"
},
hinges: {
ref: "#o1.3,o1.4"
},
latch: {
selectors: ["o1.5"]
}
}
}
}, {
url: "/models/mechanisms/.box.step.js?v=abc",
cadPath: "models/mechanisms/box"
});
assert.equal(definition.cadPath, "models/mechanisms/box");
assert.equal(definition.features[0].ref, "#o1.2");
assert.equal(definition.features[1].ref, "#o1.3,o1.4");
assert.equal(definition.features[2].ref, "#o1.5");
});
test("STEP module local feature refs prefer explicit relative STEP path", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
step: {
path: "models/mechanisms/actual-box.step"
},
features: {
lid: {
ref: "#o1.2"
}
}
}
}, {
url: "/models/mechanisms/.legacy-name.step.js?v=abc",
cadPath: "models/mechanisms/legacy-name"
});
assert.deepEqual(definition.step, {
path: "models/mechanisms/actual-box.step",
cadPath: "models/mechanisms/actual-box",
explicit: true,
inferred: false
});
assert.equal(definition.manifest.step.path, "models/mechanisms/actual-box.step");
assert.equal(definition.cadPath, "models/mechanisms/actual-box");
assert.equal(definition.features[0].ref, "#o1.2");
});
test("STEP module invalid explicit STEP paths fall back to sidecar URL inference", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
step: {
path: "/models/mechanisms/box.step"
},
features: {
lid: {
ref: "#o1.2"
}
}
}
}, {
url: "/models/mechanisms/.box.step.js?v=abc"
});
assert.deepEqual(definition.step, {
path: "",
cadPath: "models/mechanisms/box",
explicit: false,
inferred: true
});
assert.equal(definition.manifest.step, undefined);
assert.equal(definition.cadPath, "models/mechanisms/box");
assert.equal(definition.features[0].ref, "#o1.2");
});
test("STEP module CAD path can be inferred from sidecar URL", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
features: {
lid: {
ref: "#o1.2"
}
}
}
}, {
url: "/models/fun/.gearbox.step.js?v=abc"
});
assert.equal(definition.cadPath, "models/fun/gearbox");
assert.equal(definition.features[0].ref, "#o1.2");
});
test("STEP module display transforms respect baked assembly part transforms", () => {
const transform = [
1, 0, 0, 12,
0, 1, 0, 34,
0, 0, 1, 56,
0, 0, 0, 1
];
const part = { id: "o1.2", transform };
assert.equal(displayTransformForPart({ partTransformsBaked: true }, part, true), null);
assert.equal(displayTransformForPart({}, part, false), null);
assert.deepEqual(displayTransformForPart({}, part, true), transform);
});
test("STEP module effects report only non-identity transforms", () => {
const effectsByPartId = new Map();
const transforms = [];
const effects = createStepModuleEffectsApi(THREE, {
meshData: {
parts: [{ id: "arm", occurrenceId: "arm" }]
},
features: {
arm: { partIds: ["arm"] }
},
runtime: { displayRecords: [] },
effectsByPartId,
onTransformEffect: (event) => transforms.push(event)
});
effects.transform("arm", { translate: [0, 0, 0] });
assert.equal(transforms.length, 0);
effects.transform("arm", { translate: [2, 0, 0] });
assert.equal(transforms.length, 1);
assert.deepEqual(transforms[0].partIds, ["arm"]);
assert.ok(effectsByPartId.get("arm").matrix instanceof THREE.Matrix4);
});
test("STEP module transform detection ignores floating point pose noise", () => {
const transforms = [];
const effects = createStepModuleEffectsApi(THREE, {
meshData: {
parts: [{ id: "actuator", occurrenceId: "actuator" }]
},
features: {
actuator: { partIds: ["actuator"] }
},
runtime: { displayRecords: [] },
effectsByPartId: new Map(),
onTransformEffect: (event) => transforms.push(event)
});
effects.transform("actuator", { translate: [0, 0, -5.278885595316751e-8] });
assert.equal(transforms.length, 0);
effects.transform("actuator", { translate: [0, 0, 0.001] });
assert.equal(transforms.length, 1);
});
test("STEP render parameters normalize static values and animated numeric ranges", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
parameters: {
drive: { type: "number", min: 0, max: 360, default: 0 },
visible: { type: "boolean", default: true },
mode: { type: "select", options: ["mesh", "ghost"], default: "mesh" }
}
}
});
const params = normalizeStepParameterRenderValues(definition, {
values: {
visible: false,
mode: "ghost"
},
animate: {
drive: { from: 0, to: 360 }
},
durationSeconds: 2,
fps: 4
});
assert.equal(params.animated, true);
assert.equal(params.frameCount, 8);
assert.deepEqual(stepParameterRenderFrameValues(definition, params, 0), {
drive: 0,
visible: false,
mode: "ghost"
});
assert.deepEqual(stepParameterRenderFrameValues(definition, params, 4), {
drive: 180,
visible: false,
mode: "ghost"
});
assert.equal(stepParameterRenderFrameProgress(params, 7), 7 / 8);
});
test("non-looping STEP render parameters include the final animated endpoint", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
parameters: {
drive: { type: "number", min: 0, max: 360, default: 0 }
}
}
});
const params = normalizeStepParameterRenderValues(definition, {
animate: {
drive: { from: 0, to: 360 }
},
durationSeconds: 1,
fps: 4,
loop: false
});
assert.equal(params.frameCount, 4);
assert.equal(stepParameterRenderFrameProgress(params, 3), 1);
assert.deepEqual(stepParameterRenderFrameValues(definition, params, 3), {
drive: 360
});
});
test("STEP render parameters reject unknown ids and non-number animation ranges", () => {
const definition = normalizeStepModuleDefinition({
manifest: {
schemaVersion: 1,
parameters: {
drive: { type: "number", min: 0, max: 360, default: 0 },
visible: { type: "boolean", default: true }
}
}
});
assert.throws(
() => normalizeStepParameterRenderValues(definition, { missing: 1 }),
/Unknown STEP parameter/
);
assert.throws(
() => normalizeStepParameterRenderValues(definition, {
animate: {
visible: { from: 0, to: 1 }
}
}),
/must be numeric/
);
});
@@ -0,0 +1,733 @@
export const STEP_MODEL_ROOT_ID = "__step_model__";
export const STEP_MODEL_RENDER_PART_ID = "__model__";
export const STEP_TREE_TOPOLOGY_NODE_PREFIX = "step-topology:";
const STEP_TREE_TOPOLOGY_SELECTOR_TYPES = new Set(["occurrence", "shape", "face", "edge"]);
function normalizeString(value, fallback = "") {
return String(value ?? fallback).trim();
}
function normalizePositiveInteger(value) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue < 1) {
return null;
}
return Math.floor(numericValue);
}
function basename(value) {
const normalized = normalizeString(value);
if (!normalized) {
return "";
}
return normalized.split(/[\\/]/).filter(Boolean).pop() || normalized;
}
function componentLabelFromPartLabel(value) {
return normalizeString(value).replace(/\.(?:step|stp)$/i, "") || "STEP part";
}
export function stepTreeNodeId(node) {
return normalizeString(node?.id || node?.occurrenceId);
}
export function stepTreeNodeLabel(node) {
return normalizeString(
node?.displayName ||
node?.name ||
node?.label ||
node?.sourcePath && basename(node.sourcePath) ||
node?.partSourcePath && basename(node.partSourcePath) ||
node?.id,
"STEP"
);
}
export function stepTreeNodeDetail(node) {
return normalizeString(node?.detail || node?.summary || node?.shortSummary);
}
export function stepTreeNodeChildren(node) {
return Array.isArray(node?.children) ? node.children : [];
}
export function stepTreeNodeTopologyType(node) {
const nodeType = normalizeString(node?.nodeType);
if (!nodeType.startsWith("topology-")) {
return "";
}
return nodeType.slice("topology-".length);
}
export function stepTreeNodeIsTopology(node) {
return Boolean(stepTreeNodeTopologyType(node));
}
export function stepTreeNodeHasChildren(node) {
return stepTreeNodeChildren(node).length > 0;
}
export function stepTreeNodeLeafPartIds(node) {
const declaredLeafPartIds = Array.isArray(node?.leafPartIds)
? node.leafPartIds.map((id) => normalizeString(id)).filter(Boolean)
: [];
if (declaredLeafPartIds.length) {
return [...new Set(declaredLeafPartIds)];
}
const nodeType = normalizeString(node?.nodeType);
const nodeId = stepTreeNodeId(node);
if (nodeType === "part" && nodeId) {
return [nodeId];
}
const leafPartIds = [];
const stack = [...stepTreeNodeChildren(node)].reverse();
while (stack.length) {
const child = stack.pop();
const childNodeType = normalizeString(child?.nodeType);
const children = stepTreeNodeChildren(child);
if (children.length) {
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
continue;
}
if (childNodeType === "part") {
const childId = stepTreeNodeId(child);
if (childId) {
leafPartIds.push(childId);
}
}
}
return [...new Set(leafPartIds)];
}
export function buildStepPartRoot({ selectedEntry = null, meshData = null } = {}) {
const label = normalizeString(
selectedEntry?.file && basename(selectedEntry.file) ||
"STEP part"
);
return {
id: STEP_MODEL_ROOT_ID,
occurrenceId: "",
nodeType: "part",
displayName: label,
name: label,
sourceKind: "entry",
leafPartIds: [STEP_MODEL_RENDER_PART_ID],
bbox: meshData?.bounds || null,
children: []
};
}
export function buildStepTreeRoot({ selectedEntry = null, assemblyRoot = null, meshData = null } = {}) {
if (assemblyRoot && typeof assemblyRoot === "object") {
return assemblyRoot;
}
if (!meshData) {
return null;
}
return buildStepPartRoot({ selectedEntry, meshData });
}
function nodeMatchesQuery(node, query) {
if (!query) {
return true;
}
const normalizedQuery = normalizeString(query).toLowerCase();
if (!normalizedQuery) {
return true;
}
const haystack = [
stepTreeNodeLabel(node),
stepTreeNodeDetail(node),
stepTreeNodeId(node),
node?.occurrenceId,
node?.topologyReferenceId,
node?.displaySelector,
node?.sourcePath,
node?.partSourcePath
].map((value) => normalizeString(value).toLowerCase()).join(" ");
return haystack.includes(normalizedQuery);
}
function topologyReferenceSelector(reference) {
return normalizeString(reference?.displaySelector || reference?.normalizedSelector || reference?.id);
}
function topologySelectorToken(selector) {
const normalizedSelector = normalizeString(selector);
if (!normalizedSelector) {
return "";
}
return normalizedSelector.split(".").filter(Boolean).pop() || normalizedSelector;
}
function topologyOccurrenceIdFromSelector(selector) {
const normalizedSelector = normalizeString(selector);
if (!normalizedSelector) {
return "";
}
const match = normalizedSelector.match(/^(.*)\.[sfe]\d+$/i);
return match ? match[1] : normalizedSelector;
}
function topologyReferenceOccurrenceId(reference) {
const selectorType = normalizeString(reference?.selectorType);
if (selectorType === "occurrence") {
return topologyReferenceSelector(reference);
}
return normalizeString(reference?.occurrenceId) ||
topologyOccurrenceIdFromSelector(topologyReferenceSelector(reference));
}
function topologyReferenceName(reference) {
const pickData = reference?.pickData && typeof reference.pickData === "object"
? reference.pickData
: {};
return normalizeString(
reference?.name ||
reference?.sourceName ||
pickData.name ||
pickData.sourceName
);
}
function topologyReferenceLabel(reference) {
const selectorType = normalizeString(reference?.selectorType);
const selector = topologyReferenceSelector(reference);
const selectorToken = topologySelectorToken(selector);
const name = topologyReferenceName(reference);
const summary = normalizeString(reference?.summary || reference?.shortSummary);
if (selectorType === "occurrence") {
return summary || name || selector || "Occurrence";
}
if (selectorType === "shape") {
return name || (selectorToken ? `Shape ${selectorToken}` : "Shape");
}
if (selectorType === "face") {
return selectorToken ? `Face ${selectorToken}` : "Face";
}
if (selectorType === "edge") {
return selectorToken ? `Edge ${selectorToken}` : "Edge";
}
return summary || selector || "Topology";
}
function topologyReferenceDetail(reference) {
const selectorType = normalizeString(reference?.selectorType);
const selector = topologyReferenceSelector(reference);
const label = topologyReferenceLabel(reference);
const summary = normalizeString(reference?.summary || reference?.shortSummary);
if (selectorType === "occurrence") {
return summary && summary !== selector && summary !== label ? selector : "";
}
if (selectorType === "shape") {
return summary && summary !== label ? summary : selector;
}
return summary;
}
function topologyNodeId(partId, selectorType, selector) {
return `${STEP_TREE_TOPOLOGY_NODE_PREFIX}${partId || "part"}:${selectorType}:${selector || "item"}`;
}
function compareTopologyReferences(a, b) {
const aRowIndex = Number(a?.rowIndex);
const bRowIndex = Number(b?.rowIndex);
if (Number.isFinite(aRowIndex) && Number.isFinite(bRowIndex) && aRowIndex !== bRowIndex) {
return aRowIndex - bRowIndex;
}
if (Number.isFinite(aRowIndex)) {
return -1;
}
if (Number.isFinite(bRowIndex)) {
return 1;
}
return topologyReferenceSelector(a).localeCompare(topologyReferenceSelector(b));
}
function buildTopologyReferenceNode({ reference, selectorType, partId, occurrenceId }) {
const selector = topologyReferenceSelector(reference);
return {
id: topologyNodeId(partId, selectorType, selector || normalizeString(reference?.id)),
nodeType: `topology-${selectorType}`,
displayName: topologyReferenceLabel(reference),
detail: topologyReferenceDetail(reference),
topologyReferenceId: normalizeString(reference?.id),
displaySelector: selector,
partId,
occurrenceId,
shapeId: normalizeString(reference?.shapeId),
children: []
};
}
function buildSyntheticOccurrenceNode({ partId, occurrenceId, children }) {
const id = topologyNodeId(partId, "occurrence", occurrenceId);
return {
id,
nodeType: "topology-occurrence",
displayName: occurrenceId || "Occurrence",
detail: "",
topologyReferenceId: id,
displaySelector: occurrenceId,
partId,
occurrenceId,
children
};
}
function buildTopologyFolderNode({ partNode, partId, children }) {
const label = componentLabelFromPartLabel(stepTreeNodeLabel(partNode));
return {
id: topologyNodeId(partId, "folder", "faces-edges"),
nodeType: "topology-folder",
displayName: label,
name: label,
detail: "",
topologyReferenceId: "",
displaySelector: "",
partId,
selectionPartId: partId,
leafPartIds: [STEP_MODEL_RENDER_PART_ID],
visualOnly: true,
children
};
}
function topologySelectorAliases(value) {
const normalizedValue = normalizeString(value);
if (!normalizedValue) {
return [];
}
const aliases = [normalizedValue];
if (normalizedValue.startsWith("#")) {
aliases.push(normalizedValue.slice(1));
}
if (normalizedValue.startsWith(STEP_TREE_TOPOLOGY_NODE_PREFIX)) {
const internalSelector = normalizeString(normalizedValue.split(":").pop());
if (internalSelector) {
aliases.push(internalSelector);
}
}
return [...new Set(aliases.filter(Boolean))];
}
function topologyOccurrenceNodeIsRedundantForPart(partNode, occurrenceNode, siblingCount) {
if (normalizeString(occurrenceNode?.nodeType) !== "topology-occurrence") {
return false;
}
if (siblingCount === 1) {
return true;
}
return topologyOccurrenceNodeDuplicatesPart(partNode, occurrenceNode);
}
function topologyOccurrenceNodeDuplicatesPart(partNode, occurrenceNode) {
if (normalizeString(occurrenceNode?.nodeType) !== "topology-occurrence") {
return false;
}
const occurrenceId = normalizeString(occurrenceNode?.occurrenceId || occurrenceNode?.displaySelector);
if (!occurrenceId) {
return false;
}
const partSelectors = [
stepTreeNodeId(partNode),
partNode?.occurrenceId,
partNode?.sourceOccurrenceId,
partNode?.sourceRootTargetOccurrenceId,
partNode?.topologyReferenceId,
partNode?.displaySelector,
partNode?.displayName,
partNode?.name,
partNode?.label
].flatMap(topologySelectorAliases);
const occurrenceSelectors = [
occurrenceId,
stepTreeNodeId(occurrenceNode),
occurrenceNode?.topologyReferenceId,
occurrenceNode?.displaySelector,
occurrenceNode?.displayName,
occurrenceNode?.name,
occurrenceNode?.label
].flatMap(topologySelectorAliases);
return occurrenceSelectors.some((selector) => partSelectors.includes(selector));
}
function flattenRedundantTopologyOccurrenceNodes(partNode, topologyChildren) {
const children = Array.isArray(topologyChildren) ? topologyChildren : [];
return children.flatMap((child) => (
topologyOccurrenceNodeIsRedundantForPart(partNode, child, children.length)
? stepTreeNodeChildren(child)
: [child]
));
}
function maybeWrapSinglePartTopologyFolder(partNode, topologyChildren) {
const children = Array.isArray(topologyChildren) ? topologyChildren : [];
if (
!children.length ||
normalizeString(partNode?.nodeType) !== "part" ||
stepTreeNodeId(partNode) !== STEP_MODEL_ROOT_ID
) {
return children;
}
return [buildTopologyFolderNode({
partNode,
partId: STEP_MODEL_ROOT_ID,
children
})];
}
function topologyReferencePartId(reference, fallbackPartId) {
return normalizeString(reference?.partId) || normalizeString(fallbackPartId);
}
function addStepTreeTopologyPartTarget(targets, seenTargets, partId, occurrenceId, priority = 0) {
const normalizedPartId = normalizeString(partId);
const normalizedOccurrenceId = normalizeString(occurrenceId);
if (!normalizedPartId || !normalizedOccurrenceId) {
return;
}
const key = `${normalizedPartId}\0${normalizedOccurrenceId}`;
if (seenTargets.has(key)) {
return;
}
seenTargets.add(key);
targets.push({
partId: normalizedPartId,
occurrenceId: normalizedOccurrenceId,
priority: Number.isFinite(Number(priority)) ? Number(priority) : 0
});
}
function collectStepTreeTopologyPartTargets(root) {
const targets = [];
const seenTargets = new Set();
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
const children = stepTreeNodeChildren(node);
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
if (normalizeString(node?.nodeType) !== "part") {
continue;
}
const partId = stepTreeNodeId(node);
if (!partId) {
continue;
}
addStepTreeTopologyPartTarget(targets, seenTargets, partId, partId, 0);
addStepTreeTopologyPartTarget(targets, seenTargets, partId, node?.occurrenceId, 0);
for (const leafPartId of stepTreeNodeLeafPartIds(node)) {
addStepTreeTopologyPartTarget(targets, seenTargets, partId, leafPartId, 1);
}
addStepTreeTopologyPartTarget(targets, seenTargets, partId, node?.sourceOccurrenceId, 2);
addStepTreeTopologyPartTarget(targets, seenTargets, partId, node?.sourceRootTargetOccurrenceId, 2);
}
return targets.sort((a, b) => (
b.occurrenceId.length - a.occurrenceId.length ||
a.priority - b.priority
));
}
function occurrenceMatchesStepTreePartTarget(occurrenceId, targetOccurrenceId) {
const normalizedOccurrenceId = normalizeString(occurrenceId);
const normalizedTargetOccurrenceId = normalizeString(targetOccurrenceId);
return Boolean(
normalizedOccurrenceId &&
normalizedTargetOccurrenceId &&
(
normalizedOccurrenceId === normalizedTargetOccurrenceId ||
normalizedOccurrenceId.startsWith(`${normalizedTargetOccurrenceId}.`)
)
);
}
export function assignStepTreeTopologyReferencePartIds(root = null, references = []) {
const normalizedReferences = Array.isArray(references) ? references : [];
const targets = collectStepTreeTopologyPartTargets(root);
if (!normalizedReferences.length || !targets.length) {
return normalizedReferences;
}
let changed = false;
const assignedReferences = normalizedReferences.map((reference) => {
if (!reference || typeof reference !== "object" || normalizeString(reference.partId)) {
return reference;
}
const occurrenceId = topologyReferenceOccurrenceId(reference);
const target = targets.find((candidate) => (
occurrenceMatchesStepTreePartTarget(occurrenceId, candidate.occurrenceId)
));
if (!target) {
return reference;
}
changed = true;
return {
...reference,
partId: target.partId
};
});
return changed ? assignedReferences : normalizedReferences;
}
function buildTopologyChildrenByPart(references, fallbackPartId) {
const byPart = new Map();
for (const reference of Array.isArray(references) ? references : []) {
const selectorType = normalizeString(reference?.selectorType);
if (!STEP_TREE_TOPOLOGY_SELECTOR_TYPES.has(selectorType)) {
continue;
}
const partId = topologyReferencePartId(reference, fallbackPartId);
const occurrenceId = topologyReferenceOccurrenceId(reference);
if (!partId || !occurrenceId) {
continue;
}
let partGroup = byPart.get(partId);
if (!partGroup) {
partGroup = new Map();
byPart.set(partId, partGroup);
}
let occurrenceGroup = partGroup.get(occurrenceId);
if (!occurrenceGroup) {
occurrenceGroup = {
occurrence: null,
shapes: [],
faces: [],
edges: []
};
partGroup.set(occurrenceId, occurrenceGroup);
}
if (selectorType === "occurrence") {
occurrenceGroup.occurrence = reference;
} else if (selectorType === "shape") {
occurrenceGroup.shapes.push(reference);
} else if (selectorType === "face") {
occurrenceGroup.faces.push(reference);
} else if (selectorType === "edge") {
occurrenceGroup.edges.push(reference);
}
}
return new Map([...byPart.entries()].map(([partId, occurrenceGroups]) => {
const occurrenceNodes = [...occurrenceGroups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([occurrenceId, group]) => {
const children = [
...group.shapes.sort(compareTopologyReferences).map((reference) => buildTopologyReferenceNode({
reference,
selectorType: "shape",
partId,
occurrenceId
})),
...group.faces.sort(compareTopologyReferences).map((reference) => buildTopologyReferenceNode({
reference,
selectorType: "face",
partId,
occurrenceId
})),
...group.edges.sort(compareTopologyReferences).map((reference) => buildTopologyReferenceNode({
reference,
selectorType: "edge",
partId,
occurrenceId
}))
];
if (group.occurrence) {
return {
...buildTopologyReferenceNode({
reference: group.occurrence,
selectorType: "occurrence",
partId,
occurrenceId
}),
children
};
}
return buildSyntheticOccurrenceNode({ partId, occurrenceId, children });
});
return [partId, occurrenceNodes];
}));
}
export function buildStepTreeRootWithTopology({
root = null,
references = [],
fallbackPartId = "",
topologyPartIds = null
} = {}) {
if (!root || typeof root !== "object") {
return root;
}
const topologyPartIdSet = topologyPartIds == null
? null
: new Set(
(Array.isArray(topologyPartIds) ? topologyPartIds : [topologyPartIds])
.map((id) => normalizeString(id))
.filter(Boolean)
);
const topologyChildrenByPart = buildTopologyChildrenByPart(references, fallbackPartId || stepTreeNodeId(root));
if (!topologyChildrenByPart.size) {
return root;
}
function cloneWithTopology(node) {
const id = stepTreeNodeId(node);
const children = stepTreeNodeChildren(node).map((child) => cloneWithTopology(child));
const topologyLoaded = !topologyPartIdSet || topologyPartIdSet.has(id);
const topologyChildren = topologyLoaded && normalizeString(node?.nodeType) === "part"
? maybeWrapSinglePartTopologyFolder(
node,
flattenRedundantTopologyOccurrenceNodes(node, topologyChildrenByPart.get(id) || [])
)
: [];
if (!children.length && !topologyChildren.length) {
return node;
}
return {
...node,
children: [...children, ...topologyChildren]
};
}
return cloneWithTopology(root);
}
function subtreeMatchesQuery(node, query) {
if (!query || nodeMatchesQuery(node, query)) {
return true;
}
return stepTreeNodeChildren(node).some((child) => subtreeMatchesQuery(child, query));
}
function subtreeContainsNodeId(node, nodeId) {
const normalizedNodeId = normalizeString(nodeId);
if (!node || !normalizedNodeId) {
return false;
}
if (stepTreeNodeId(node) === normalizedNodeId) {
return true;
}
return stepTreeNodeChildren(node).some((child) => subtreeContainsNodeId(child, normalizedNodeId));
}
export function stepTreeRootChildIndexForNode(root, nodeId) {
const normalizedNodeId = normalizeString(nodeId);
if (!root || !normalizedNodeId) {
return -1;
}
const children = stepTreeNodeChildren(root);
for (let index = 0; index < children.length; index += 1) {
if (subtreeContainsNodeId(children[index], normalizedNodeId)) {
return index;
}
}
return -1;
}
export function flattenVisibleStepTreeRows(root, expandedNodeIds = [], {
query = "",
omitRoot = false,
rootChildLimit = null,
showAllRootChildren = true
} = {}) {
if (!root) {
return [];
}
const expanded = new Set(
(Array.isArray(expandedNodeIds) ? expandedNodeIds : [])
.map((id) => normalizeString(id))
.filter(Boolean)
);
const normalizedQuery = normalizeString(query).toLowerCase();
const normalizedRootChildLimit = normalizePositiveInteger(rootChildLimit);
const limitRootChildren = Boolean(
normalizedRootChildLimit &&
showAllRootChildren !== true &&
!normalizedQuery
);
const rows = [];
function rootChildrenForVisit(children) {
return limitRootChildren
? children.slice(0, normalizedRootChildLimit)
: children;
}
function visit(node, depth, options = {}, parentNode = null) {
if (normalizedQuery && !subtreeMatchesQuery(node, normalizedQuery)) {
return;
}
const id = stepTreeNodeId(node);
const children = stepTreeNodeChildren(node);
const hasChildren = children.length > 0;
const expandedByQuery = Boolean(normalizedQuery && hasChildren);
const isExpanded = expandedByQuery || expanded.has(id);
const elideRedundantTopologyOccurrence = parentNode &&
topologyOccurrenceNodeDuplicatesPart(parentNode, node);
if (!elideRedundantTopologyOccurrence) {
rows.push({
id,
node,
label: stepTreeNodeLabel(node),
detail: stepTreeNodeDetail(node),
nodeType: normalizeString(node?.nodeType),
topologyType: stepTreeNodeTopologyType(node),
topologyReferenceId: normalizeString(node?.topologyReferenceId),
depth,
hasChildren,
expanded: isExpanded,
leafPartIds: stepTreeNodeLeafPartIds(node)
});
}
if (!hasChildren || (!isExpanded && !elideRedundantTopologyOccurrence)) {
return;
}
const childRows = options.isRoot ? rootChildrenForVisit(children) : children;
for (const child of childRows) {
visit(child, elideRedundantTopologyOccurrence ? depth : depth + 1, {}, node);
}
}
if (omitRoot) {
for (const child of rootChildrenForVisit(stepTreeNodeChildren(root))) {
visit(child, 0, {}, root);
}
} else {
visit(root, 0, { isRoot: true });
}
return rows;
}
export function collectStepTreeAncestorIds(root, nodeId) {
const normalizedNodeId = normalizeString(nodeId);
if (!root || !normalizedNodeId) {
return [];
}
const path = [];
function visit(node) {
const id = stepTreeNodeId(node);
path.push(id);
if (id === normalizedNodeId) {
return true;
}
for (const child of stepTreeNodeChildren(node)) {
if (visit(child)) {
return true;
}
}
path.pop();
return false;
}
return visit(root) ? path.slice(0, -1).filter(Boolean) : [];
}
@@ -0,0 +1,622 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
assignStepTreeTopologyReferencePartIds,
buildStepTreeRoot,
buildStepTreeRootWithTopology,
collectStepTreeAncestorIds,
flattenVisibleStepTreeRows,
STEP_MODEL_RENDER_PART_ID,
STEP_MODEL_ROOT_ID,
stepTreeRootChildIndexForNode,
stepTreeNodeLeafPartIds
} from "./stepTree.js";
const nestedRoot = {
id: "root",
nodeType: "assembly",
displayName: "root assembly",
children: [
{
id: "sub",
nodeType: "assembly",
displayName: "sub assembly",
children: [
{
id: "leaf-a",
nodeType: "part",
displayName: "leaf A",
children: []
},
{
id: "leaf-b",
nodeType: "part",
displayName: "leaf B",
children: []
}
]
},
{
id: "leaf-c",
nodeType: "part",
displayName: "leaf C",
children: []
}
]
};
test("visible STEP tree rows follow independent expansion state", () => {
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, []).map((row) => row.id),
["root"]
);
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, ["root"]).map((row) => [row.id, row.depth, row.expanded]),
[
["root", 0, true],
["sub", 1, false],
["leaf-c", 1, false]
]
);
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, ["root", "sub"]).map((row) => row.id),
["root", "sub", "leaf-a", "leaf-b", "leaf-c"]
);
});
test("visible STEP tree rows can elide a wrapper root assembly", () => {
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, [], { omitRoot: true }).map((row) => [row.id, row.depth, row.expanded]),
[
["sub", 0, false],
["leaf-c", 0, false]
]
);
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, ["sub"], { omitRoot: true }).map((row) => [row.id, row.depth]),
[
["sub", 0],
["leaf-a", 1],
["leaf-b", 1],
["leaf-c", 0]
]
);
});
test("visible STEP tree rows can limit only root assembly items", () => {
const root = {
id: "root-many",
nodeType: "assembly",
children: Array.from({ length: 18 }, (_, index) => ({
id: `root-child-${index + 1}`,
nodeType: "part",
displayName: `Root child ${index + 1}`,
children: []
}))
};
assert.deepEqual(
flattenVisibleStepTreeRows(root, [], {
omitRoot: true,
rootChildLimit: 15,
showAllRootChildren: false
}).map((row) => row.id),
Array.from({ length: 15 }, (_, index) => `root-child-${index + 1}`)
);
assert.deepEqual(
flattenVisibleStepTreeRows(root, [], {
omitRoot: true,
rootChildLimit: 15,
showAllRootChildren: true
}).map((row) => row.id),
Array.from({ length: 18 }, (_, index) => `root-child-${index + 1}`)
);
});
test("root item limit does not limit nested assembly descendants", () => {
const root = {
id: "root-nested-limit",
nodeType: "assembly",
children: [
{
id: "large-sub",
nodeType: "assembly",
displayName: "large sub assembly",
children: Array.from({ length: 18 }, (_, index) => ({
id: `large-sub-child-${index + 1}`,
nodeType: "part",
displayName: `Large sub child ${index + 1}`,
children: []
}))
},
{
id: "root-hidden",
nodeType: "part",
displayName: "hidden root child",
children: []
}
]
};
assert.deepEqual(
flattenVisibleStepTreeRows(root, ["large-sub"], {
omitRoot: true,
rootChildLimit: 1,
showAllRootChildren: false
}).map((row) => row.id),
[
"large-sub",
...Array.from({ length: 18 }, (_, index) => `large-sub-child-${index + 1}`)
]
);
});
test("STEP tree query keeps matching descendants visible with ancestors", () => {
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, [], { query: "leaf b" }).map((row) => row.id),
["root", "sub", "leaf-b"]
);
});
test("STEP tree query supports elided wrapper roots", () => {
assert.deepEqual(
flattenVisibleStepTreeRows(nestedRoot, [], { query: "leaf b", omitRoot: true }).map((row) => row.id),
["sub", "leaf-b"]
);
});
test("STEP tree leaf ids include nested descendant parts", () => {
assert.deepEqual(stepTreeNodeLeafPartIds(nestedRoot.children[0]), ["leaf-a", "leaf-b"]);
});
test("plain STEP parts get a synthetic selectable root", () => {
const root = buildStepTreeRoot({
selectedEntry: {
file: "parts/bracket.step",
kind: "part"
},
meshData: {
bounds: {
min: [0, 0, 0],
max: [1, 2, 3]
}
}
});
assert.equal(root.id, STEP_MODEL_ROOT_ID);
assert.equal(root.displayName, "bracket.step");
assert.deepEqual(root.leafPartIds, [STEP_MODEL_RENDER_PART_ID]);
});
test("STEP tree topology rows attach below their owning part", () => {
const root = {
id: "root",
nodeType: "assembly",
displayName: "root assembly",
children: [
{
id: "part-a",
nodeType: "part",
displayName: "part A",
children: []
}
]
};
const augmented = buildStepTreeRootWithTopology({
root,
references: [
{
id: "topology|part-a|occurrence|fixture.base",
selectorType: "occurrence",
displaySelector: "fixture.base",
summary: "base_plate",
partId: "part-a",
rowIndex: 0
},
{
id: "topology|part-a|shape|fixture.base.s1",
selectorType: "shape",
displaySelector: "fixture.base.s1",
occurrenceId: "fixture.base",
summary: "base_plate solid volume=1200",
partId: "part-a",
pickData: {
name: "base_plate"
},
rowIndex: 0
},
{
id: "topology|part-a|face|fixture.base.f1",
selectorType: "face",
displaySelector: "fixture.base.f1",
occurrenceId: "fixture.base",
shapeId: "fixture.base.s1",
summary: "plane area=100",
partId: "part-a",
rowIndex: 0
},
{
id: "topology|part-a|edge|fixture.base.e1",
selectorType: "edge",
displaySelector: "fixture.base.e1",
occurrenceId: "fixture.base",
shapeId: "fixture.base.s1",
summary: "line length=10",
partId: "part-a",
rowIndex: 0
}
]
});
assert.equal(root.children[0].children.length, 0);
assert.deepEqual(
augmented.children[0].children.map((child) => [child.nodeType, child.displayName, child.detail]),
[
["topology-shape", "base_plate", "base_plate solid volume=1200"],
["topology-face", "Face f1", "plane area=100"],
["topology-edge", "Edge e1", "line length=10"]
]
);
assert.deepEqual(
flattenVisibleStepTreeRows(augmented, [
"root",
"part-a"
]).map((row) => [row.nodeType, row.label, row.detail]),
[
["assembly", "root assembly", ""],
["part", "part A", ""],
["topology-shape", "base_plate", "base_plate solid volume=1200"],
["topology-face", "Face f1", "plane area=100"],
["topology-edge", "Edge e1", "line length=10"]
]
);
});
test("STEP tree keeps occurrence rows when one part owns multiple topology occurrences", () => {
const root = {
id: "root",
nodeType: "part",
displayName: "part",
children: []
};
const augmented = buildStepTreeRootWithTopology({
root,
references: [
{
id: "topology|part-a|face|fixture.base.f1",
selectorType: "face",
displaySelector: "fixture.base.f1",
occurrenceId: "fixture.base",
partId: "root",
rowIndex: 0
},
{
id: "topology|part-a|face|fixture.lid.f1",
selectorType: "face",
displaySelector: "fixture.lid.f1",
occurrenceId: "fixture.lid",
partId: "root",
rowIndex: 1
}
]
});
const occurrence = augmented.children[0];
assert.equal(occurrence.nodeType, "topology-occurrence");
assert.equal(occurrence.id, "step-topology:root:occurrence:fixture.base");
assert.equal(occurrence.topologyReferenceId, occurrence.id);
assert.deepEqual(
augmented.children.map((child) => [child.nodeType, child.displaySelector, child.children.length]),
[
["topology-occurrence", "fixture.base", 1],
["topology-occurrence", "fixture.lid", 1]
]
);
});
test("STEP tree flattens duplicate semantic occurrence wrappers", () => {
const root = {
id: "o1.6",
occurrenceId: "o1.6",
nodeType: "part",
displayName: "triangular_prism",
children: []
};
const augmented = buildStepTreeRootWithTopology({
root,
references: [
{
id: "topology|o1.6|face|triangular_prism.f1",
selectorType: "face",
displaySelector: "triangular_prism.f1",
occurrenceId: "triangular_prism",
partId: "o1.6",
rowIndex: 0
},
{
id: "topology|o1.6|face|fixture.lid.f1",
selectorType: "face",
displaySelector: "fixture.lid.f1",
occurrenceId: "fixture.lid",
partId: "o1.6",
rowIndex: 1
}
]
});
assert.deepEqual(
augmented.children.map((child) => [child.nodeType, child.displaySelector, child.children.length]),
[
["topology-occurrence", "fixture.lid", 1],
["topology-face", "triangular_prism.f1", 0]
]
);
});
test("STEP tree flattens duplicate numbered component occurrence wrappers", () => {
const root = {
id: "root",
occurrenceId: "o1",
nodeType: "assembly",
displayName: "root assembly",
children: [
{
id: "o1.7.1",
occurrenceId: "o1.7.1",
nodeType: "part",
displayName: "cube_top_pad",
children: []
}
]
};
const augmented = buildStepTreeRootWithTopology({
root,
references: [
{
id: "o1.7.1",
selectorType: "occurrence",
displaySelector: "o1.7.1",
summary: "cube_top_pad",
partId: "o1.7.1",
rowIndex: 0
},
{
id: "o1.7.1.s1",
selectorType: "shape",
displaySelector: "o1.7.1.s1",
occurrenceId: "o1.7.1",
summary: "cube_top_pad solid volume=490",
partId: "o1.7.1",
rowIndex: 0
},
{
id: "fixture.lid.f1",
selectorType: "face",
displaySelector: "fixture.lid.f1",
occurrenceId: "fixture.lid",
partId: "o1.7.1",
rowIndex: 1
}
]
});
assert.deepEqual(
augmented.children[0].children.map((child) => [child.nodeType, child.displaySelector, child.children.length]),
[
["topology-occurrence", "fixture.lid", 1],
["topology-shape", "o1.7.1.s1", 0]
]
);
assert.deepEqual(
flattenVisibleStepTreeRows(augmented, ["root", "o1.7.1"]).map((row) => [row.id, row.depth]),
[
["root", 0],
["o1.7.1", 1],
["step-topology:o1.7.1:occurrence:fixture.lid", 2],
["step-topology:o1.7.1:shape:o1.7.1.s1", 2]
]
);
});
test("STEP tree can assign topology references to assembly parts by occurrence id", () => {
const root = {
id: "root",
occurrenceId: "o1",
nodeType: "assembly",
displayName: "root assembly",
children: [
{
id: "servo-part",
occurrenceId: "o1.3.2",
sourceOccurrenceId: "o1.4",
nodeType: "part",
displayName: "servo",
children: []
},
{
id: "gripper-part",
occurrenceId: "o1.4",
nodeType: "part",
displayName: "gripper",
children: []
}
]
};
const references = [
{
id: "o1",
selectorType: "occurrence",
displaySelector: "o1",
summary: "root"
},
{
id: "o1.3.2",
selectorType: "occurrence",
displaySelector: "o1.3.2",
summary: "servo"
},
{
id: "o1.3.2.s1",
selectorType: "shape",
displaySelector: "o1.3.2.s1",
occurrenceId: "o1.3.2",
summary: "servo solid"
},
{
id: "o1.3.2.f1",
selectorType: "face",
displaySelector: "o1.3.2.f1",
occurrenceId: "o1.3.2",
summary: "plane area=10"
},
{
id: "o1.4.s1",
selectorType: "shape",
displaySelector: "o1.4.s1",
occurrenceId: "o1.4",
summary: "gripper solid"
},
{
id: "manual-part-ref",
selectorType: "shape",
displaySelector: "o1.9.s1",
occurrenceId: "o1.9",
partId: "explicit-part",
summary: "explicit part"
}
];
const assigned = assignStepTreeTopologyReferencePartIds(root, references);
assert.deepEqual(
assigned.map((reference) => reference.partId || ""),
["", "servo-part", "servo-part", "servo-part", "gripper-part", "explicit-part"]
);
assert.equal(references[1].partId, undefined);
const augmented = buildStepTreeRootWithTopology({ root, references: assigned });
assert.deepEqual(
augmented.children.map((child) => [
child.id,
child.children.map((topologyChild) => topologyChild.displaySelector)
]),
[
["servo-part", ["o1.3.2.s1", "o1.3.2.f1"]],
["gripper-part", ["o1.4.s1"]]
]
);
});
test("STEP tree topology rows can be limited to loaded parts", () => {
const root = {
id: "root",
occurrenceId: "o1",
nodeType: "assembly",
displayName: "root assembly",
children: [
{
id: "part-a",
occurrenceId: "o1.1",
nodeType: "part",
displayName: "part A",
children: []
},
{
id: "part-b",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "part B",
children: []
}
]
};
const references = [
{
id: "o1.1.f1",
selectorType: "face",
displaySelector: "o1.1.f1",
occurrenceId: "o1.1",
partId: "part-a",
summary: "plane area=10"
},
{
id: "o1.2.f1",
selectorType: "face",
displaySelector: "o1.2.f1",
occurrenceId: "o1.2",
partId: "part-b",
summary: "plane area=20"
}
];
const augmented = buildStepTreeRootWithTopology({
root,
references,
topologyPartIds: ["part-b"]
});
assert.deepEqual(
augmented.children.map((child) => [
child.id,
child.children.map((topologyChild) => topologyChild.displaySelector)
]),
[
["part-a", []],
["part-b", ["o1.2.f1"]]
]
);
});
test("STEP tree flattens redundant topology occurrence rows for single STEP roots", () => {
const root = {
id: STEP_MODEL_ROOT_ID,
nodeType: "part",
displayName: "base_plate.step",
children: []
};
const augmented = buildStepTreeRootWithTopology({
root,
references: [
{
id: "o1.f1",
selectorType: "face",
displaySelector: "o1.f1",
occurrenceId: "o1",
partId: STEP_MODEL_ROOT_ID,
summary: "plane area=100"
}
]
});
const folder = augmented.children[0];
assert.equal(folder.nodeType, "topology-folder");
assert.equal(folder.displayName, "base_plate");
assert.equal(folder.visualOnly, true);
assert.deepEqual(folder.leafPartIds, [STEP_MODEL_RENDER_PART_ID]);
assert.deepEqual(
folder.children.map((child) => [child.nodeType, child.displaySelector]),
[["topology-face", "o1.f1"]]
);
assert.deepEqual(
flattenVisibleStepTreeRows(augmented, [folder.id], { omitRoot: true })
.map((row) => [row.nodeType, row.label, row.depth]),
[
["topology-folder", "base_plate", 0],
["topology-face", "Face f1", 1]
]
);
});
test("ancestor ids are collected without the selected node", () => {
assert.deepEqual(collectStepTreeAncestorIds(nestedRoot, "leaf-b"), ["root", "sub"]);
});
test("root child index resolves direct children and descendants", () => {
assert.equal(stepTreeRootChildIndexForNode(nestedRoot, "sub"), 0);
assert.equal(stepTreeRootChildIndexForNode(nestedRoot, "leaf-b"), 0);
assert.equal(stepTreeRootChildIndexForNode(nestedRoot, "leaf-c"), 1);
assert.equal(stepTreeRootChildIndexForNode(nestedRoot, "root"), -1);
assert.equal(stepTreeRootChildIndexForNode(nestedRoot, "missing"), -1);
});
@@ -0,0 +1,402 @@
import {
STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
STEP_EDGE_CLASS_ATTRIBUTE,
STEP_TOPOLOGY_EXTENSION,
STEP_TOPOLOGY_SCHEMA_VERSION,
isCurrentStepTopologySchemaVersion
} from "../common/stepTopology.mjs";
import { buildMeshDataFromGlbBuffer } from "./render/glbMeshData.js";
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
const arrayBufferCache = new Map();
const arrayBufferPendingCache = new Map();
const glbCache = new Map();
const selectorCache = new Map();
const displayEdgeCache = new Map();
const topologyIndexCache = new Map();
const GIT_LFS_POINTER_PREFIX = "version https://git-lfs.github.com/spec/v1";
const GIT_LFS_POINTER_SCAN_BYTES = 512;
async function fetchArrayBuffer(url, { signal } = {}) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
async function loadCached(cache, key, loader, { cachePending = true } = {}) {
if (!key) {
throw new Error("Missing asset cache key");
}
if (cache.has(key)) {
const cached = cache.get(key);
if (cachePending || typeof cached?.then !== "function") {
return cached;
}
}
if (!cachePending) {
const payload = await loader();
cache.set(key, payload);
return payload;
}
let pending;
pending = loader().catch((error) => {
if (cache.get(key) === pending) {
cache.delete(key);
}
throw error;
});
cache.set(key, pending);
return pending;
}
function peekCached(cache, key) {
const value = cache.get(key);
return value && typeof value.then !== "function" ? value : null;
}
function finalizeCached(cache, key, value) {
cache.set(key, value);
return value;
}
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function withConsumerAbort(promise, signal) {
if (!signal) {
return promise;
}
if (signal.aborted) {
return Promise.reject(makeAbortError());
}
return new Promise((resolve, reject) => {
const cleanup = () => {
signal.removeEventListener?.("abort", abort);
};
const abort = () => {
cleanup();
reject(makeAbortError());
};
signal.addEventListener?.("abort", abort, { once: true });
promise.then(
(value) => {
cleanup();
resolve(value);
},
(error) => {
cleanup();
reject(error);
}
);
});
}
function gitLfsPointerDetailsFromBuffer(buffer) {
const byteLength = Number(buffer?.byteLength || 0);
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return null;
}
const preview = new TextDecoder("utf-8").decode(
new Uint8Array(buffer, 0, Math.min(byteLength, GIT_LFS_POINTER_SCAN_BYTES))
);
if (!preview.startsWith(GIT_LFS_POINTER_PREFIX)) {
return null;
}
const oid = preview.match(/^oid sha256:([0-9a-f]{64})\r?$/m)?.[1] || "";
const sizeText = preview.match(/^size ([0-9]+)\r?$/m)?.[1] || "";
const size = sizeText ? Number(sizeText) : null;
return {
oid,
size: Number.isSafeInteger(size) ? size : null,
};
}
function displayPathFromUrl(url) {
const rawUrl = String(url || "").trim();
if (!rawUrl) {
return "(unknown)";
}
try {
const baseUrl = typeof window !== "undefined" && window.location?.href
? window.location.href
: "http://localhost/";
const parsed = new URL(rawUrl, baseUrl);
return decodeURIComponent(parsed.pathname || rawUrl);
} catch {
return rawUrl.split(/[?#]/)[0] || rawUrl;
}
}
function assertNotGitLfsPointer(buffer, url, assetLabel = "Render asset") {
const pointer = gitLfsPointerDetailsFromBuffer(buffer);
if (!pointer) {
return;
}
const sizeText = pointer.size !== null ? ` Expected LFS object size: ${pointer.size} bytes.` : "";
const oidText = pointer.oid ? ` sha256:${pointer.oid}.` : "";
throw new Error(
`${assetLabel} is a Git LFS pointer, not downloaded mesh data: ${displayPathFromUrl(url)}.${oidText}${sizeText} Fetch the LFS object for this file and reload the viewer.`
);
}
export async function loadRenderArrayBuffer(url, { signal } = {}) {
if (!url) {
throw new Error("Missing asset cache key");
}
if (signal?.aborted) {
throw makeAbortError();
}
const cached = peekCached(arrayBufferCache, url);
if (cached) {
return cached;
}
let pending = arrayBufferPendingCache.get(url);
if (!pending) {
pending = fetchArrayBuffer(url)
.then((payload) => finalizeCached(arrayBufferCache, url, payload))
.finally(() => {
if (arrayBufferPendingCache.get(url) === pending) {
arrayBufferPendingCache.delete(url);
}
});
arrayBufferPendingCache.set(url, pending);
}
return withConsumerAbort(pending, signal);
}
export async function loadRenderGlb(url, { signal } = {}) {
const meshData = await loadCached(glbCache, url, async () => {
const buffer = await loadRenderArrayBuffer(url, { signal });
assertNotGitLfsPointer(buffer, url, "GLB render asset");
return buildMeshDataFromGlbBuffer(buffer);
}, { cachePending: !signal });
return finalizeCached(glbCache, url, meshData);
}
function parseGlbContainer(arrayBuffer) {
const data = new DataView(arrayBuffer);
if (data.byteLength < 20 || data.getUint32(0, true) !== 0x46546c67 || data.getUint32(4, true) !== 2) {
throw new Error("Invalid GLB topology container");
}
const totalLength = Math.min(data.getUint32(8, true), data.byteLength);
let offset = 12;
let json = null;
let bin = null;
while (offset + 8 <= totalLength) {
const chunkLength = data.getUint32(offset, true);
const chunkType = data.getUint32(offset + 4, true);
offset += 8;
if (offset + chunkLength > totalLength) {
throw new Error("Invalid GLB chunk length");
}
if (chunkType === 0x4e4f534a) {
json = JSON.parse(new TextDecoder("utf-8").decode(arrayBuffer.slice(offset, offset + chunkLength)).trim());
} else if (chunkType === 0x004e4942) {
bin = {
buffer: arrayBuffer,
byteOffset: offset,
byteLength: chunkLength,
};
}
offset += chunkLength;
}
if (!json || !bin) {
throw new Error("GLB topology requires JSON and BIN chunks");
}
return { json, bin };
}
function glbBufferViewRange(gltf, bin, viewIndex) {
const index = Number(viewIndex);
const view = Array.isArray(gltf?.bufferViews) ? gltf.bufferViews[index] : null;
if (!Number.isInteger(index) || !view || Number(view.buffer || 0) !== 0) {
return null;
}
const byteOffset = bin.byteOffset + Number(view.byteOffset || 0);
const byteLength = Number(view.byteLength || 0);
if (!Number.isFinite(byteOffset) || !Number.isFinite(byteLength) || byteLength < 0) {
return null;
}
if (byteOffset < bin.byteOffset || byteOffset + byteLength > bin.byteOffset + bin.byteLength) {
return null;
}
return { byteOffset, byteLength };
}
function buildTypedView(glb, view) {
if (!isObject(view)) {
return null;
}
const range = glbBufferViewRange(glb.json, glb.bin, view.bufferView);
if (!range) {
return null;
}
const count = Number(view.count || 0);
const relativeOffset = Number(view.byteOffset || 0);
if (!Number.isFinite(count) || count < 0 || !Number.isFinite(relativeOffset) || relativeOffset < 0) {
return null;
}
const byteOffset = range.byteOffset + relativeOffset;
if (view.dtype === "float32") {
return new Float32Array(glb.bin.buffer, byteOffset, count);
}
if (view.dtype === "uint32") {
return new Uint32Array(glb.bin.buffer, byteOffset, count);
}
return null;
}
function buildSelectorBuffers(manifest, glb) {
const views = manifest?.buffers?.views;
if (!isObject(views)) {
return {};
}
const output = {};
for (const [name, view] of Object.entries(views)) {
const typed = buildTypedView(glb, view);
if (typed) {
output[name] = typed;
}
}
return output;
}
function glbPrimitivesHaveSurfaceEdgeAttributes(glb) {
const meshes = Array.isArray(glb?.json?.meshes) ? glb.json.meshes : [];
let primitiveCount = 0;
for (const mesh of meshes) {
for (const primitive of Array.isArray(mesh?.primitives) ? mesh.primitives : []) {
primitiveCount += 1;
const attributes = primitive?.attributes || {};
if (
attributes[STEP_EDGE_BARYCENTRIC_ATTRIBUTE] === undefined ||
attributes[STEP_EDGE_CLASS_ATTRIBUTE] === undefined
) {
return false;
}
}
}
return primitiveCount > 0;
}
function stepTopologyExtension(glb) {
const extension = glb.json?.extensions?.[STEP_TOPOLOGY_EXTENSION];
if (!isObject(extension)) {
throw new Error(`GLB is missing ${STEP_TOPOLOGY_EXTENSION}`);
}
if (!isCurrentStepTopologySchemaVersion(extension.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} schemaVersion ${extension.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (extension.edgeView === undefined || extension.edgeView === null) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView is not available`);
}
if (!glbPrimitivesHaveSurfaceEdgeAttributes(glb)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} requires ${STEP_EDGE_BARYCENTRIC_ATTRIBUTE} and ${STEP_EDGE_CLASS_ATTRIBUTE} on every STEP mesh primitive`);
}
return extension;
}
function parseJsonBufferView(glb, viewIndex, encoding = "utf-8") {
const range = glbBufferViewRange(glb.json, glb.bin, viewIndex);
if (!range) {
return null;
}
const bytes = new Uint8Array(glb.bin.buffer, range.byteOffset, range.byteLength);
return JSON.parse(new TextDecoder(String(encoding || "utf-8")).decode(bytes));
}
function topologyIndexFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.indexView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} indexView is invalid`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} index schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
return manifest;
}
function selectorBundleFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.selectorView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} selectorView is not available`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} selector schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (manifest?.buffers?.littleEndian === false) {
throw new Error("Big-endian selector buffers are not supported");
}
return {
manifest,
buffers: buildSelectorBuffers(manifest, glb),
};
}
function displayEdgeBundleFromGlbBuffer(arrayBuffer) {
const glb = parseGlbContainer(arrayBuffer);
const extension = stepTopologyExtension(glb);
const manifest = parseJsonBufferView(glb, extension.edgeView, extension.encoding);
if (!isObject(manifest)) {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView is not available`);
}
if (!isCurrentStepTopologySchemaVersion(manifest.schemaVersion)) {
throw new Error(`Unsupported ${STEP_TOPOLOGY_EXTENSION} edgeView schemaVersion ${manifest.schemaVersion || "unknown"}; expected ${STEP_TOPOLOGY_SCHEMA_VERSION}`);
}
if (String(manifest.profile || "") !== "surface-edges") {
throw new Error(`${STEP_TOPOLOGY_EXTENSION} edgeView has unsupported profile ${manifest.profile || "unknown"}`);
}
if (manifest?.buffers?.littleEndian === false) {
throw new Error("Big-endian edgeView buffers are not supported");
}
return {
manifest,
buffers: buildSelectorBuffers(manifest, glb),
};
}
export async function loadRenderTopologyIndex(glbUrl, { signal } = {}) {
const manifest = await loadCached(topologyIndexCache, glbUrl, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB topology asset");
return topologyIndexFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(topologyIndexCache, glbUrl, manifest);
}
export async function loadRenderSelectorBundle(glbUrl, { signal } = {}) {
const bundle = await loadCached(selectorCache, glbUrl, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB selector topology asset");
return selectorBundleFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(selectorCache, glbUrl, bundle);
}
export async function loadRenderDisplayEdgeBundle(glbUrl, { signal } = {}) {
const bundle = await loadCached(displayEdgeCache, glbUrl, async () => {
const arrayBuffer = await loadRenderArrayBuffer(glbUrl, { signal });
assertNotGitLfsPointer(arrayBuffer, glbUrl, "GLB display edge topology asset");
return displayEdgeBundleFromGlbBuffer(arrayBuffer);
}, { cachePending: !signal });
return finalizeCached(displayEdgeCache, glbUrl, bundle);
}
@@ -0,0 +1 @@
export * from "../common/themeSettings.js";
@@ -0,0 +1,579 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cloneThemePresetSettings,
DEFAULT_FLOOR_GRID_SETTINGS,
DEFAULT_THEME_PRESET_ID,
DEFAULT_THEME_SETTINGS,
getThemePresetIdForSettings,
inferThemeSettingsSceneTone,
MAX_FLOOR_GRID_DENSITY,
THEME_COLOR_MODES,
THEME_FLOOR_MODES,
THEME_PRESETS,
MAX_THEME_FILL_COLORS,
normalizeThemeFillColors,
normalizeThemeSettings,
resolveThemeFillColor,
resolveThemeSettingsForColorMode,
resolveSystemThemePresetId,
themeSettingsSupportsSystemColorMode
} from "./themeSettings.js";
const WORKBENCH_FILL_COLORS = Object.freeze([
"#b6c4ce",
"#f4a7a7",
"#f8c77e",
"#f7e38d",
"#b9e88f",
"#8fe3c0",
"#92d7f5",
"#a9b8ff",
"#c7a8ff",
"#f2a7d9"
]);
const BLUE_FILL_COLORS = Object.freeze(["#4cc9f0"]);
const MAGENTA_FILL_COLORS = Object.freeze(["#ff4faf"]);
const CLAY_FILL_COLORS = Object.freeze(["#b9856e"]);
const BEACH_FILL_COLORS = Object.freeze(["#178f9f", "#ef7459"]);
const TERMINAL_FILL_COLORS = Object.freeze(["#0b7a3f"]);
const DARKOAL_FILL_COLORS = Object.freeze([
"#b6c4ce",
"#c2a1a5",
"#e6d1af",
"#b0ab85",
"#91ae86",
"#7cab9f",
"#7da5b9",
"#8996be",
"#988ebe",
"#ad8dab"
]);
test("theme presets expose a default material color", () => {
const blue = cloneThemePresetSettings("blue");
const pink = cloneThemePresetSettings("pink");
assert.equal(THEME_PRESETS.find((preset) => preset.id === "pink")?.label, "Magenta");
assert.equal(blue.materials.defaultColor, "#4cc9f0");
assert.deepEqual(blue.materials.fillColors, BLUE_FILL_COLORS);
assert.equal(blue.materials.cycleColors, false);
assert.equal(resolveThemeFillColor(blue.materials, 3), "#4cc9f0");
assert.equal(pink.materials.defaultColor, "#ff4faf");
assert.deepEqual(pink.materials.fillColors, MAGENTA_FILL_COLORS);
assert.equal(pink.materials.cycleColors, false);
assert.equal(resolveThemeFillColor(pink.materials, 3), "#ff4faf");
assert.equal(getThemePresetIdForSettings(blue), "blue");
assert.equal(getThemePresetIdForSettings(pink), "pink");
});
test("workbench is the default system-aware theme preset", () => {
assert.equal(DEFAULT_THEME_PRESET_ID, "workbench");
assert.equal(THEME_PRESETS[0]?.id, "workbench");
assert.equal(THEME_PRESETS[0]?.label, "Workbench");
assert.equal(getThemePresetIdForSettings(DEFAULT_THEME_SETTINGS), "workbench");
assert.deepEqual(cloneThemePresetSettings("cinematic"), cloneThemePresetSettings("workbench"));
assert.deepEqual(cloneThemePresetSettings("light"), cloneThemePresetSettings("workbench"));
assert.deepEqual(cloneThemePresetSettings("dark"), cloneThemePresetSettings("workbench"));
});
test("workbench preset uses neutral material treatment while preserving source colors", () => {
const cinematic = cloneThemePresetSettings("workbench");
assert.equal(cinematic.colorMode, THEME_COLOR_MODES.SYSTEM);
assert.equal(cinematic.materials.defaultColor, "#b6c4ce");
assert.deepEqual(cinematic.materials.fillColors, WORKBENCH_FILL_COLORS);
assert.equal(cinematic.materials.cycleColors, false);
assert.equal(resolveThemeFillColor(cinematic.materials, 3), "#b6c4ce");
assert.equal(cinematic.materials.overrideSourceColors, false);
assert.equal(cinematic.materials.tintMode, "blend");
assert.equal(cinematic.materials.tintStrength, 0);
assert.equal(cinematic.materials.saturation, 1.18);
assert.equal(cinematic.materials.contrast, 1.12);
assert.equal(cinematic.materials.brightness, 1.02);
assert.equal(cinematic.materials.roughness, 0.58);
assert.equal(cinematic.materials.clearcoat, 0.12);
assert.equal(cinematic.materials.opacity, 1);
assert.equal(cinematic.materials.envMapIntensity, 0.42);
assert.equal(cinematic.materials.emissiveIntensity, 0.02);
assert.equal(Object.hasOwn(cinematic, "edges"), false);
assert.equal(cinematic.environment.enabled, false);
assert.equal(cinematic.environment.intensity, 0.32);
assert.equal(cinematic.background.type, "solid");
assert.equal(cinematic.background.solidColor, "#f0f4f9");
assert.equal(cinematic.background.linearStart, "#f0f4f9");
assert.equal(cinematic.background.linearEnd, "#f0f4f9");
assert.equal(cinematic.floor.mode, THEME_FLOOR_MODES.STAGE);
assert.equal(cinematic.floor.enabled, true);
assert.equal(cinematic.floor.grid.enabled, true);
assert.equal(cinematic.floor.reflectivity, 0.14);
assert.equal(cinematic.lighting.toneMappingExposure, 1.16);
assert.equal(cinematic.lighting.ambient.intensity, 0.4);
assert.equal(cinematic.lighting.hemisphere.intensity, 1.12);
assert.equal(cinematic.modeColors.light.background.linearStart, "#f0f4f9");
assert.equal(cinematic.modeColors.dark.background.linearStart, "#242e3a");
assert.equal(cinematic.modeColors.dark.floor.color, "#202832");
});
test("workbench dark color mode uses the workbench dark color treatment", () => {
const workbench = cloneThemePresetSettings("workbench");
const dark = resolveThemeSettingsForColorMode(workbench, { prefersDark: true });
assert.equal(THEME_PRESETS.some((preset) => preset.id === "dark"), false);
assert.equal(dark.colorMode, THEME_COLOR_MODES.DARK);
assert.equal(dark.materials.defaultColor, "#b6c4ce");
assert.deepEqual(dark.materials.fillColors, WORKBENCH_FILL_COLORS);
assert.equal(dark.materials.cycleColors, false);
assert.equal(resolveThemeFillColor(dark.materials, 3), "#b6c4ce");
assert.equal(dark.materials.tintMode, "blend");
assert.equal(dark.materials.tintStrength, 0);
assert.equal(dark.materials.saturation, 1.18);
assert.equal(dark.materials.contrast, 1.12);
assert.equal(dark.materials.brightness, 1.02);
assert.equal(dark.materials.opacity, 1);
assert.equal(Object.hasOwn(dark, "edges"), false);
assert.equal(dark.environment.enabled, false);
assert.equal(dark.environment.intensity, 0.32);
assert.equal(dark.background.type, "solid");
assert.equal(dark.background.solidColor, "#181f28");
assert.equal(dark.background.linearStart, "#242e3a");
assert.equal(dark.background.linearEnd, "#0c1016");
assert.equal(dark.background.radialInner, "#293443");
assert.equal(dark.background.radialOuter, "#0c1016");
assert.equal(dark.floor.color, "#202832");
assert.equal(dark.floor.roughness, 0.7);
assert.equal(dark.floor.reflectivity, 0.14);
assert.equal(dark.floor.shadowOpacity, 0.16);
assert.equal(dark.floor.horizonBlend, 0.18);
assert.equal(dark.lighting.toneMappingExposure, 1.16);
assert.equal(dark.lighting.spot.enabled, true);
assert.equal(dark.lighting.spot.color, "#b3d4f2");
assert.equal(dark.lighting.spot.intensity, 0.52);
assert.equal(dark.lighting.point.color, "#bfd8f0");
assert.equal(dark.lighting.ambient.color, "#dfe7f0");
assert.equal(dark.lighting.ambient.intensity, 0.4);
assert.equal(dark.lighting.hemisphere.groundColor, "#333d4b");
assert.equal(dark.lighting.hemisphere.intensity, 1.12);
assert.equal(getThemePresetIdForSettings(workbench), "workbench");
});
test("beach preset keeps light materials with sunlit sand presentation styling", () => {
const beach = cloneThemePresetSettings("beach");
const beachPreset = THEME_PRESETS.find((preset) => preset.id === "beach");
assert.equal(beachPreset?.label, "Beach");
assert.equal(beach.colorMode, THEME_COLOR_MODES.LIGHT);
assert.equal(beach.materials.defaultColor, BEACH_FILL_COLORS[0]);
assert.deepEqual(beach.materials.fillColors, BEACH_FILL_COLORS);
assert.equal(beach.materials.cycleColors, true);
assert.equal(resolveThemeFillColor(beach.materials, 0), BEACH_FILL_COLORS[0]);
assert.equal(resolveThemeFillColor(beach.materials, 1), BEACH_FILL_COLORS[1]);
assert.equal(beach.materials.overrideSourceColors, false);
assert.equal(beach.materials.tintMode, "blend");
assert.equal(beach.materials.tintStrength, 0);
assert.equal(beach.materials.opacity, 1);
assert.equal(Object.hasOwn(beach, "edges"), false);
assert.equal(beach.background.type, "linear");
assert.equal(beach.background.solidColor, "#dff7f7");
assert.equal(beach.background.linearStart, "#fff4d6");
assert.equal(beach.background.linearEnd, "#47c5d6");
assert.equal(beach.floor.mode, "stage");
assert.equal(beach.floor.color, "#f2d59b");
assert.equal(beach.floor.reflectivity, 0.18);
assert.equal(beach.environment.enabled, true);
assert.equal(beach.environment.presetId, "studio-hdri-12");
assert.equal(beach.lighting.directional.color, "#fff7df");
assert.equal(beach.lighting.point.color, "#ffd08a");
assert.equal(beach.lighting.hemisphere.skyColor, "#bff8ff");
assert.equal(inferThemeSettingsSceneTone(beach), "light");
assert.equal(getThemePresetIdForSettings(beach), "beach");
});
test("theme settings do not normalize display edge settings", () => {
const normalized = normalizeThemeSettings({
...cloneThemePresetSettings("workbench"),
edges: {
enabled: false,
color: "#ff0000"
}
});
assert.equal(Object.hasOwn(normalized, "edges"), false);
assert.equal(Object.hasOwn(cloneThemePresetSettings("workbench"), "edges"), false);
});
test("built-in theme preset ids stay explicit", () => {
assert.deepEqual(THEME_PRESETS.map((preset) => preset.id), [
"workbench",
"blue",
"pink",
"clay-sunrise",
"beach",
"terminal"
]);
});
test("legacy darkoal and charcoal ids resolve to workbench", () => {
const workbench = cloneThemePresetSettings("workbench");
assert.equal(THEME_PRESETS.some((preset) => preset.id === "darkoal"), false);
assert.equal(THEME_PRESETS.some((preset) => preset.id === "charcoal"), false);
assert.deepEqual(cloneThemePresetSettings("darkoal"), workbench);
assert.deepEqual(cloneThemePresetSettings("charcoal"), workbench);
assert.deepEqual(cloneThemePresetSettings("dark-2"), workbench);
});
test("stylized presets keep their palettes and declare an opinionated color mode", () => {
const paletteExpectations = [
{
presetId: "blue",
colorMode: THEME_COLOR_MODES.DARK,
materialColor: BLUE_FILL_COLORS[0],
fillColors: BLUE_FILL_COLORS,
cycleColors: false,
backgroundColor: "#04131f",
floorColor: "#06324f"
},
{
presetId: "pink",
colorMode: THEME_COLOR_MODES.DARK,
materialColor: MAGENTA_FILL_COLORS[0],
fillColors: MAGENTA_FILL_COLORS,
cycleColors: false,
backgroundColor: "#281323",
floorColor: "#4a1833"
},
{
presetId: "clay-sunrise",
colorMode: THEME_COLOR_MODES.LIGHT,
materialColor: CLAY_FILL_COLORS[0],
fillColors: CLAY_FILL_COLORS,
cycleColors: false,
backgroundColor: "#f3eadc",
floorColor: "#d4a070"
},
{
presetId: "beach",
colorMode: THEME_COLOR_MODES.LIGHT,
materialColor: BEACH_FILL_COLORS[0],
fillColors: BEACH_FILL_COLORS,
cycleColors: true,
backgroundColor: "#dff7f7",
floorColor: "#f2d59b"
},
{
presetId: "terminal",
colorMode: THEME_COLOR_MODES.DARK,
materialColor: TERMINAL_FILL_COLORS[0],
fillColors: TERMINAL_FILL_COLORS,
cycleColors: false,
backgroundColor: "#020403",
floorColor: "#020403"
}
];
for (const expectation of paletteExpectations) {
const settings = cloneThemePresetSettings(expectation.presetId);
assert.equal(settings.colorMode, expectation.colorMode);
assert.equal(settings.materials.defaultColor, expectation.materialColor);
assert.deepEqual(settings.materials.fillColors, expectation.fillColors);
assert.equal(settings.materials.cycleColors, expectation.cycleColors);
assert.equal(Object.hasOwn(settings, "edges"), false);
assert.equal(settings.background.solidColor, expectation.backgroundColor);
assert.equal(settings.floor.color, expectation.floorColor);
assert.equal(getThemePresetIdForSettings(settings), expectation.presetId);
}
});
test("fill color normalization keeps up to fifty colors and syncs the default fill", () => {
assert.deepEqual(normalizeThemeFillColors(["#ABC", "nope", "#123456"], "#ffffff"), ["#aabbcc", "#123456"]);
assert.deepEqual(normalizeThemeFillColors([], "#abc123"), ["#abc123"]);
const fillColors = Array.from({ length: MAX_THEME_FILL_COLORS + 1 }, (_, index) => {
return `#${String(index + 1).padStart(6, "0")}`;
});
const normalized = normalizeThemeSettings({
...cloneThemePresetSettings("dark"),
materials: {
...cloneThemePresetSettings("dark").materials,
defaultColor: "#111111",
fillColors,
cycleColors: true,
overrideSourceColors: true
}
});
assert.equal(normalized.materials.defaultColor, "#000001");
assert.equal(normalized.materials.fillColors.length, MAX_THEME_FILL_COLORS);
assert.equal(normalized.materials.fillColors.at(-1), "#000050");
assert.equal(normalized.materials.cycleColors, true);
assert.equal(normalized.materials.overrideSourceColors, true);
assert.equal(resolveThemeFillColor(normalized.materials, 51), "#000002");
});
test("floor grid settings normalize as theme-owned controls", () => {
const normalized = normalizeThemeSettings({
floor: {
mode: "grid",
color: "#101820",
gridCenter: "#123",
gridCellColor: "#456789",
gridOpacity: 2,
gridDensity: 99
}
});
assert.equal(normalized.floor.mode, THEME_FLOOR_MODES.GRID);
assert.equal(normalized.floor.gridCenterColor, "#112233");
assert.equal(normalized.floor.gridCellColor, "#456789");
assert.equal(normalized.floor.gridOpacity, 1);
assert.equal(normalized.floor.gridDensity, MAX_FLOOR_GRID_DENSITY);
const fallback = normalizeThemeSettings({ floor: { color: "#111111" } });
assert.notEqual(fallback.floor.gridCenterColor, DEFAULT_FLOOR_GRID_SETTINGS.gridCenterColor);
assert.equal(fallback.floor.gridOpacity, DEFAULT_FLOOR_GRID_SETTINGS.gridOpacity);
});
test("disabled color cycling preserves palettes without rotating fills", () => {
const normalized = normalizeThemeSettings({
materials: {
defaultColor: "#111111",
fillColors: ["#111111", "#222222", "#333333"],
cycleColors: false
}
});
assert.deepEqual(normalized.materials.fillColors, ["#111111", "#222222", "#333333"]);
assert.equal(resolveThemeFillColor(normalized.materials, 0), "#111111");
assert.equal(resolveThemeFillColor(normalized.materials, 2), "#111111");
});
test("system theme preset stays on the workbench preset", () => {
assert.equal(resolveSystemThemePresetId({ prefersDark: false }), "workbench");
assert.equal(resolveSystemThemePresetId({ prefersDark: true }), "workbench");
});
test("scene tone is inferred from the active floor color", () => {
assert.equal(inferThemeSettingsSceneTone(cloneThemePresetSettings("workbench")), "light");
assert.equal(inferThemeSettingsSceneTone(cloneThemePresetSettings("workbench"), { prefersDark: true }), "dark");
assert.equal(inferThemeSettingsSceneTone(cloneThemePresetSettings("charcoal"), { prefersDark: true }), "dark");
assert.equal(inferThemeSettingsSceneTone(cloneThemePresetSettings("blue")), "dark");
assert.equal(inferThemeSettingsSceneTone(cloneThemePresetSettings("clay-sunrise")), "light");
assert.equal(inferThemeSettingsSceneTone({
...cloneThemePresetSettings("workbench"),
colorMode: THEME_COLOR_MODES.LIGHT,
background: {
...cloneThemePresetSettings("workbench").background,
type: "solid",
solidColor: "#f8fafc"
}
}), "light");
assert.equal(inferThemeSettingsSceneTone({
...cloneThemePresetSettings("workbench"),
colorMode: THEME_COLOR_MODES.LIGHT,
background: {
...cloneThemePresetSettings("workbench").background,
type: "solid",
solidColor: "#030914"
},
floor: {
...cloneThemePresetSettings("workbench").floor,
color: "#f8fafc"
}
}), "light");
});
test("system color mode support is exposed only for system-aware themes", () => {
assert.equal(themeSettingsSupportsSystemColorMode(cloneThemePresetSettings("workbench")), true);
assert.equal(themeSettingsSupportsSystemColorMode(cloneThemePresetSettings("blue")), false);
assert.equal(themeSettingsSupportsSystemColorMode(cloneThemePresetSettings("terminal")), false);
});
test("normalizeThemeSettings migrates legacy tint color into default color", () => {
const normalized = normalizeThemeSettings({
materials: {
tintColor: "#abc123"
}
});
assert.equal(normalized.materials.defaultColor, "#abc123");
assert.equal(Object.hasOwn(normalized.materials, "tintColor"), false);
});
test("normalizeThemeSettings migrates persisted legacy cinematic preset values", () => {
const legacyCinematic = cloneThemePresetSettings("cinematic");
delete legacyCinematic.colorMode;
delete legacyCinematic.modeColors;
delete legacyCinematic.materials.fillColors;
delete legacyCinematic.materials.overrideSourceColors;
delete legacyCinematic.materials.tintMode;
delete legacyCinematic.materials.emissiveIntensity;
legacyCinematic.materials.defaultColor = "#aeb9c3";
legacyCinematic.materials.tintStrength = 0.28;
legacyCinematic.materials.saturation = 0.42;
legacyCinematic.materials.contrast = 1.02;
legacyCinematic.materials.brightness = 0.94;
legacyCinematic.materials.roughness = 0.46;
legacyCinematic.materials.metalness = 0.02;
legacyCinematic.materials.clearcoat = 0.18;
legacyCinematic.materials.clearcoatRoughness = 0.34;
legacyCinematic.materials.opacity = 1;
legacyCinematic.materials.envMapIntensity = 0.58;
legacyCinematic.background.solidColor = "#050711";
legacyCinematic.background.linearStart = "#02040b";
legacyCinematic.background.linearEnd = "#252f47";
legacyCinematic.background.linearAngle = 90;
legacyCinematic.background.radialInner = "#171d30";
legacyCinematic.background.radialOuter = "#02040b";
legacyCinematic.floor.color = "#141a29";
legacyCinematic.floor.roughness = 0.62;
legacyCinematic.floor.reflectivity = 0.22;
legacyCinematic.floor.shadowOpacity = 0.24;
legacyCinematic.floor.horizonBlend = 0.28;
legacyCinematic.environment.enabled = true;
legacyCinematic.environment.intensity = 0.46;
legacyCinematic.environment.rotationY = -0.35;
legacyCinematic.lighting.toneMappingExposure = 1.2;
legacyCinematic.lighting.directional.color = "#f1f6fb";
legacyCinematic.lighting.directional.intensity = 2.45;
legacyCinematic.lighting.directional.position = { x: -190, y: 300, z: 210 };
legacyCinematic.lighting.spot.color = "#dbeafe";
legacyCinematic.lighting.spot.intensity = 1.34;
legacyCinematic.lighting.spot.angle = 0.72;
legacyCinematic.lighting.spot.position = { x: 160, y: 245, z: 126 };
legacyCinematic.lighting.point.color = "#8fb6d8";
legacyCinematic.lighting.point.intensity = 0.34;
legacyCinematic.lighting.point.position = { x: -260, y: 95, z: -220 };
legacyCinematic.lighting.ambient.color = "#1e293b";
legacyCinematic.lighting.ambient.intensity = 0.2;
legacyCinematic.lighting.hemisphere.skyColor = "#dbe7f3";
legacyCinematic.lighting.hemisphere.groundColor = "#070a14";
legacyCinematic.lighting.hemisphere.intensity = 0.68;
assert.deepEqual(normalizeThemeSettings(legacyCinematic), cloneThemePresetSettings("workbench"));
});
test("normalizeThemeSettings migrates previous cinematic preset values", () => {
const transitionalCinematic = cloneThemePresetSettings("cinematic");
delete transitionalCinematic.colorMode;
delete transitionalCinematic.modeColors;
delete transitionalCinematic.materials.fillColors;
delete transitionalCinematic.materials.overrideSourceColors;
transitionalCinematic.materials.defaultColor = "#aeb9c3";
transitionalCinematic.materials.tintStrength = 0.08;
transitionalCinematic.materials.saturation = 1;
transitionalCinematic.materials.contrast = 1.04;
transitionalCinematic.materials.brightness = 1.02;
transitionalCinematic.materials.roughness = 0.46;
transitionalCinematic.materials.metalness = 0.02;
transitionalCinematic.materials.clearcoat = 0.18;
transitionalCinematic.materials.clearcoatRoughness = 0.34;
transitionalCinematic.materials.opacity = 1;
transitionalCinematic.materials.envMapIntensity = 0.58;
transitionalCinematic.materials.emissiveIntensity = 0.06;
transitionalCinematic.background.solidColor = "#050711";
transitionalCinematic.background.linearStart = "#02040b";
transitionalCinematic.background.linearEnd = "#252f47";
transitionalCinematic.background.linearAngle = 90;
transitionalCinematic.background.radialInner = "#171d30";
transitionalCinematic.background.radialOuter = "#02040b";
transitionalCinematic.floor.color = "#141a29";
transitionalCinematic.floor.roughness = 0.62;
transitionalCinematic.floor.reflectivity = 0.06;
transitionalCinematic.floor.shadowOpacity = 0.24;
transitionalCinematic.floor.horizonBlend = 0.12;
transitionalCinematic.lighting.toneMappingExposure = 1.2;
transitionalCinematic.lighting.directional.color = "#f1f6fb";
transitionalCinematic.lighting.directional.intensity = 2.45;
transitionalCinematic.lighting.directional.position = { x: -190, y: 300, z: 210 };
transitionalCinematic.lighting.spot.color = "#dbeafe";
transitionalCinematic.lighting.spot.intensity = 1.34;
transitionalCinematic.lighting.spot.angle = 0.72;
transitionalCinematic.lighting.spot.position = { x: 160, y: 245, z: 126 };
transitionalCinematic.lighting.point.color = "#8fb6d8";
transitionalCinematic.lighting.point.intensity = 0.34;
transitionalCinematic.lighting.point.position = { x: -260, y: 95, z: -220 };
transitionalCinematic.lighting.ambient.color = "#1e293b";
transitionalCinematic.lighting.ambient.intensity = 0.2;
transitionalCinematic.lighting.hemisphere.skyColor = "#dbe7f3";
transitionalCinematic.lighting.hemisphere.groundColor = "#070a14";
transitionalCinematic.lighting.hemisphere.intensity = 0.68;
assert.deepEqual(normalizeThemeSettings(transitionalCinematic), cloneThemePresetSettings("workbench"));
});
test("normalizeThemeSettings migrates dim cinematic preset values", () => {
const dimCinematic = cloneThemePresetSettings("cinematic");
delete dimCinematic.colorMode;
delete dimCinematic.modeColors;
delete dimCinematic.materials.fillColors;
delete dimCinematic.materials.overrideSourceColors;
dimCinematic.materials.defaultColor = "#aeb9c3";
dimCinematic.materials.tintMode = "blend";
dimCinematic.materials.tintStrength = 0;
dimCinematic.materials.saturation = 1.34;
dimCinematic.materials.contrast = 1.02;
dimCinematic.materials.brightness = 0.82;
dimCinematic.materials.roughness = 0.76;
dimCinematic.materials.metalness = 0;
dimCinematic.materials.clearcoat = 0;
dimCinematic.materials.clearcoatRoughness = 0.72;
dimCinematic.materials.opacity = 1;
dimCinematic.materials.envMapIntensity = 0.08;
dimCinematic.materials.emissiveIntensity = 0.01;
dimCinematic.background.solidColor = "#0a0f18";
dimCinematic.background.linearStart = "#08111c";
dimCinematic.background.linearEnd = "#1f2c3d";
dimCinematic.background.linearAngle = 90;
dimCinematic.background.radialInner = "#182337";
dimCinematic.background.radialOuter = "#08111c";
dimCinematic.floor.color = "#121a24";
dimCinematic.floor.roughness = 0.86;
dimCinematic.floor.reflectivity = 0.06;
dimCinematic.floor.shadowOpacity = 0.24;
dimCinematic.floor.horizonBlend = 0.28;
dimCinematic.environment.enabled = false;
dimCinematic.environment.intensity = 0;
dimCinematic.environment.rotationY = -0.35;
dimCinematic.lighting.toneMappingExposure = 1.03;
dimCinematic.lighting.directional.color = "#f1f6fb";
dimCinematic.lighting.directional.intensity = 1.28;
dimCinematic.lighting.directional.position = { x: -190, y: 300, z: 210 };
dimCinematic.lighting.spot.color = "#dbeafe";
dimCinematic.lighting.spot.intensity = 0.18;
dimCinematic.lighting.spot.angle = 0.72;
dimCinematic.lighting.spot.position = { x: 160, y: 245, z: 126 };
dimCinematic.lighting.point.color = "#8fb6d8";
dimCinematic.lighting.point.intensity = 0.08;
dimCinematic.lighting.point.position = { x: -260, y: 95, z: -220 };
dimCinematic.lighting.ambient.color = "#1e293b";
dimCinematic.lighting.ambient.intensity = 0.42;
dimCinematic.lighting.hemisphere.skyColor = "#dbe7f3";
dimCinematic.lighting.hemisphere.groundColor = "#070a14";
dimCinematic.lighting.hemisphere.intensity = 0.92;
assert.deepEqual(normalizeThemeSettings(dimCinematic), cloneThemePresetSettings("workbench"));
});
test("normalizeThemeSettings preserves non-cinematic legacy material defaults", () => {
const legacyWorkbench = cloneThemePresetSettings("workbench");
delete legacyWorkbench.materials.tintMode;
delete legacyWorkbench.materials.emissiveIntensity;
const normalized = normalizeThemeSettings(legacyWorkbench);
assert.equal(normalized.materials.tintMode, "multiply");
assert.equal(normalized.materials.emissiveIntensity, 0);
assert.notDeepEqual(normalized, cloneThemePresetSettings("workbench"));
});
test("built-in theme presets preserve source colors by default", () => {
for (const preset of THEME_PRESETS) {
assert.equal(
preset.settings.materials.overrideSourceColors,
false,
`${preset.id} source color override default`
);
}
});
@@ -0,0 +1,127 @@
export const URDF_JOINT_ANIMATION_DURATION_MS = 840;
export const URDF_JOINT_ANIMATION_FOLLOW_MS = 240;
export const URDF_JOINT_ANIMATION_EPSILON = 0.001;
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clampUnit(value) {
return Math.min(Math.max(toFiniteNumber(value, 1), 0), 1);
}
function normalizedJointValueEntries(values) {
if (!values || typeof values !== "object") {
return [];
}
return Object.entries(values)
.map(([name, value]) => [String(name || "").trim(), toFiniteNumber(value, 0)])
.filter(([name]) => name)
.sort(([leftName], [rightName]) => leftName.localeCompare(rightName));
}
function wrappedJointSetHas(wrappedJointNames, name) {
if (!name || !wrappedJointNames) {
return false;
}
if (wrappedJointNames instanceof Set || wrappedJointNames instanceof Map) {
return wrappedJointNames.has(name);
}
return !!wrappedJointNames?.[name];
}
function wrapAngleDeltaDeg(deltaDeg) {
const numericDelta = toFiniteNumber(deltaDeg, 0);
const wrapped = ((((numericDelta + 180) % 360) + 360) % 360) - 180;
return wrapped === -180 && numericDelta > 0 ? 180 : wrapped;
}
function animatedJointDeltaDeg(name, currentValue, targetValue, wrappedJointNames) {
const numericCurrent = toFiniteNumber(currentValue, 0);
const numericTarget = toFiniteNumber(targetValue, numericCurrent);
if (!wrappedJointSetHas(wrappedJointNames, name)) {
return numericTarget - numericCurrent;
}
return wrapAngleDeltaDeg(numericTarget - numericCurrent);
}
export function easeUrdfJointAnimation(progress) {
const t = clampUnit(progress);
return (t * t * t) * (t * ((6 * t) - 15) + 10);
}
export function jointValueMapsClose(left, right, epsilon = URDF_JOINT_ANIMATION_EPSILON) {
const leftEntries = normalizedJointValueEntries(left);
const rightEntries = normalizedJointValueEntries(right);
if (leftEntries.length !== rightEntries.length) {
return false;
}
for (let index = 0; index < rightEntries.length; index += 1) {
const [leftName, leftValue] = leftEntries[index];
const [rightName, rightValue] = rightEntries[index];
if (leftName !== rightName || Math.abs(leftValue - rightValue) > epsilon) {
return false;
}
}
return true;
}
export function interpolateUrdfJointValues(
startValues,
targetValues,
progress,
epsilon = URDF_JOINT_ANIMATION_EPSILON,
wrappedJointNames = null
) {
const targetEntries = normalizedJointValueEntries(targetValues);
const startByName = new Map(normalizedJointValueEntries(startValues));
const easedProgress = easeUrdfJointAnimation(progress);
const values = {};
let done = true;
for (const [name, targetValue] of targetEntries) {
const startValue = startByName.has(name) ? startByName.get(name) : targetValue;
const deltaValue = animatedJointDeltaDeg(name, startValue, targetValue, wrappedJointNames);
const interpolatedValue = startValue + (deltaValue * easedProgress);
if (Math.abs(deltaValue * (1 - easedProgress)) <= epsilon || easedProgress >= 1) {
values[name] = targetValue;
} else {
values[name] = interpolatedValue;
done = false;
}
}
return { values, done };
}
export function advanceUrdfJointValues(
currentValues,
targetValues,
deltaMs,
smoothingMs = URDF_JOINT_ANIMATION_FOLLOW_MS,
epsilon = URDF_JOINT_ANIMATION_EPSILON,
wrappedJointNames = null
) {
const targetEntries = normalizedJointValueEntries(targetValues);
const currentByName = new Map(normalizedJointValueEntries(currentValues));
const safeDeltaMs = Math.max(toFiniteNumber(deltaMs, 0), 0);
const safeSmoothingMs = Math.max(toFiniteNumber(smoothingMs, URDF_JOINT_ANIMATION_FOLLOW_MS), 1);
const alpha = clampUnit(1 - Math.exp(-safeDeltaMs / safeSmoothingMs));
const values = {};
let done = true;
for (const [name, targetValue] of targetEntries) {
const currentValue = currentByName.has(name) ? currentByName.get(name) : targetValue;
const deltaValue = animatedJointDeltaDeg(name, currentValue, targetValue, wrappedJointNames);
const nextValue = currentValue + (deltaValue * alpha);
if (Math.abs(deltaValue * (1 - alpha)) <= epsilon || alpha >= 1) {
values[name] = targetValue;
} else {
values[name] = nextValue;
done = false;
}
}
return { values, done };
}
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
advanceUrdfJointValues,
easeUrdfJointAnimation,
interpolateUrdfJointValues,
jointValueMapsClose,
URDF_JOINT_ANIMATION_DURATION_MS,
URDF_JOINT_ANIMATION_FOLLOW_MS
} from "./jointAnimation.js";
function roundedMap(values) {
return Object.fromEntries(
Object.entries(values).map(([key, value]) => [key, Math.round(value * 1000) / 1000])
);
}
test("easeUrdfJointAnimation clamps progress to the animation range", () => {
assert.equal(easeUrdfJointAnimation(-1), 0);
assert.equal(easeUrdfJointAnimation(1.5), 1);
assert.equal(easeUrdfJointAnimation(0), 0);
assert.equal(easeUrdfJointAnimation(1), 1);
assert.equal(easeUrdfJointAnimation(0.5), 0.5);
assert.ok(easeUrdfJointAnimation(0.25) < 0.25);
assert.ok(easeUrdfJointAnimation(0.75) > 0.75);
});
test("URDF joint animations use a deliberate smooth duration", () => {
assert.equal(URDF_JOINT_ANIMATION_DURATION_MS, 840);
assert.equal(URDF_JOINT_ANIMATION_FOLLOW_MS, 240);
});
test("interpolateUrdfJointValues eases each target joint value", () => {
const halfway = interpolateUrdfJointValues(
{ shoulder: 0, elbow: 10 },
{ shoulder: 90, elbow: -10 },
0.5
);
assert.equal(halfway.done, false);
assert.deepEqual(roundedMap(halfway.values), {
elbow: 0,
shoulder: 45
});
});
test("interpolateUrdfJointValues uses the shortest wrapped path for continuous joints", () => {
const halfway = interpolateUrdfJointValues(
{ shoulder: -170 },
{ shoulder: 170 },
0.5,
undefined,
new Set(["shoulder"])
);
assert.equal(halfway.done, false);
assert.deepEqual(roundedMap(halfway.values), {
shoulder: -180
});
});
test("interpolateUrdfJointValues snaps to target values at completion", () => {
const result = interpolateUrdfJointValues(
{ shoulder: 89.9995, elbow: 10 },
{ shoulder: 90 },
1
);
assert.equal(result.done, true);
assert.deepEqual(result.values, { shoulder: 90 });
});
test("advanceUrdfJointValues follows moving targets without overshooting", () => {
const first = advanceUrdfJointValues({ shoulder: 0 }, { shoulder: 90 }, 16);
const redirected = advanceUrdfJointValues(first.values, { shoulder: 30 }, 16);
assert.equal(first.done, false);
assert.ok(first.values.shoulder > 0);
assert.ok(first.values.shoulder < 90);
assert.equal(redirected.done, false);
assert.ok(redirected.values.shoulder > first.values.shoulder);
assert.ok(redirected.values.shoulder < 30);
});
test("advanceUrdfJointValues snaps close values to the target", () => {
const result = advanceUrdfJointValues({ shoulder: 89.9995 }, { shoulder: 90 }, 16);
assert.equal(result.done, true);
assert.deepEqual(result.values, { shoulder: 90 });
});
test("advanceUrdfJointValues rotates continuous joints along the shortest path", () => {
const result = advanceUrdfJointValues(
{ shoulder: -170 },
{ shoulder: 170 },
16,
undefined,
undefined,
new Set(["shoulder"])
);
assert.equal(result.done, false);
assert.ok(result.values.shoulder < -170);
assert.ok(result.values.shoulder > -190);
});
test("jointValueMapsClose compares normalized finite joint values", () => {
assert.equal(jointValueMapsClose({ shoulder: "90" }, { shoulder: 90.0005 }), true);
assert.equal(jointValueMapsClose({ shoulder: 90, elbow: 0 }, { shoulder: 90 }), false);
assert.equal(jointValueMapsClose({ shoulder: 90 }, { shoulder: 91 }), false);
});
@@ -0,0 +1,740 @@
const IDENTITY_TRANSFORM = Object.freeze([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const CYLINDER_SEGMENTS = 32;
const SPHERE_WIDTH_SEGMENTS = 24;
const SPHERE_HEIGHT_SEGMENTS = 12;
function srgbToLinear(value) {
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
}
function parseHexColorToLinearRgb(value) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return null;
}
const expanded = normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
return [
srgbToLinear(Number.parseInt(expanded.slice(1, 3), 16) / 255),
srgbToLinear(Number.parseInt(expanded.slice(3, 5), 16) / 255),
srgbToLinear(Number.parseInt(expanded.slice(5, 7), 16) / 255)
];
}
function toTransformArray(value, fallback = IDENTITY_TRANSFORM) {
if (!Array.isArray(value) || value.length !== 16) {
return [...fallback];
}
return value.map((component, index) => Number.isFinite(Number(component)) ? Number(component) : fallback[index]);
}
function toVector3(value, fallback = [0, 0, 1]) {
if (!Array.isArray(value) || value.length < 3) {
return [...fallback];
}
return [
Number.isFinite(Number(value[0])) ? Number(value[0]) : fallback[0],
Number.isFinite(Number(value[1])) ? Number(value[1]) : fallback[1],
Number.isFinite(Number(value[2])) ? Number(value[2]) : fallback[2]
];
}
function normalizeVector(vector) {
const [x, y, z] = toVector3(vector, [0, 0, 1]);
const length = Math.hypot(x, y, z);
if (length <= 1e-9) {
return [0, 0, 1];
}
return [x / length, y / length, z / length];
}
export function multiplyTransforms(left, right) {
const a = toTransformArray(left);
const b = toTransformArray(right);
const product = new Array(16).fill(0);
for (let row = 0; row < 4; row += 1) {
for (let column = 0; column < 4; column += 1) {
let total = 0;
for (let offset = 0; offset < 4; offset += 1) {
total += a[(row * 4) + offset] * b[(offset * 4) + column];
}
product[(row * 4) + column] = total;
}
}
return product;
}
export function translationTransform(x, y, z) {
return [
1, 0, 0, Number.isFinite(Number(x)) ? Number(x) : 0,
0, 1, 0, Number.isFinite(Number(y)) ? Number(y) : 0,
0, 0, 1, Number.isFinite(Number(z)) ? Number(z) : 0,
0, 0, 0, 1
];
}
export function rotationTransformFromRpy(roll, pitch, yaw) {
const safeRoll = Number.isFinite(Number(roll)) ? Number(roll) : 0;
const safePitch = Number.isFinite(Number(pitch)) ? Number(pitch) : 0;
const safeYaw = Number.isFinite(Number(yaw)) ? Number(yaw) : 0;
const sr = Math.sin(safeRoll);
const cr = Math.cos(safeRoll);
const sp = Math.sin(safePitch);
const cp = Math.cos(safePitch);
const sy = Math.sin(safeYaw);
const cy = Math.cos(safeYaw);
return [
cy * cp, (cy * sp * sr) - (sy * cr), (cy * sp * cr) + (sy * sr), 0,
sy * cp, (sy * sp * sr) + (cy * cr), (sy * sp * cr) - (cy * sr), 0,
-sp, cp * sr, cp * cr, 0,
0, 0, 0, 1
];
}
export function poseTransformFromXyzRpy(values) {
const poseValues = Array.isArray(values) ? values : [];
return multiplyTransforms(
translationTransform(poseValues[0], poseValues[1], poseValues[2]),
rotationTransformFromRpy(poseValues[3], poseValues[4], poseValues[5])
);
}
export function axisAngleTransform(axis, angleRad) {
const [x, y, z] = normalizeVector(axis);
const cosine = Math.cos(angleRad);
const sine = Math.sin(angleRad);
const oneMinusCosine = 1 - cosine;
return [
cosine + (x * x * oneMinusCosine), (x * y * oneMinusCosine) - (z * sine), (x * z * oneMinusCosine) + (y * sine), 0,
(y * x * oneMinusCosine) + (z * sine), cosine + (y * y * oneMinusCosine), (y * z * oneMinusCosine) - (x * sine), 0,
(z * x * oneMinusCosine) - (y * sine), (z * y * oneMinusCosine) + (x * sine), cosine + (z * z * oneMinusCosine), 0,
0, 0, 0, 1
];
}
export function transformPoint(transform, point) {
const matrix = toTransformArray(transform);
const [x, y, z] = toVector3(point, [0, 0, 0]);
return [
(matrix[0] * x) + (matrix[1] * y) + (matrix[2] * z) + matrix[3],
(matrix[4] * x) + (matrix[5] * y) + (matrix[6] * z) + matrix[7],
(matrix[8] * x) + (matrix[9] * y) + (matrix[10] * z) + matrix[11]
];
}
export function invertRigidTransform(transform) {
const matrix = toTransformArray(transform);
return [
matrix[0], matrix[4], matrix[8], -((matrix[0] * matrix[3]) + (matrix[4] * matrix[7]) + (matrix[8] * matrix[11])),
matrix[1], matrix[5], matrix[9], -((matrix[1] * matrix[3]) + (matrix[5] * matrix[7]) + (matrix[9] * matrix[11])),
matrix[2], matrix[6], matrix[10], -((matrix[2] * matrix[3]) + (matrix[6] * matrix[7]) + (matrix[10] * matrix[11])),
0, 0, 0, 1
];
}
export function transformBounds(bounds, transform) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [0, 0, 0];
const corners = [
[min[0], min[1], min[2]],
[min[0], min[1], max[2]],
[min[0], max[1], min[2]],
[min[0], max[1], max[2]],
[max[0], min[1], min[2]],
[max[0], min[1], max[2]],
[max[0], max[1], min[2]],
[max[0], max[1], max[2]]
];
const transformed = corners.map((corner) => transformPoint(transform, corner));
const xs = transformed.map((point) => point[0]);
const ys = transformed.map((point) => point[1]);
const zs = transformed.map((point) => point[2]);
return {
min: [Math.min(...xs), Math.min(...ys), Math.min(...zs)],
max: [Math.max(...xs), Math.max(...ys), Math.max(...zs)]
};
}
export function mergeBounds(boundsList) {
const normalized = (Array.isArray(boundsList) ? boundsList : []).filter(Boolean);
if (!normalized.length) {
return {
min: [0, 0, 0],
max: [0, 0, 0]
};
}
const xs = normalized.flatMap((bounds) => [bounds.min[0], bounds.max[0]]);
const ys = normalized.flatMap((bounds) => [bounds.min[1], bounds.max[1]]);
const zs = normalized.flatMap((bounds) => [bounds.min[2], bounds.max[2]]);
return {
min: [Math.min(...xs), Math.min(...ys), Math.min(...zs)],
max: [Math.max(...xs), Math.max(...ys), Math.max(...zs)]
};
}
export function buildDefaultUrdfJointValues(urdfData) {
const joints = Array.isArray(urdfData?.joints) ? urdfData.joints : [];
return Object.fromEntries(
joints
.filter((joint) => String(joint?.type || "") !== "fixed" && !joint?.mimic)
.map((joint) => [String(joint?.name || ""), Number(joint?.defaultValueDeg) || 0])
.filter(([name]) => name)
);
}
export function clampJointValueDeg(joint, valueDeg) {
const defaultValue = Number(joint?.defaultValueDeg) || 0;
const jointType = String(joint?.type || "");
if (jointType === "fixed") {
return defaultValue;
}
const numericValue = Number.isFinite(Number(valueDeg)) ? Number(valueDeg) : defaultValue;
if (jointType === "continuous") {
return numericValue;
}
const minValue = Number.isFinite(Number(joint?.minValueDeg)) ? Number(joint.minValueDeg) : numericValue;
const maxValue = Number.isFinite(Number(joint?.maxValueDeg)) ? Number(joint.maxValueDeg) : numericValue;
return Math.min(Math.max(numericValue, minValue), Math.max(minValue, maxValue));
}
function isAngularJoint(joint) {
const jointType = String(joint?.type || "fixed");
return jointType === "continuous" || jointType === "revolute";
}
function jointValueToNative(joint, value) {
const clampedValue = clampJointValueDeg(joint, value);
return isAngularJoint(joint) ? (clampedValue * Math.PI) / 180 : clampedValue;
}
function nativeToJointValue(joint, value) {
const numericValue = Number.isFinite(Number(value)) ? Number(value) : 0;
return isAngularJoint(joint) ? (numericValue * 180) / Math.PI : numericValue;
}
function translationAlongAxisTransform(axis, distance) {
const [x, y, z] = normalizeVector(axis);
const safeDistance = Number.isFinite(Number(distance)) ? Number(distance) : 0;
return [
1, 0, 0, x * safeDistance,
0, 1, 0, y * safeDistance,
0, 0, 1, z * safeDistance,
0, 0, 0, 1
];
}
export function posedJointLocalTransform(joint, valueDeg) {
const jointType = String(joint?.type || "fixed");
const originTransform = toTransformArray(joint?.originTransform);
const preMotionTransform = toTransformArray(joint?.preMotionTransform, originTransform);
const postMotionTransform = toTransformArray(joint?.postMotionTransform);
if (jointType === "fixed") {
return multiplyTransforms(preMotionTransform, postMotionTransform);
}
const axis = toVector3(joint?.axis ?? joint?.axisInJointFrame ?? joint?.axisInParentFrame, [0, 0, 1]);
let motionTransform;
if (jointType === "prismatic") {
motionTransform = translationAlongAxisTransform(axis, clampJointValueDeg(joint, valueDeg));
} else {
const angleRad = (clampJointValueDeg(joint, valueDeg) * Math.PI) / 180;
// URDF axes are defined in the joint frame; SDF axes are converted into the
// joint frame at parse time. In both cases, apply the static parent-to-joint
// transform before the animated axis motion.
motionTransform = axisAngleTransform(axis, angleRad);
}
return multiplyTransforms(multiplyTransforms(preMotionTransform, motionTransform), postMotionTransform);
}
function resolveJointValue(joint, jointByName, jointValuesByName, resolving = new Set()) {
const jointName = String(joint?.name || "");
if (!joint?.mimic) {
return clampJointValueDeg(joint, jointValuesByName?.[jointName]);
}
if (resolving.has(jointName)) {
return clampJointValueDeg(joint, joint?.defaultValueDeg);
}
resolving.add(jointName);
const mimic = joint.mimic;
const masterJoint = jointByName.get(String(mimic.joint || ""));
const masterValue = masterJoint
? resolveJointValue(masterJoint, jointByName, jointValuesByName, resolving)
: Number(jointValuesByName?.[mimic.joint]) || 0;
resolving.delete(jointName);
const masterNativeValue = masterJoint ? jointValueToNative(masterJoint, masterValue) : masterValue;
const multiplier = Number.isFinite(Number(mimic.multiplier)) ? Number(mimic.multiplier) : 1;
const offset = Number.isFinite(Number(mimic.offset)) ? Number(mimic.offset) : 0;
return clampJointValueDeg(joint, nativeToJointValue(joint, (multiplier * masterNativeValue) + offset));
}
export function solveUrdfLinkWorldTransforms(urdfData, jointValuesByName = {}) {
const rootLink = String(urdfData?.rootLink || "");
const rootWorldTransform = toTransformArray(urdfData?.rootWorldTransform);
const joints = Array.isArray(urdfData?.joints) ? urdfData.joints : [];
const jointByName = new Map(joints.map((joint) => [String(joint?.name || ""), joint]).filter(([name]) => name));
const linkTransforms = new Map();
if (!rootLink) {
return linkTransforms;
}
const jointsByParent = new Map();
for (const joint of joints) {
const parentLink = String(joint?.parentLink || "");
if (!parentLink) {
continue;
}
const current = jointsByParent.get(parentLink) || [];
current.push(joint);
jointsByParent.set(parentLink, current);
}
const visit = (linkName, worldTransform) => {
linkTransforms.set(linkName, worldTransform);
const childJoints = jointsByParent.get(linkName) || [];
for (const joint of childJoints) {
const childLink = String(joint?.childLink || "");
if (!childLink) {
continue;
}
const jointValueDeg = resolveJointValue(joint, jointByName, jointValuesByName);
const childWorldTransform = multiplyTransforms(worldTransform, posedJointLocalTransform(joint, jointValueDeg));
visit(childLink, childWorldTransform);
}
};
visit(rootLink, rootWorldTransform);
return linkTransforms;
}
export function linkOriginInFrame(urdfData, jointValuesByName, linkName, frameLinkName) {
const normalizedLinkName = String(linkName || "").trim();
const normalizedFrameLinkName = String(frameLinkName || "").trim();
if (!normalizedLinkName || !normalizedFrameLinkName) {
return null;
}
const linkWorldTransforms = solveUrdfLinkWorldTransforms(urdfData, jointValuesByName);
const linkWorldTransform = linkWorldTransforms.get(normalizedLinkName);
const frameWorldTransform = linkWorldTransforms.get(normalizedFrameLinkName);
if (!linkWorldTransform || !frameWorldTransform) {
return null;
}
return transformPoint(
invertRigidTransform(frameWorldTransform),
transformPoint(linkWorldTransform, [0, 0, 0])
);
}
export function rootPointInFrame(urdfData, jointValuesByName, point, frameLinkName) {
const normalizedFrameLinkName = String(frameLinkName || "").trim();
if (!normalizedFrameLinkName) {
return null;
}
const linkWorldTransforms = solveUrdfLinkWorldTransforms(urdfData, jointValuesByName);
const frameWorldTransform = linkWorldTransforms.get(normalizedFrameLinkName);
if (!frameWorldTransform) {
return null;
}
return transformPoint(invertRigidTransform(frameWorldTransform), point);
}
function resolveVisualMesh(meshesByUrl, meshUrl, partFileRef) {
const lookupKey = meshUrl || partFileRef;
if (!lookupKey) {
return null;
}
if (meshesByUrl instanceof Map) {
return meshesByUrl.get(lookupKey) || null;
}
if (meshesByUrl && typeof meshesByUrl === "object") {
return meshesByUrl[lookupKey] || null;
}
return null;
}
function primitiveBounds(min, max) {
return {
min,
max
};
}
function primitiveMesh(vertices, normals, indices, bounds) {
return {
vertices: new Float32Array(vertices),
normals: new Float32Array(normals),
indices: new Uint32Array(indices),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds,
has_source_colors: false
};
}
function buildBoxPrimitiveMesh(size) {
const [sx, sy, sz] = toVector3(size, [1, 1, 1]);
const hx = sx / 2;
const hy = sy / 2;
const hz = sz / 2;
const vertices = [];
const normals = [];
const indices = [];
const faces = [
[[1, 0, 0], [[hx, -hy, -hz], [hx, hy, -hz], [hx, hy, hz], [hx, -hy, hz]]],
[[-1, 0, 0], [[-hx, hy, -hz], [-hx, -hy, -hz], [-hx, -hy, hz], [-hx, hy, hz]]],
[[0, 1, 0], [[-hx, hy, -hz], [hx, hy, -hz], [hx, hy, hz], [-hx, hy, hz]]],
[[0, -1, 0], [[hx, -hy, -hz], [-hx, -hy, -hz], [-hx, -hy, hz], [hx, -hy, hz]]],
[[0, 0, 1], [[-hx, -hy, hz], [hx, -hy, hz], [hx, hy, hz], [-hx, hy, hz]]],
[[0, 0, -1], [[-hx, hy, -hz], [hx, hy, -hz], [hx, -hy, -hz], [-hx, -hy, -hz]]]
];
for (const [normal, corners] of faces) {
const offset = vertices.length / 3;
for (const corner of corners) {
vertices.push(...corner);
normals.push(...normal);
}
indices.push(offset, offset + 1, offset + 2, offset, offset + 2, offset + 3);
}
return primitiveMesh(vertices, normals, indices, primitiveBounds([-hx, -hy, -hz], [hx, hy, hz]));
}
function buildCylinderPrimitiveMesh(radius, length) {
const r = Number.isFinite(Number(radius)) ? Number(radius) : 0.5;
const halfLength = (Number.isFinite(Number(length)) ? Number(length) : 1) / 2;
const vertices = [];
const normals = [];
const indices = [];
for (let index = 0; index < CYLINDER_SEGMENTS; index += 1) {
const nextIndex = (index + 1) % CYLINDER_SEGMENTS;
const angle = (index / CYLINDER_SEGMENTS) * Math.PI * 2;
const nextAngle = (nextIndex / CYLINDER_SEGMENTS) * Math.PI * 2;
const x0 = Math.cos(angle) * r;
const y0 = Math.sin(angle) * r;
const x1 = Math.cos(nextAngle) * r;
const y1 = Math.sin(nextAngle) * r;
const base = vertices.length / 3;
vertices.push(x0, y0, -halfLength, x1, y1, -halfLength, x1, y1, halfLength, x0, y0, halfLength);
normals.push(Math.cos(angle), Math.sin(angle), 0, Math.cos(nextAngle), Math.sin(nextAngle), 0, Math.cos(nextAngle), Math.sin(nextAngle), 0, Math.cos(angle), Math.sin(angle), 0);
indices.push(base, base + 1, base + 2, base, base + 2, base + 3);
const topBase = vertices.length / 3;
vertices.push(0, 0, halfLength, x0, y0, halfLength, x1, y1, halfLength);
normals.push(0, 0, 1, 0, 0, 1, 0, 0, 1);
indices.push(topBase, topBase + 1, topBase + 2);
const bottomBase = vertices.length / 3;
vertices.push(0, 0, -halfLength, x1, y1, -halfLength, x0, y0, -halfLength);
normals.push(0, 0, -1, 0, 0, -1, 0, 0, -1);
indices.push(bottomBase, bottomBase + 1, bottomBase + 2);
}
return primitiveMesh(vertices, normals, indices, primitiveBounds([-r, -r, -halfLength], [r, r, halfLength]));
}
function buildSpherePrimitiveMesh(radius) {
const r = Number.isFinite(Number(radius)) ? Number(radius) : 0.5;
const vertices = [];
const normals = [];
const indices = [];
for (let lat = 0; lat <= SPHERE_HEIGHT_SEGMENTS; lat += 1) {
const theta = (lat / SPHERE_HEIGHT_SEGMENTS) * Math.PI;
const sinTheta = Math.sin(theta);
const cosTheta = Math.cos(theta);
for (let lon = 0; lon <= SPHERE_WIDTH_SEGMENTS; lon += 1) {
const phi = (lon / SPHERE_WIDTH_SEGMENTS) * Math.PI * 2;
const x = Math.cos(phi) * sinTheta;
const y = Math.sin(phi) * sinTheta;
const z = cosTheta;
vertices.push(x * r, y * r, z * r);
normals.push(x, y, z);
}
}
const rowWidth = SPHERE_WIDTH_SEGMENTS + 1;
for (let lat = 0; lat < SPHERE_HEIGHT_SEGMENTS; lat += 1) {
for (let lon = 0; lon < SPHERE_WIDTH_SEGMENTS; lon += 1) {
const first = (lat * rowWidth) + lon;
const second = first + rowWidth;
indices.push(first, second, first + 1, second, second + 1, first + 1);
}
}
return primitiveMesh(vertices, normals, indices, primitiveBounds([-r, -r, -r], [r, r, r]));
}
function buildUrdfPrimitiveMesh(primitive) {
const type = String(primitive?.type || "").trim().toLowerCase();
if (type === "box") {
return buildBoxPrimitiveMesh(primitive?.size);
}
if (type === "cylinder") {
return buildCylinderPrimitiveMesh(primitive?.radius, primitive?.length);
}
if (type === "sphere") {
return buildSpherePrimitiveMesh(primitive?.radius);
}
return null;
}
function resolveUrdfVisuals(urdfData, meshesByUrl) {
const links = Array.isArray(urdfData?.links) ? urdfData.links : [];
const resolvedVisuals = [];
for (const link of links) {
const linkName = String(link?.name || "");
const visuals = Array.isArray(link?.visuals) ? link.visuals : [];
for (const visual of visuals) {
const meshUrl = String(visual?.meshUrl || "");
const partFileRef = String(visual?.partFileRef || "");
const partMesh = visual?.primitive
? buildUrdfPrimitiveMesh(visual.primitive)
: resolveVisualMesh(meshesByUrl, meshUrl, partFileRef);
if (!partMesh) {
continue;
}
resolvedVisuals.push({
linkName,
meshUrl,
partFileRef,
visual,
partMesh
});
}
}
return resolvedVisuals;
}
export function buildUrdfMeshGeometry(urdfData, meshesByUrl, options = {}) {
const resolvedVisuals = resolveUrdfVisuals(urdfData, meshesByUrl);
const lightweightGeometry = options?.lightweight === true || options?.mode === "source-parts";
if (lightweightGeometry) {
const parts = [];
let hasAuthoredDisplayColors = false;
for (let visualIndex = 0; visualIndex < resolvedVisuals.length; visualIndex += 1) {
const { linkName, meshUrl, partFileRef, partMesh, visual } = resolvedVisuals[visualIndex];
const sourceVertices = partMesh.vertices || new Float32Array(0);
const sourceIndices = partMesh.indices || new Uint32Array(0);
const vertexCount = Math.floor(sourceVertices.length / 3);
const triangleCount = Math.floor(sourceIndices.length / 3);
const authoredVisualColor = String(visual?.color || "").trim();
const visualColor = authoredVisualColor;
const visualRgb = parseHexColorToLinearRgb(visualColor);
const partHasSourceColors = !!visualRgb || urdfMeshHasSourceColors(partMesh);
hasAuthoredDisplayColors ||= urdfVisualHasDisplayColors(visualColor, partMesh);
const partLabel = String(visual?.label || visual?.instanceId || visual?.id || meshUrl || partFileRef).trim();
parts.push({
id: String(visual?.id || `${linkName}:${meshUrl || partFileRef}`),
name: partLabel,
label: partLabel,
occurrenceId: String(visual?.occurrenceId || "").trim(),
instanceId: String(visual?.instanceId || "").trim(),
color: visualColor,
meshUrl,
partFileRef,
linkName,
localTransform: toTransformArray(visual?.localTransform),
sourceBounds: partMesh.bounds,
bounds: partMesh.bounds,
transform: [...IDENTITY_TRANSFORM],
hasSourceColors: partHasSourceColors,
sourceMesh: partMesh,
sourceMeshKey: String(meshUrl || partFileRef || visual?.id || `visual:${visualIndex + 1}`),
vertexOffset: 0,
vertexCount,
triangleOffset: 0,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0
});
}
return {
vertices: new Float32Array([0, 0, 0, 0, 0, 0, 0, 0, 0]),
indices: new Uint32Array([0, 1, 2]),
normals: new Float32Array([0, 0, 1, 0, 0, 1, 0, 0, 1]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: mergeBounds(parts.map((part) => part.bounds)),
parts,
has_source_colors: hasAuthoredDisplayColors,
lightweightGeometry: true,
geometrySource: {
type: "urdf-source-parts",
urdfData,
meshesByUrl
}
};
}
let totalVertexCount = 0;
let totalIndexCount = 0;
let hasAuthoredDisplayColors = false;
for (const resolvedVisual of resolvedVisuals) {
const partMesh = resolvedVisual.partMesh;
totalVertexCount += Math.floor((partMesh.vertices?.length || 0) / 3);
totalIndexCount += partMesh.indices?.length || 0;
const visualColor = String(resolvedVisual.visual?.color || "").trim();
hasAuthoredDisplayColors ||= urdfVisualHasDisplayColors(visualColor, partMesh);
}
const hasSourceColors = hasAuthoredDisplayColors;
const vertices = new Float32Array(totalVertexCount * 3);
const normals = new Float32Array(totalVertexCount * 3);
const indices = new Uint32Array(totalIndexCount);
const colors = hasSourceColors ? new Float32Array(totalVertexCount * 3).fill(1) : new Float32Array(0);
const parts = [];
let vertexOffset = 0;
let indexOffset = 0;
for (let visualIndex = 0; visualIndex < resolvedVisuals.length; visualIndex += 1) {
const resolvedVisual = resolvedVisuals[visualIndex];
const { linkName, meshUrl, partFileRef, partMesh, visual } = resolvedVisual;
const sourceVertices = partMesh.vertices || new Float32Array(0);
const sourceNormals = partMesh.normals || new Float32Array(0);
const sourceColors = partMesh.colors || new Float32Array(0);
const sourceIndices = partMesh.indices || new Uint32Array(0);
const partVertexOffset = vertexOffset;
const partTriangleOffset = Math.floor(indexOffset / 3);
const vertexCount = Math.floor(sourceVertices.length / 3);
const triangleCount = Math.floor(sourceIndices.length / 3);
const authoredVisualColor = String(visual?.color || "").trim();
const visualColor = authoredVisualColor;
const visualRgb = parseHexColorToLinearRgb(visualColor);
const partHasSourceColors = !!visualRgb || urdfMeshHasSourceColors(partMesh);
vertices.set(sourceVertices, partVertexOffset * 3);
if (sourceNormals.length === sourceVertices.length) {
normals.set(sourceNormals, partVertexOffset * 3);
}
if (hasSourceColors && partHasSourceColors) {
if (visualRgb) {
for (let colorIndex = 0; colorIndex < vertexCount; colorIndex += 1) {
const offset = (partVertexOffset + colorIndex) * 3;
colors[offset] = visualRgb[0];
colors[offset + 1] = visualRgb[1];
colors[offset + 2] = visualRgb[2];
}
} else if (sourceColors.length === sourceVertices.length) {
colors.set(sourceColors, partVertexOffset * 3);
}
}
for (let index = 0; index < sourceIndices.length; index += 1) {
indices[indexOffset + index] = sourceIndices[index] + partVertexOffset;
}
const partLabel = String(visual?.label || visual?.instanceId || visual?.id || meshUrl || partFileRef).trim();
parts.push({
id: String(visual?.id || `${linkName}:${meshUrl || partFileRef}`),
name: partLabel,
label: partLabel,
occurrenceId: String(visual?.occurrenceId || "").trim(),
instanceId: String(visual?.instanceId || "").trim(),
color: visualColor,
meshUrl,
partFileRef,
linkName,
localTransform: toTransformArray(visual?.localTransform),
sourceBounds: partMesh.bounds,
bounds: partMesh.bounds,
transform: [...IDENTITY_TRANSFORM],
hasSourceColors: partHasSourceColors,
vertexOffset: partVertexOffset,
vertexCount,
triangleOffset: partTriangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0
});
vertexOffset += vertexCount;
indexOffset += sourceIndices.length;
}
return {
vertices,
indices,
normals,
colors,
edge_indices: new Uint32Array(0),
bounds: mergeBounds(parts.map((part) => part.bounds)),
parts,
has_source_colors: hasSourceColors
};
}
function urdfMeshHasSourceColors(partMesh) {
return !!partMesh?.has_source_colors &&
partMesh.colors?.length > 0 &&
partMesh.colors.length === partMesh.vertices?.length;
}
function urdfVisualHasDisplayColors(visualColor, partMesh) {
return !!parseHexColorToLinearRgb(visualColor) || (!visualColor && urdfMeshHasSourceColors(partMesh));
}
export function poseUrdfMeshData(urdfData, meshData, jointValuesByName = {}, linkWorldTransformOverrides = null) {
const linkWorldTransforms = solveUrdfLinkWorldTransforms(urdfData, jointValuesByName);
if (linkWorldTransformOverrides instanceof Map) {
for (const [linkName, transform] of linkWorldTransformOverrides.entries()) {
linkWorldTransforms.set(String(linkName || ""), toTransformArray(transform));
}
} else if (linkWorldTransformOverrides && typeof linkWorldTransformOverrides === "object") {
for (const [linkName, transform] of Object.entries(linkWorldTransformOverrides)) {
linkWorldTransforms.set(String(linkName || ""), toTransformArray(transform));
}
}
const sourceParts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const geometrySource = meshData?.geometrySource && typeof meshData.geometrySource === "object"
? meshData.geometrySource
: meshData;
const posedParts = sourceParts.map((part) => {
const linkWorldTransform = linkWorldTransforms.get(String(part?.linkName || "")) || [...IDENTITY_TRANSFORM];
const localTransform = toTransformArray(part?.localTransform);
const worldTransform = multiplyTransforms(linkWorldTransform, localTransform);
const sourceBounds = part?.sourceBounds || part?.bounds;
return {
...part,
transform: worldTransform,
bounds: transformBounds(sourceBounds, worldTransform)
};
});
return {
meshData: {
...meshData,
geometrySource,
bounds: mergeBounds(posedParts.map((part) => part.bounds)),
parts: posedParts
},
linkWorldTransforms
};
}
export function applyUrdfPoseToMeshData(urdfData, meshData, jointValuesByName = {}, linkWorldTransformOverrides = null) {
const posed = poseUrdfMeshData(urdfData, meshData, jointValuesByName, linkWorldTransformOverrides);
if (!meshData || typeof meshData !== "object") {
return posed;
}
meshData.geometrySource = posed.meshData.geometrySource || meshData.geometrySource || meshData;
meshData.bounds = posed.meshData.bounds;
meshData.parts = posed.meshData.parts;
return {
...posed,
meshData
};
}
export function buildUrdfMeshData(urdfData, meshesByUrl, jointValuesByName = {}) {
return poseUrdfMeshData(
urdfData,
buildUrdfMeshGeometry(urdfData, meshesByUrl),
jointValuesByName
);
}
@@ -0,0 +1,608 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
applyUrdfPoseToMeshData,
buildDefaultUrdfJointValues,
buildUrdfMeshGeometry,
buildUrdfMeshData,
clampJointValueDeg,
linkOriginInFrame,
poseUrdfMeshData,
posedJointLocalTransform,
rootPointInFrame,
solveUrdfLinkWorldTransforms,
transformPoint
} from "./kinematics.js";
function translationTransform(x, y, z) {
return [
1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1
];
}
function rotationZTransform(angleDeg) {
const angleRad = (angleDeg * Math.PI) / 180;
const cosine = Math.cos(angleRad);
const sine = Math.sin(angleRad);
return [
cosine, -sine, 0, 0,
sine, cosine, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
];
}
function partMesh(bounds, color = null) {
const vertices = new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]);
const colors = color
? new Float32Array([
...color,
...color,
...color
])
: new Float32Array(0);
return {
vertices,
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2]),
bounds,
colors,
has_source_colors: colors.length === vertices.length
};
}
function rounded(values) {
return Array.from(values).map((value) => {
const roundedValue = Math.round(value * 1000) / 1000;
return Object.is(roundedValue, -0) ? 0 : roundedValue;
});
}
function srgbToLinear(value) {
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
}
function linearHexTriplet(hexColor) {
return [
srgbToLinear(Number.parseInt(hexColor.slice(1, 3), 16) / 255),
srgbToLinear(Number.parseInt(hexColor.slice(3, 5), 16) / 255),
srgbToLinear(Number.parseInt(hexColor.slice(5, 7), 16) / 255)
];
}
function repeatedTriplet(triplet) {
return [
...triplet,
...triplet,
...triplet
];
}
function sampleUrdf() {
return {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{
name: "base_link",
visuals: [
{
id: "base_link:base",
label: "base",
instanceId: "base",
partFileRef: "base-part",
color: "#2b2f33",
localTransform: translationTransform(0, 0, 0)
}
]
},
{
name: "arm_link",
visuals: [
{
id: "arm_link:arm",
label: "arm",
instanceId: "arm",
partFileRef: "arm-part",
color: "#a4abb3",
localTransform: translationTransform(0, 0, 0)
}
]
},
{
name: "tool_link",
visuals: [
{
id: "tool_link:tool",
label: "tool",
instanceId: "tool",
partFileRef: "tool-part",
color: "#a4abb3",
localTransform: translationTransform(0, 0, 0)
}
]
}
],
joints: [
{
name: "base_to_arm",
type: "continuous",
parentLink: "base_link",
childLink: "arm_link",
originTransform: translationTransform(10, 0, 0),
axisInJointFrame: [0, 0, 1],
defaultValueDeg: 0,
minValueDeg: -180,
maxValueDeg: 180
},
{
name: "arm_to_tool",
type: "fixed",
parentLink: "arm_link",
childLink: "tool_link",
originTransform: translationTransform(0, 5, 0),
axisInJointFrame: [],
defaultValueDeg: 0,
minValueDeg: 0,
maxValueDeg: 0
},
{
name: "limited_joint",
type: "revolute",
parentLink: "tool_link",
childLink: "aux_link",
originTransform: translationTransform(0, 0, 0),
axisInJointFrame: [1, 0, 0],
defaultValueDeg: 0,
minValueDeg: -45,
maxValueDeg: 60
}
]
};
}
const PART_MESHES = new Map([
["base-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] })],
["arm-part", partMesh({ min: [0, 0, 0], max: [2, 1, 0] })],
["tool-part", partMesh({ min: [0, 0, 0], max: [1, 2, 0] })]
]);
test("zero-pose solving reproduces authored default transforms", () => {
const linkWorldTransforms = solveUrdfLinkWorldTransforms(sampleUrdf(), buildDefaultUrdfJointValues(sampleUrdf()));
assert.deepEqual(transformPoint(linkWorldTransforms.get("arm_link"), [0, 0, 0]), [10, 0, 0]);
assert.deepEqual(transformPoint(linkWorldTransforms.get("tool_link"), [0, 0, 0]), [10, 5, 0]);
});
test("rotating the shoulder changes only the shoulder subtree", () => {
const linkWorldTransforms = solveUrdfLinkWorldTransforms(sampleUrdf(), { base_to_arm: 90 });
assert.deepEqual(transformPoint(linkWorldTransforms.get("base_link"), [0, 0, 0]), [0, 0, 0]);
assert.deepEqual(transformPoint(linkWorldTransforms.get("arm_link"), [0, 0, 0]), [10, 0, 0]);
assert.deepEqual(transformPoint(linkWorldTransforms.get("tool_link"), [0, 0, 0]).map((value) => Math.round(value * 1000) / 1000), [5, 0, 0]);
});
test("link origin can be expressed in another link frame", () => {
const urdf = sampleUrdf();
assert.deepEqual(
linkOriginInFrame(urdf, { base_to_arm: 0 }, "tool_link", "base_link").map((value) => Math.round(value * 1000) / 1000),
[10, 5, 0]
);
assert.deepEqual(
linkOriginInFrame(urdf, { base_to_arm: 90 }, "tool_link", "arm_link").map((value) => Math.round(value * 1000) / 1000),
[0, 5, 0]
);
});
test("root-frame points can be expressed in another link frame", () => {
const urdf = sampleUrdf();
assert.deepEqual(
rounded(rootPointInFrame(urdf, { base_to_arm: 0 }, [10, 6, 0], "arm_link")),
[0, 6, 0]
);
assert.deepEqual(
rounded(rootPointInFrame(urdf, { base_to_arm: 90 }, [5, 0, 0], "arm_link")),
[0, 5, 0]
);
});
test("fixed joints do not create default controls or motion", () => {
const defaults = buildDefaultUrdfJointValues(sampleUrdf());
assert.equal(Object.hasOwn(defaults, "arm_to_tool"), false);
assert.equal(clampJointValueDeg(sampleUrdf().joints[1], 25), 0);
});
test("joint clamping respects revolute and continuous limits", () => {
assert.equal(clampJointValueDeg(sampleUrdf().joints[0], 270), 270);
assert.equal(clampJointValueDeg(sampleUrdf().joints[0], -240), -240);
assert.equal(clampJointValueDeg(sampleUrdf().joints[2], 90), 60);
assert.equal(clampJointValueDeg(sampleUrdf().joints[2], -90), -45);
});
test("prismatic mimic joints follow a revolute master in native URDF units", () => {
const urdf = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{ name: "base_link", visuals: [] },
{ name: "driver_link", visuals: [] },
{ name: "right_slider", visuals: [] },
{ name: "left_slider", visuals: [] }
],
joints: [
{
name: "driver_joint",
type: "revolute",
parentLink: "base_link",
childLink: "driver_link",
originTransform: translationTransform(0, 0, 0),
axisInJointFrame: [0, 0, 1],
defaultValueDeg: 0,
minValueDeg: 0,
maxValueDeg: 180
},
{
name: "right_slide",
type: "prismatic",
parentLink: "base_link",
childLink: "right_slider",
originTransform: translationTransform(0, 0, 0),
axisInJointFrame: [-1, 0, 0],
defaultValueDeg: 0,
minValueDeg: 0,
maxValueDeg: 0.05,
mimic: { joint: "driver_joint", multiplier: 0.01, offset: 0 }
},
{
name: "left_slide",
type: "prismatic",
parentLink: "base_link",
childLink: "left_slider",
originTransform: translationTransform(0, 0, 0),
axisInJointFrame: [1, 0, 0],
defaultValueDeg: 0,
minValueDeg: 0,
maxValueDeg: 0.05,
mimic: { joint: "driver_joint", multiplier: 0.01, offset: 0 }
}
]
};
const defaults = buildDefaultUrdfJointValues(urdf);
const linkWorldTransforms = solveUrdfLinkWorldTransforms(urdf, { driver_joint: 90 });
const travel = Math.round(((Math.PI / 2) * 0.01) * 1000000) / 1000000;
assert.deepEqual(Object.keys(defaults), ["driver_joint"]);
assert.deepEqual(rounded(transformPoint(linkWorldTransforms.get("right_slider"), [0, 0, 0])), rounded([-travel, 0, 0]));
assert.deepEqual(rounded(transformPoint(linkWorldTransforms.get("left_slider"), [0, 0, 0])), rounded([travel, 0, 0]));
});
test("joint origin rotation reorients the motion axis from joint frame into parent space", () => {
const joint = {
type: "continuous",
originTransform: rotationZTransform(90),
axisInJointFrame: [0, 1, 0],
defaultValueDeg: 0,
minValueDeg: -180,
maxValueDeg: 180
};
const transformedPoint = transformPoint(posedJointLocalTransform(joint, 90), [0, 0, 1]).map((value) => Math.round(value * 1000) / 1000);
assert.deepEqual(transformedPoint, [0, 1, 0]);
});
test("posed mesh bounds update after joint motion", () => {
const zeroPose = buildUrdfMeshData(sampleUrdf(), PART_MESHES, { base_to_arm: 0 });
const rotatedPose = buildUrdfMeshData(sampleUrdf(), PART_MESHES, { base_to_arm: 90 });
assert.notDeepEqual(zeroPose.meshData.bounds, rotatedPose.meshData.bounds);
assert.equal(zeroPose.meshData.parts.length, 3);
assert.equal(rotatedPose.meshData.parts.length, 3);
});
test("posing URDF mesh data reuses the static geometry buffers", () => {
const meshGeometry = buildUrdfMeshGeometry(sampleUrdf(), PART_MESHES);
const zeroPose = poseUrdfMeshData(sampleUrdf(), meshGeometry, { base_to_arm: 0 });
const rotatedPose = poseUrdfMeshData(sampleUrdf(), meshGeometry, { base_to_arm: 90 });
assert.equal(zeroPose.meshData.vertices, meshGeometry.vertices);
assert.equal(rotatedPose.meshData.vertices, meshGeometry.vertices);
assert.equal(zeroPose.meshData.indices, meshGeometry.indices);
assert.equal(rotatedPose.meshData.indices, meshGeometry.indices);
assert.equal(zeroPose.meshData.geometrySource, meshGeometry);
assert.equal(rotatedPose.meshData.geometrySource, meshGeometry);
assert.notDeepEqual(zeroPose.meshData.bounds, rotatedPose.meshData.bounds);
});
test("applying a URDF pose in place preserves mesh identity and updates posed parts", () => {
const meshGeometry = buildUrdfMeshGeometry(sampleUrdf(), PART_MESHES);
const zeroPose = applyUrdfPoseToMeshData(sampleUrdf(), meshGeometry, { base_to_arm: 0 });
const zeroParts = zeroPose.meshData.parts;
const zeroBounds = zeroPose.meshData.bounds;
const rotatedPose = applyUrdfPoseToMeshData(sampleUrdf(), meshGeometry, { base_to_arm: 90 });
assert.equal(zeroPose.meshData, meshGeometry);
assert.equal(rotatedPose.meshData, meshGeometry);
assert.equal(rotatedPose.meshData.geometrySource, meshGeometry);
assert.notEqual(rotatedPose.meshData.parts, zeroParts);
assert.notDeepEqual(zeroBounds, rotatedPose.meshData.bounds);
assert.notDeepEqual(
zeroParts.find((part) => part.linkName === "arm_link").transform,
rotatedPose.meshData.parts.find((part) => part.linkName === "arm_link").transform
);
});
test("posed URDF mesh data can override link world transforms", () => {
const meshGeometry = buildUrdfMeshGeometry(sampleUrdf(), PART_MESHES);
const overrideTransform = translationTransform(25, 0, 0);
const posed = poseUrdfMeshData(
sampleUrdf(),
meshGeometry,
{ base_to_arm: 0 },
new Map([["arm_link", overrideTransform]])
);
assert.deepEqual(transformPoint(posed.linkWorldTransforms.get("arm_link"), [0, 0, 0]), [25, 0, 0]);
assert.deepEqual(transformPoint(posed.meshData.parts.find((part) => part.linkName === "arm_link").transform, [0, 0, 0]), [25, 0, 0]);
});
test("posed URDF mesh data preserves resolved visual colors", () => {
const posed = buildUrdfMeshData(sampleUrdf(), PART_MESHES, { base_to_arm: 0 });
assert.deepEqual(
posed.meshData.parts.map((part) => part.color),
["#2b2f33", "#a4abb3", "#a4abb3"]
);
assert.equal(posed.meshData.has_source_colors, true);
assert.deepEqual(
rounded(posed.meshData.colors.slice(0, 9)),
rounded(repeatedTriplet(linearHexTriplet("#2b2f33")))
);
assert.deepEqual(
rounded(posed.meshData.colors.slice(9, 18)),
rounded(repeatedTriplet(linearHexTriplet("#a4abb3")))
);
});
test("URDF mesh data builds primitive box visuals without external mesh assets", () => {
const urdfData = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{
name: "base_link",
visuals: [
{
id: "base_link:v1",
label: "box",
primitive: {
type: "box",
size: [2, 4, 6]
},
color: "#a4abb3",
localTransform: translationTransform(0, 0, 0)
}
]
}
],
joints: []
};
const meshGeometry = buildUrdfMeshGeometry(urdfData, new Map());
assert.equal(meshGeometry.parts.length, 1);
assert.equal(meshGeometry.parts[0].vertexCount, 24);
assert.equal(meshGeometry.parts[0].triangleCount, 12);
assert.deepEqual(meshGeometry.parts[0].bounds, {
min: [-1, -2, -3],
max: [1, 2, 3]
});
assert.equal(meshGeometry.has_source_colors, true);
assert.deepEqual(
rounded(meshGeometry.colors.slice(0, 9)),
rounded(repeatedTriplet(linearHexTriplet("#a4abb3")))
);
});
test("URDF mesh data preserves mesh source colors when visuals omit material colors", () => {
const urdfData = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{
name: "base_link",
visuals: [
{
id: "base_link:painted",
label: "painted",
partFileRef: "painted-part",
color: "#2b2f33",
localTransform: translationTransform(0, 0, 0)
},
{
id: "base_link:source",
label: "source",
partFileRef: "source-part",
localTransform: translationTransform(0, 0, 0)
}
]
}
],
joints: []
};
const meshes = new Map([
["painted-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] }, [0.8, 0.1, 0.1])],
["source-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] }, [0.25, 0.5, 0.75])]
]);
const meshGeometry = buildUrdfMeshGeometry(urdfData, meshes);
assert.equal(meshGeometry.has_source_colors, true);
assert.deepEqual(
meshGeometry.parts.map((part) => part.color),
["#2b2f33", ""]
);
assert.deepEqual(
rounded(meshGeometry.colors.slice(0, 9)),
rounded(repeatedTriplet(linearHexTriplet("#2b2f33")))
);
assert.deepEqual(
Array.from(meshGeometry.colors.slice(9, 18)),
[0.25, 0.5, 0.75, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75]
);
});
test("URDF mesh data marks uncolored visuals for default viewer color", () => {
const urdfData = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{
name: "base_link",
visuals: [
{
id: "base_link:motor",
label: "motor",
partFileRef: "motor-part",
color: "#2b2f33",
localTransform: translationTransform(0, 0, 0)
},
{
id: "base_link:default",
label: "default",
partFileRef: "default-part",
localTransform: translationTransform(0, 0, 0)
}
]
}
],
joints: []
};
const meshes = new Map([
["motor-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] })],
["default-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] })]
]);
const meshGeometry = buildUrdfMeshGeometry(urdfData, meshes);
assert.equal(meshGeometry.has_source_colors, true);
assert.deepEqual(
meshGeometry.parts.map((part) => part.hasSourceColors),
[true, false]
);
assert.deepEqual(
rounded(meshGeometry.colors.slice(0, 9)),
rounded(repeatedTriplet(linearHexTriplet("#2b2f33")))
);
assert.deepEqual(Array.from(meshGeometry.colors.slice(9, 18)), [1, 1, 1, 1, 1, 1, 1, 1, 1]);
});
test("URDF mesh geometry can keep source meshes for lightweight viewer rendering", () => {
const urdfData = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{
name: "base_link",
visuals: [
{
id: "base_link:first",
label: "first",
partFileRef: "shared-part",
localTransform: translationTransform(0, 0, 0)
},
{
id: "base_link:second",
label: "second",
partFileRef: "shared-part",
localTransform: translationTransform(2, 0, 0)
}
]
}
],
joints: []
};
const sharedMesh = partMesh({ min: [0, 0, 0], max: [1, 1, 0] });
const meshes = new Map([["shared-part", sharedMesh]]);
const meshGeometry = buildUrdfMeshGeometry(urdfData, meshes, { lightweight: true });
const posed = poseUrdfMeshData(urdfData, meshGeometry, {});
assert.equal(meshGeometry.lightweightGeometry, true);
assert.equal(meshGeometry.parts[0].sourceMesh, sharedMesh);
assert.equal(meshGeometry.parts[1].sourceMesh, sharedMesh);
assert.equal(meshGeometry.vertices.length, 9);
assert.equal(meshGeometry.indices.length, 3);
assert.deepEqual(posed.meshData.bounds, {
min: [0, 0, 0],
max: [3, 1, 0]
});
});
test("URDF mesh data leaves uncolored robots for theme fill colors", () => {
const urdfData = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{
name: "base_link",
visuals: [
{
id: "base_link:first",
label: "first",
occurrenceId: "o1.2.10",
partFileRef: "first-part",
localTransform: translationTransform(0, 0, 0)
},
{
id: "base_link:second",
label: "second",
occurrenceId: "o1.2.11",
partFileRef: "second-part",
localTransform: translationTransform(0, 0, 0)
}
]
}
],
joints: []
};
const meshes = new Map([
["first-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] })],
["second-part", partMesh({ min: [0, 0, 0], max: [1, 1, 0] })]
]);
const meshGeometry = buildUrdfMeshGeometry(urdfData, meshes);
assert.equal(meshGeometry.has_source_colors, false);
assert.deepEqual(
meshGeometry.parts.map((part) => part.color),
["", ""]
);
assert.deepEqual(
meshGeometry.parts.map((part) => part.hasSourceColors),
[false, false]
);
assert.deepEqual(
meshGeometry.parts.map((part) => part.occurrenceId),
["o1.2.10", "o1.2.11"]
);
assert.equal(meshGeometry.colors.length, 0);
});
@@ -0,0 +1,161 @@
import { clampJointValueDeg, linkOriginInFrame } from "./kinematics.js";
const MOTION_LIMIT_EPSILON = 1e-6;
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function isAngularJoint(joint) {
const jointType = String(joint?.type || "fixed");
return jointType === "continuous" || jointType === "revolute";
}
export function nativeJointValueToDisplay(joint, value) {
const numericValue = toFiniteNumber(value, 0);
return isAngularJoint(joint) ? (numericValue * 180) / Math.PI : numericValue;
}
export function displayJointValueToNative(joint, value) {
const numericValue = toFiniteNumber(value, 0);
return isAngularJoint(joint) ? (numericValue * Math.PI) / 180 : numericValue;
}
export function jointValuesByNameToNative(urdfData, jointValuesByName) {
const joints = Array.isArray(urdfData?.joints) ? urdfData.joints : [];
const jointByName = new Map(joints.map((joint) => [String(joint?.name || ""), joint]).filter(([name]) => name));
const nativeValues = {};
for (const [name, value] of Object.entries(jointValuesByName || {})) {
const jointName = String(name || "").trim();
const joint = jointByName.get(jointName);
if (jointName && joint) {
nativeValues[jointName] = displayJointValueToNative(joint, value);
}
}
return nativeValues;
}
export function normalizeMotionTargetPosition(value, fallback = [0, 0, 0]) {
const source = Array.isArray(value) ? value : fallback;
return [0, 1, 2].map((index) => toFiniteNumber(source[index], fallback[index] ?? 0));
}
export function positionDistance(left, right) {
const a = normalizeMotionTargetPosition(left);
const b = normalizeMotionTargetPosition(right);
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
export function validateUrdfMotionJointValues(urdfData, jointValuesByName, { native = false } = {}) {
if (!isPlainObject(jointValuesByName)) {
throw new Error("MoveIt2 server response jointValuesByName must be an object");
}
const joints = Array.isArray(urdfData?.joints) ? urdfData.joints : [];
const jointByName = new Map(joints.map((joint) => [String(joint?.name || ""), joint]).filter(([name]) => name));
const validated = {};
for (const [name, rawValue] of Object.entries(jointValuesByName)) {
const jointName = String(name || "").trim();
if (!jointName) {
throw new Error("MoveIt2 server response includes an empty joint name");
}
const joint = jointByName.get(jointName);
if (!joint) {
throw new Error(`MoveIt2 server response references unknown joint ${jointName}`);
}
const jointType = String(joint?.type || "fixed");
if (jointType === "fixed") {
throw new Error(`MoveIt2 server response cannot set fixed joint ${jointName}`);
}
if (joint?.mimic) {
throw new Error(`MoveIt2 server response cannot set mimic joint ${jointName}`);
}
const rawNumericValue = Number(rawValue);
if (!Number.isFinite(rawNumericValue)) {
throw new Error(`MoveIt2 server response joint ${jointName} must be a finite number`);
}
const numericValue = native ? nativeJointValueToDisplay(joint, rawNumericValue) : rawNumericValue;
const clampedValue = clampJointValueDeg(joint, numericValue);
if (jointType !== "continuous" && Math.abs(clampedValue - numericValue) > MOTION_LIMIT_EPSILON) {
throw new Error(`MoveIt2 server response joint ${jointName} must stay within joint limits`);
}
validated[jointName] = numericValue;
}
return validated;
}
export function validateUrdfMotionTrajectory(urdfData, trajectory) {
if (!isPlainObject(trajectory)) {
throw new Error("MoveIt2 server response trajectory must be an object");
}
const rawJointNames = Array.isArray(trajectory.jointNames) ? trajectory.jointNames : [];
const jointNames = rawJointNames.map((name) => String(name || "").trim()).filter(Boolean);
if (!jointNames.length) {
throw new Error("MoveIt2 server response trajectory.jointNames must be a non-empty array");
}
const rawPoints = Array.isArray(trajectory.points) ? trajectory.points : [];
if (!rawPoints.length) {
throw new Error("MoveIt2 server response trajectory.points must be a non-empty array");
}
let previousTime = -Number.EPSILON;
const points = rawPoints.map((point, pointIndex) => {
if (!isPlainObject(point)) {
throw new Error(`MoveIt2 server response trajectory point ${pointIndex + 1} must be an object`);
}
const timeFromStartSec = Number(point.timeFromStartSec);
if (!Number.isFinite(timeFromStartSec) || timeFromStartSec < 0 || timeFromStartSec < previousTime) {
throw new Error("MoveIt2 server response trajectory times must be finite, non-negative, and sorted");
}
previousTime = timeFromStartSec;
const hasNativePositions = Array.isArray(point.positions);
const nativePositions = hasNativePositions ? point.positions.map((value) => Number(value)) : [];
if (hasNativePositions && (nativePositions.length !== jointNames.length || nativePositions.some((value) => !Number.isFinite(value)))) {
throw new Error("MoveIt2 server response trajectory positions must match trajectory.jointNames");
}
const positionsDeg = hasNativePositions
? nativePositions.map((value, index) => {
const joint = (Array.isArray(urdfData?.joints) ? urdfData.joints : []).find((candidate) => String(candidate?.name || "") === jointNames[index]);
return nativeJointValueToDisplay(joint, value);
})
: (Array.isArray(point.positionsDeg) ? point.positionsDeg.map((value) => Number(value)) : []);
if (positionsDeg.length !== jointNames.length || positionsDeg.some((value) => !Number.isFinite(value))) {
throw new Error("MoveIt2 server response trajectory positions must match trajectory.jointNames");
}
const positionsByNameDeg = Object.fromEntries(jointNames.map((jointName, index) => [jointName, positionsDeg[index]]));
validateUrdfMotionJointValues(urdfData, positionsByNameDeg);
return {
timeFromStartSec,
positions: hasNativePositions ? nativePositions : null,
positionsDeg,
positionsByName: hasNativePositions ? Object.fromEntries(jointNames.map((jointName, index) => [jointName, nativePositions[index]])) : null,
positionsByNameDeg
};
});
return {
jointNames,
points
};
}
export function measureUrdfMotionResult(urdfData, jointValuesByName, endEffector, targetPosition) {
const actualPosition = linkOriginInFrame(
urdfData,
jointValuesByName,
endEffector?.link,
endEffector?.frame
);
if (!actualPosition) {
return {
actualPosition: null,
positionError: Number.POSITIVE_INFINITY
};
}
return {
actualPosition,
positionError: positionDistance(actualPosition, targetPosition)
};
}
@@ -0,0 +1,135 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
jointValuesByNameToNative,
measureUrdfMotionResult,
normalizeMotionTargetPosition,
validateUrdfMotionTrajectory,
validateUrdfMotionJointValues
} from "./motion.js";
function translationTransform(x, y, z) {
return [
1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1
];
}
const SAMPLE_URDF = {
rootLink: "base_link",
rootWorldTransform: translationTransform(0, 0, 0),
links: [
{ name: "base_link", visuals: [] },
{ name: "tool_link", visuals: [] },
{ name: "mimic_link", visuals: [] }
],
joints: [
{
name: "base_to_tool",
type: "revolute",
parentLink: "base_link",
childLink: "tool_link",
originTransform: translationTransform(0.2, 0, 0),
axis: [0, 0, 1],
defaultValueDeg: 0,
minValueDeg: -45,
maxValueDeg: 45
},
{
name: "mimic_joint",
type: "revolute",
parentLink: "tool_link",
childLink: "mimic_link",
originTransform: translationTransform(0, 0, 0),
axis: [0, 0, 1],
defaultValueDeg: 0,
minValueDeg: -45,
maxValueDeg: 45,
mimic: { joint: "base_to_tool", multiplier: 1, offset: 0 }
}
]
};
test("normalizeMotionTargetPosition returns a stable finite xyz tuple", () => {
assert.deepEqual(normalizeMotionTargetPosition(["1", 2, Number.NaN], [0, 0, 3]), [1, 2, 3]);
});
test("validateUrdfMotionJointValues accepts movable non-mimic joints", () => {
assert.deepEqual(validateUrdfMotionJointValues(SAMPLE_URDF, { base_to_tool: 12.5 }), {
base_to_tool: 12.5
});
assert.deepEqual(validateUrdfMotionJointValues(SAMPLE_URDF, { base_to_tool: Math.PI / 4 }, { native: true }), {
base_to_tool: 45
});
assert.deepEqual(jointValuesByNameToNative(SAMPLE_URDF, { base_to_tool: 90 }), {
base_to_tool: Math.PI / 2
});
});
test("validateUrdfMotionJointValues rejects mimic and out-of-limit joints", () => {
assert.throws(
() => validateUrdfMotionJointValues(SAMPLE_URDF, { mimic_joint: 5 }),
/cannot set mimic joint/
);
assert.throws(
() => validateUrdfMotionJointValues(SAMPLE_URDF, { base_to_tool: 90 }),
/must stay within joint limits/
);
});
test("measureUrdfMotionResult reports FK residual for the end effector", () => {
const measurement = measureUrdfMotionResult(
SAMPLE_URDF,
{ base_to_tool: 0 },
{ link: "tool_link", frame: "base_link" },
[0.2, 0.001, 0]
);
assert.deepEqual(measurement.actualPosition, [0.2, 0, 0]);
assert.equal(Math.round(measurement.positionError * 1000) / 1000, 0.001);
});
test("validateUrdfMotionTrajectory accepts sorted finite waypoint positions", () => {
assert.deepEqual(validateUrdfMotionTrajectory(SAMPLE_URDF, {
jointNames: ["base_to_tool"],
points: [
{ timeFromStartSec: 0, positionsDeg: [0] },
{ timeFromStartSec: 0.25, positionsDeg: [12.5] }
]
}), {
jointNames: ["base_to_tool"],
points: [
{ timeFromStartSec: 0, positions: null, positionsDeg: [0], positionsByName: null, positionsByNameDeg: { base_to_tool: 0 } },
{ timeFromStartSec: 0.25, positions: null, positionsDeg: [12.5], positionsByName: null, positionsByNameDeg: { base_to_tool: 12.5 } }
]
});
});
test("validateUrdfMotionTrajectory accepts native waypoint positions", () => {
const trajectory = validateUrdfMotionTrajectory(SAMPLE_URDF, {
jointNames: ["base_to_tool"],
points: [
{ timeFromStartSec: 0, positions: [0] },
{ timeFromStartSec: 0.25, positions: [Math.PI / 4] }
]
});
assert.equal(trajectory.points[1].positionsByName.base_to_tool, Math.PI / 4);
assert.equal(trajectory.points[1].positionsByNameDeg.base_to_tool, 45);
});
test("validateUrdfMotionTrajectory rejects out-of-limit waypoint positions", () => {
assert.throws(
() => validateUrdfMotionTrajectory(SAMPLE_URDF, {
jointNames: ["base_to_tool"],
points: [
{ timeFromStartSec: 0, positionsDeg: [0] },
{ timeFromStartSec: 0.25, positionsDeg: [90] }
]
}),
/must stay within joint limits/
);
});
@@ -0,0 +1,237 @@
const DEFAULT_TIMEOUT_MS = 60000;
const DEFAULT_MOVEIT2_SERVER_WS_URL = "ws://127.0.0.1:8765/ws";
const MOVEIT2_PROTOCOL_VERSION = 1;
const pendingRequests = new Map();
let socket = null;
let socketUrl = "";
function defaultMoveIt2ServerEnabled() {
if (explicitMoveIt2ServerUrl()) {
return true;
}
const env = typeof import.meta !== "undefined" ? import.meta.env : null;
if (env && typeof env === "object" && Object.hasOwn(env, "DEV")) {
return Boolean(env.DEV);
}
return true;
}
export function moveit2ServerEnabled({ enabled = defaultMoveIt2ServerEnabled() } = {}) {
return Boolean(enabled);
}
function normalizeRequestId(value) {
const id = String(value || "").trim();
if (id) {
return id;
}
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `moveit2-server-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function configuredMoveIt2ServerUrl({ enabled = moveit2ServerEnabled() } = {}) {
if (!moveit2ServerEnabled({ enabled })) {
return "";
}
const envUrl = typeof import.meta !== "undefined"
? String(import.meta.env?.VIEWER_MOVEIT2_WS_URL || "").trim()
: "";
if (envUrl) {
return envUrl;
}
return DEFAULT_MOVEIT2_SERVER_WS_URL;
}
function explicitMoveIt2ServerUrl() {
const envUrl = typeof import.meta !== "undefined"
? String(import.meta.env?.VIEWER_MOVEIT2_WS_URL || "").trim()
: "";
if (envUrl) {
return envUrl;
}
if (typeof window === "undefined") {
return "";
}
try {
return String(new URL(window.location.href).searchParams.get("moveit2Ws") || "").trim();
} catch {
return "";
}
}
export function moveit2ServerUrl({ enabled = moveit2ServerEnabled() } = {}) {
if (!moveit2ServerEnabled({ enabled })) {
return "";
}
if (typeof window === "undefined") {
return configuredMoveIt2ServerUrl({ enabled });
}
const queryUrl = explicitMoveIt2ServerUrl();
return String(queryUrl || "").trim() || configuredMoveIt2ServerUrl({ enabled });
}
function rejectPendingRequests(message) {
for (const [id, pending] of pendingRequests.entries()) {
clearTimeout(pending.timer);
pending.reject(new Error(message));
pendingRequests.delete(id);
}
}
function resetSocket(message = "MoveIt2 server connection closed.") {
rejectPendingRequests(message);
socket = null;
socketUrl = "";
}
function ensureMoveIt2ServerSocket(url, WebSocketImpl) {
if (!WebSocketImpl) {
throw new Error("MoveIt2 server requires browser WebSocket support.");
}
if (socket && socketUrl === url && socket.readyState <= WebSocketImpl.OPEN) {
return socket;
}
if (socket) {
socket.close();
resetSocket("MoveIt2 server connection changed.");
}
socket = new WebSocketImpl(url);
socketUrl = url;
socket.addEventListener("message", (event) => {
let message;
try {
message = JSON.parse(String(event.data || ""));
} catch {
return;
}
const id = String(message?.id || "").trim();
const pending = pendingRequests.get(id);
if (!pending) {
return;
}
pendingRequests.delete(id);
clearTimeout(pending.timer);
if (message?.ok === false) {
pending.reject(new Error(String(message?.error?.message || "MoveIt2 server request failed.")));
return;
}
pending.resolve(message?.result ?? {});
});
socket.addEventListener("close", () => resetSocket("MoveIt2 server connection closed."));
socket.addEventListener("error", () => {
if (socket?.readyState !== WebSocketImpl.OPEN) {
resetSocket(`Could not connect to MoveIt2 server at ${url}.`);
}
});
return socket;
}
function sendWhenOpen(activeSocket, message, resolve, reject) {
const openState = activeSocket.constructor?.OPEN ?? 1;
if (activeSocket.readyState === openState) {
activeSocket.send(message);
resolve();
return;
}
const handleOpen = () => {
activeSocket.removeEventListener("open", handleOpen);
activeSocket.send(message);
resolve();
};
activeSocket.addEventListener("open", handleOpen);
}
export function moveit2ServerAvailable({
WebSocketImpl = globalThis.WebSocket,
enabled = moveit2ServerEnabled(),
} = {}) {
return Boolean(enabled && WebSocketImpl);
}
export function checkMoveIt2ServerLive({
timeoutMs = 1000,
url = moveit2ServerUrl(),
WebSocketImpl = globalThis.WebSocket,
enabled = moveit2ServerEnabled(),
} = {}) {
const safeTimeoutMs = Math.max(Number(timeoutMs) || 1000, 1);
return new Promise((resolve) => {
if (!enabled || !url || !WebSocketImpl) {
resolve(false);
return;
}
let settled = false;
let activeSocket = null;
let timer = 0;
const settle = (isLive) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
if (activeSocket && isLive) {
activeSocket.close();
}
resolve(Boolean(isLive));
};
timer = setTimeout(() => settle(false), safeTimeoutMs);
try {
activeSocket = new WebSocketImpl(url);
} catch {
settle(false);
return;
}
activeSocket.addEventListener("open", () => settle(true));
activeSocket.addEventListener("error", () => settle(false));
activeSocket.addEventListener("close", () => settle(false));
});
}
export function requestMoveIt2Server(type, payload, {
timeoutMs = DEFAULT_TIMEOUT_MS,
url = moveit2ServerUrl(),
WebSocketImpl = globalThis.WebSocket,
enabled = moveit2ServerEnabled(),
} = {}) {
const id = normalizeRequestId(payload?.id);
const safeTimeoutMs = Math.max(Number(timeoutMs) || DEFAULT_TIMEOUT_MS, 1);
return new Promise((resolve, reject) => {
if (!enabled || !url) {
reject(new Error("MoveIt2 server connections are not enabled for this viewer session."));
return;
}
let activeSocket;
try {
activeSocket = ensureMoveIt2ServerSocket(url, WebSocketImpl);
} catch (error) {
reject(error);
return;
}
const timer = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`MoveIt2 server request ${id} timed out.`));
}, safeTimeoutMs);
pendingRequests.set(id, { resolve, reject, timer });
const message = JSON.stringify({
id,
type,
protocolVersion: MOVEIT2_PROTOCOL_VERSION,
payload
});
sendWhenOpen(activeSocket, message, () => {}, (error) => {
pendingRequests.delete(id);
clearTimeout(timer);
reject(error);
});
});
}
export function closeMoveIt2ServerConnection() {
if (socket) {
socket.close();
}
resetSocket("MoveIt2 server connection closed.");
}
@@ -0,0 +1,227 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
checkMoveIt2ServerLive,
closeMoveIt2ServerConnection,
moveit2ServerAvailable,
moveit2ServerEnabled,
moveit2ServerUrl,
requestMoveIt2Server,
} from "./moveit2ServerClient.js";
class FakeWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 3;
static sockets = [];
constructor(url) {
this.url = url;
this.readyState = FakeWebSocket.CONNECTING;
this.listeners = new Map();
this.sent = [];
FakeWebSocket.sockets.push(this);
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
listeners.push(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
const listeners = this.listeners.get(type) || [];
this.listeners.set(type, listeners.filter((candidate) => candidate !== listener));
}
send(message) {
this.sent.push(JSON.parse(message));
}
close() {
this.readyState = FakeWebSocket.CLOSED;
this.emit("close");
}
open() {
this.readyState = FakeWebSocket.OPEN;
this.emit("open");
}
respond(message) {
this.emit("message", { data: JSON.stringify(message) });
}
emit(type, event = {}) {
for (const listener of this.listeners.get(type) || []) {
listener(event);
}
}
}
test.beforeEach(() => {
FakeWebSocket.sockets = [];
closeMoveIt2ServerConnection();
});
test.afterEach(() => {
closeMoveIt2ServerConnection();
});
test("requestMoveIt2Server sends request IDs and resolves matching responses", async () => {
const promise = requestMoveIt2Server("srdf.solvePose", {
id: "req-1",
file: "sample_robot.srdf",
}, {
url: "ws://moveit2.test/ws",
WebSocketImpl: FakeWebSocket,
timeoutMs: 1000,
});
assert.equal(FakeWebSocket.sockets.length, 1);
const socket = FakeWebSocket.sockets[0];
assert.equal(socket.url, "ws://moveit2.test/ws");
socket.open();
assert.deepEqual(socket.sent[0], {
id: "req-1",
type: "srdf.solvePose",
protocolVersion: 1,
payload: {
id: "req-1",
file: "sample_robot.srdf",
},
});
socket.respond({
id: "req-1",
ok: true,
result: {
jointValuesByNameDeg: {
joint_2: 42,
},
},
});
assert.deepEqual(await promise, {
jointValuesByNameDeg: {
joint_2: 42,
},
});
});
test("requestMoveIt2Server rejects server errors", async () => {
const promise = requestMoveIt2Server("srdf.solvePose", { id: "req-2" }, {
url: "ws://moveit2.test/ws",
WebSocketImpl: FakeWebSocket,
timeoutMs: 1000,
});
const socket = FakeWebSocket.sockets[0];
socket.open();
socket.respond({
id: "req-2",
ok: false,
error: {
message: "planning failed",
},
});
await assert.rejects(promise, /planning failed/);
});
test("moveit2ServerAvailable reflects WebSocket support", () => {
assert.equal(moveit2ServerAvailable({ WebSocketImpl: FakeWebSocket }), true);
assert.equal(moveit2ServerAvailable({ WebSocketImpl: null }), false);
});
test("moveit2ServerEnabled can disable production MoveIt2 connections", () => {
assert.equal(moveit2ServerEnabled({ enabled: true }), true);
assert.equal(moveit2ServerEnabled({ enabled: false }), false);
assert.equal(moveit2ServerAvailable({ WebSocketImpl: FakeWebSocket, enabled: false }), false);
});
test("checkMoveIt2ServerLive resolves true when websocket opens", async () => {
const promise = checkMoveIt2ServerLive({
url: "ws://moveit2.test/ws",
WebSocketImpl: FakeWebSocket,
timeoutMs: 1000,
});
const socket = FakeWebSocket.sockets[0];
assert.equal(socket.url, "ws://moveit2.test/ws");
socket.open();
assert.equal(await promise, true);
assert.equal(socket.readyState, FakeWebSocket.CLOSED);
});
test("checkMoveIt2ServerLive resolves false when websocket errors before opening", async () => {
const promise = checkMoveIt2ServerLive({
url: "ws://moveit2.test/ws",
WebSocketImpl: FakeWebSocket,
timeoutMs: 1000,
});
FakeWebSocket.sockets[0].emit("error");
assert.equal(await promise, false);
});
test("checkMoveIt2ServerLive does not open a websocket when disabled", async () => {
const live = await checkMoveIt2ServerLive({
url: "ws://moveit2.test/ws",
WebSocketImpl: FakeWebSocket,
enabled: false,
timeoutMs: 1000,
});
assert.equal(live, false);
assert.equal(FakeWebSocket.sockets.length, 0);
});
test("requestMoveIt2Server rejects without opening a websocket when disabled", async () => {
await assert.rejects(
requestMoveIt2Server("srdf.solvePose", { id: "req-disabled" }, {
url: "ws://moveit2.test/ws",
WebSocketImpl: FakeWebSocket,
enabled: false,
timeoutMs: 1000,
}),
/not enabled/
);
assert.equal(FakeWebSocket.sockets.length, 0);
});
test("moveit2ServerUrl uses the generic query override", () => {
const originalWindow = globalThis.window;
globalThis.window = {
location: {
href: "http://127.0.0.1:4178/?moveit2Ws=ws%3A%2F%2Fmoveit2.test%2Fws",
},
};
try {
assert.equal(moveit2ServerUrl(), "ws://moveit2.test/ws");
} finally {
if (originalWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
}
});
test("moveit2ServerUrl ignores query overrides when disabled", () => {
const originalWindow = globalThis.window;
globalThis.window = {
location: {
href: "http://127.0.0.1:4178/?moveit2Ws=ws%3A%2F%2Fmoveit2.test%2Fws",
},
};
try {
assert.equal(moveit2ServerUrl({ enabled: false }), "");
} finally {
if (originalWindow === undefined) {
delete globalThis.window;
} else {
globalThis.window = originalWindow;
}
}
});
@@ -0,0 +1,906 @@
import { invertRigidTransform, multiplyTransforms, poseTransformFromXyzRpy } from "./kinematics.js";
const IDENTITY_TRANSFORM = Object.freeze([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
const SDF_MODEL_FRAME = "__model__";
const SDF_WORLD_FRAME = "world";
const SDF_VIRTUAL_ROOT_LINK = "__sdf_model__";
function elementName(node) {
return String(node?.localName || node?.tagName || "").split(":").pop();
}
function childElements(parent) {
return Array.from(parent?.childNodes || []).filter((node) => node?.nodeType === 1);
}
function childElementsByTag(parent, tagName) {
return childElements(parent).filter((node) => elementName(node) === tagName);
}
function descendantElementsByTag(parent, tagName, result = []) {
for (const child of childElements(parent)) {
if (elementName(child) === tagName) {
result.push(child);
}
descendantElementsByTag(child, tagName, result);
}
return result;
}
function childText(parent, tagName) {
return String(childElementsByTag(parent, tagName)[0]?.textContent || "").trim();
}
function pluginName(pluginElement) {
return String(pluginElement?.getAttribute("name") || "").trim();
}
function pluginFilename(pluginElement) {
return String(pluginElement?.getAttribute("filename") || "").trim();
}
function parseNumberList(value, count, fallback, context) {
const text = String(value || "").trim();
if (!text) {
return [...fallback];
}
const parsed = text.split(/\s+/).map((entry) => Number(entry));
if (parsed.length !== count || parsed.some((entry) => !Number.isFinite(entry))) {
throw new Error(`${context} must contain ${count} numeric values`);
}
return parsed;
}
function translationTransform(x, y, z) {
return [
1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1
];
}
function scaleTransform(x, y, z) {
return [
x, 0, 0, 0,
0, y, 0, 0,
0, 0, z, 0,
0, 0, 0, 1
];
}
function rotationTransformFromRpy(roll, pitch, yaw) {
const sr = Math.sin(roll);
const cr = Math.cos(roll);
const sp = Math.sin(pitch);
const cp = Math.cos(pitch);
const sy = Math.sin(yaw);
const cy = Math.cos(yaw);
return [
cy * cp, (cy * sp * sr) - (sy * cr), (cy * sp * cr) + (sy * sr), 0,
sy * cp, (sy * sp * sr) + (cy * cr), (sy * sp * cr) - (cy * sr), 0,
-sp, cp * sr, cp * cr, 0,
0, 0, 0, 1
];
}
function poseTransform(values) {
return poseTransformFromXyzRpy(values);
}
function parseBooleanAttribute(value, fallback = false) {
const normalized = String(value ?? "").trim().toLowerCase();
if (!normalized) {
return fallback;
}
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return fallback;
}
function parsePose(parentElement, context) {
const poseElement = childElementsByTag(parentElement, "pose")[0] || null;
if (!poseElement) {
return {
hasPose: false,
rotationFormat: "euler_rpy",
relativeTo: "",
values: [0, 0, 0, 0, 0, 0],
transform: [...IDENTITY_TRANSFORM]
};
}
const rotationFormat = String(poseElement.getAttribute("rotation_format") || "euler_rpy").trim().toLowerCase();
if (rotationFormat !== "euler_rpy") {
throw new Error(`${context} pose uses unsupported rotation_format ${JSON.stringify(rotationFormat)}`);
}
const values = parseNumberList(
poseElement.textContent,
6,
[0, 0, 0, 0, 0, 0],
`${context} pose`
);
if (parseBooleanAttribute(poseElement.getAttribute("degrees"), false)) {
values[3] *= Math.PI / 180;
values[4] *= Math.PI / 180;
values[5] *= Math.PI / 180;
}
return {
hasPose: true,
rotationFormat,
relativeTo: String(poseElement.getAttribute("relative_to") || "").trim(),
values,
transform: poseTransform(values)
};
}
function normalizeAbsoluteUrl(url) {
if (url instanceof URL) {
return url.toString();
}
return new URL(String(url || "/"), globalThis.window?.location?.href || "http://localhost/").toString();
}
function urlHasExplicitOrigin(value) {
try {
const url = new URL(String(value || ""));
return Boolean(url.origin && url.origin !== "null");
} catch {
return false;
}
}
function resolvedMeshUrlString(resolvedUrl, { sourceUrl }) {
if (urlHasExplicitOrigin(sourceUrl)) {
return resolvedUrl.toString();
}
return `${resolvedUrl.pathname}${resolvedUrl.search}`;
}
function resolveMeshUrl(uri, sourceUrl) {
const rawUri = String(uri || "").trim();
if (!rawUri) {
throw new Error("SDF mesh URI is required");
}
const normalizedSourceUrl = normalizeAbsoluteUrl(sourceUrl);
let resolvedUrl;
if (rawUri.startsWith("package://")) {
resolvedUrl = new URL(rawUri.slice("package://".length).replace(/^\/+/, ""), new URL("/", normalizedSourceUrl));
} else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawUri)) {
throw new Error(`Unsupported SDF mesh URI scheme: ${rawUri}`);
} else {
resolvedUrl = new URL(rawUri, normalizedSourceUrl);
}
return resolvedMeshUrlString(resolvedUrl, { sourceUrl });
}
function labelForMeshUri(uri) {
const parts = String(uri || "").split("/");
return parts[parts.length - 1] || "mesh";
}
function rotateVectorByTransform(transform, vector) {
const matrix = Array.isArray(transform) && transform.length === 16 ? transform : IDENTITY_TRANSFORM;
const [x, y, z] = Array.isArray(vector) ? vector : [0, 0, 1];
return [
(matrix[0] * x) + (matrix[1] * y) + (matrix[2] * z),
(matrix[4] * x) + (matrix[5] * y) + (matrix[6] * z),
(matrix[8] * x) + (matrix[9] * y) + (matrix[10] * z)
];
}
function normalizeAxisVector(axis, fallback = [0, 0, 1]) {
const vector = Array.isArray(axis) && axis.length >= 3 ? axis : fallback;
const x = Number(vector[0]);
const y = Number(vector[1]);
const z = Number(vector[2]);
const length = Math.hypot(x, y, z);
if (!Number.isFinite(length) || length <= 1e-9) {
return [...fallback];
}
return [x / length, y / length, z / length];
}
function transformDirectionBetweenFrames(vector, fromFrameWorldTransform, toFrameWorldTransform) {
const worldVector = rotateVectorByTransform(fromFrameWorldTransform, vector);
return rotateVectorByTransform(invertRigidTransform(toFrameWorldTransform), worldVector);
}
function parseRgbaColor(colorText, context) {
const rawValues = String(colorText || "").trim().split(/\s+/).filter(Boolean);
if (rawValues.length !== 3 && rawValues.length !== 4) {
throw new Error(`${context} must contain 3 or 4 numeric color values`);
}
const values = rawValues.map((entry) => Number(entry));
if (values.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) {
throw new Error(`${context} must use color values between 0 and 1`);
}
return `#${values.slice(0, 3).map((value) => {
const component = Math.round(value * 255);
return component.toString(16).padStart(2, "0");
}).join("")}`;
}
function materialColorFromElement(materialElement, context) {
if (!materialElement) {
return "";
}
const diffuseText = childText(materialElement, "diffuse");
if (diffuseText) {
return parseRgbaColor(diffuseText, `${context} diffuse material`);
}
const ambientText = childText(materialElement, "ambient");
return ambientText ? parseRgbaColor(ambientText, `${context} ambient material`) : "";
}
function occurrenceIdFromSdfName(value) {
const normalized = String(value || "").trim();
const match = /(?:^|_)o(\d+(?:_\d+)+)(?:_|$)/i.exec(normalized);
return match ? `o${match[1].replace(/_/g, ".")}` : "";
}
function parseMeshInstance(containerElement, { linkName, kind, index, sourceUrl }) {
const labelKind = kind === "collision" ? "collision" : "visual";
const instanceId = String(containerElement?.getAttribute("name") || "").trim();
const pose = parsePose(containerElement, `SDF link ${linkName} ${labelKind} ${index}`);
const geometryElement = childElementsByTag(containerElement, "geometry")[0] || null;
const meshElement = geometryElement ? childElementsByTag(geometryElement, "mesh")[0] : null;
if (!meshElement) {
const geometryKind = geometryElement ? (elementName(childElements(geometryElement)[0]) || "unknown") : "missing";
return {
id: `${linkName}:${kind[0]}${index}`,
label: `${geometryKind} ${labelKind}`,
instanceId,
occurrenceId: occurrenceIdFromSdfName(instanceId) || occurrenceIdFromSdfName(linkName),
meshUrl: "",
color: "",
localTransform: pose.transform,
pose,
unsupportedGeometry: geometryKind
};
}
const uri = childText(meshElement, "uri");
if (!uri) {
return {
id: `${linkName}:${kind[0]}${index}`,
label: `mesh ${labelKind}`,
instanceId,
occurrenceId: occurrenceIdFromSdfName(instanceId) || occurrenceIdFromSdfName(linkName),
meshUrl: "",
color: "",
localTransform: pose.transform,
pose,
unsupportedGeometry: "mesh"
};
}
const [scaleX, scaleY, scaleZ] = parseNumberList(
childText(meshElement, "scale"),
3,
[1, 1, 1],
`SDF link ${linkName} ${labelKind} ${index} mesh scale`
);
const meshScaleTransform = scaleTransform(scaleX, scaleY, scaleZ);
return {
id: `${linkName}:${kind[0]}${index}`,
label: labelForMeshUri(uri),
instanceId,
occurrenceId: occurrenceIdFromSdfName(instanceId) || occurrenceIdFromSdfName(linkName),
meshUrl: resolveMeshUrl(uri, sourceUrl || "/"),
color: materialColorFromElement(
childElementsByTag(containerElement, "material")[0] || null,
`SDF link ${linkName} ${labelKind} ${index}`
),
localTransform: multiplyTransforms(pose.transform, meshScaleTransform),
pose,
scaleTransform: meshScaleTransform
};
}
function parseLink(linkElement, sourceUrl) {
const name = String(linkElement.getAttribute("name") || "").trim();
if (!name) {
throw new Error("SDF link name is required");
}
const visuals = childElementsByTag(linkElement, "visual").map((visualElement, index) => (
parseMeshInstance(visualElement, { linkName: name, kind: "visual", index: index + 1, sourceUrl })
));
const collisions = childElementsByTag(linkElement, "collision").map((collisionElement, index) => (
parseMeshInstance(collisionElement, { linkName: name, kind: "collision", index: index + 1, sourceUrl })
));
return {
name,
visuals,
collisions,
pose: parsePose(linkElement, `SDF link ${name}`)
};
}
function parseFrame(frameElement) {
const name = String(frameElement.getAttribute("name") || "").trim();
if (!name) {
throw new Error("SDF frame name is required");
}
return {
name,
attachedTo: String(frameElement.getAttribute("attached_to") || "").trim(),
pose: parsePose(frameElement, `SDF frame ${name}`)
};
}
function parseJointLimit(axisElement, jointName, jointType) {
if (jointType === "continuous") {
return { minValueDeg: -180, maxValueDeg: 180 };
}
if (jointType !== "revolute" && jointType !== "prismatic") {
return { minValueDeg: 0, maxValueDeg: 0 };
}
const limitElement = childElementsByTag(axisElement, "limit")[0] || null;
const lower = Number(childText(limitElement, "lower"));
const upper = Number(childText(limitElement, "upper"));
if (!Number.isFinite(lower) || !Number.isFinite(upper)) {
throw new Error(`SDF ${jointType} joint ${jointName} has invalid limits`);
}
if (jointType === "revolute") {
return {
minValueDeg: (lower * 180) / Math.PI,
maxValueDeg: (upper * 180) / Math.PI
};
}
return {
minValueDeg: lower,
maxValueDeg: upper
};
}
function normalizeSdfFrameName(frameName, { modelName, defaultFrame = SDF_MODEL_FRAME } = {}) {
const rawName = String(frameName || "").trim();
if (!rawName) {
return defaultFrame;
}
if (rawName === modelName || rawName === SDF_MODEL_FRAME) {
return SDF_MODEL_FRAME;
}
if (rawName === SDF_WORLD_FRAME) {
return SDF_WORLD_FRAME;
}
return rawName;
}
function parseJoint(jointElement, { modelName }) {
const name = String(jointElement.getAttribute("name") || "").trim();
if (!name) {
throw new Error("SDF joint name is required");
}
const type = String(jointElement.getAttribute("type") || "").trim().toLowerCase();
if (!["fixed", "continuous", "revolute", "prismatic"].includes(type)) {
throw new Error(`Unsupported SDF joint type: ${type || "(missing)"}`);
}
const parentFrame = normalizeSdfFrameName(childText(jointElement, "parent"), { modelName, defaultFrame: "" });
const childFrame = normalizeSdfFrameName(childText(jointElement, "child"), { modelName, defaultFrame: "" });
if (!parentFrame || !childFrame) {
throw new Error(`SDF joint ${name} must declare parent and child frames`);
}
if (childFrame === SDF_WORLD_FRAME) {
throw new Error(`SDF joint ${name} cannot use world as the child frame`);
}
const axisElement = childElementsByTag(jointElement, "axis")[0] || null;
const axisXyzElement = childElementsByTag(axisElement, "xyz")[0] || null;
const axis = type === "fixed"
? [1, 0, 0]
: normalizeAxisVector(parseNumberList(axisXyzElement?.textContent, 3, [0, 0, 1], `SDF joint ${name} axis`));
const limits = parseJointLimit(axisElement, name, type);
return {
name,
type,
parentFrame,
childFrame,
pose: parsePose(jointElement, `SDF joint ${name}`),
axis,
axisExpressedIn: String(axisXyzElement?.getAttribute("expressed_in") || "").trim(),
defaultValueDeg: 0,
minValueDeg: limits.minValueDeg,
maxValueDeg: limits.maxValueDeg,
mimic: null
};
}
function createFrameResolver({ modelName, modelWorldTransform, links, frames, joints }) {
const descriptors = new Map([
[SDF_WORLD_FRAME, { name: SDF_WORLD_FRAME, kind: "world", worldTransform: [...IDENTITY_TRANSFORM] }],
[SDF_MODEL_FRAME, { name: SDF_MODEL_FRAME, kind: "model", worldTransform: modelWorldTransform }]
]);
const linkNames = new Set();
const addDescriptor = (name, descriptor) => {
if (descriptors.has(name)) {
throw new Error(`Duplicate SDF frame/link/joint name: ${name}`);
}
descriptors.set(name, descriptor);
};
for (const link of links) {
linkNames.add(link.name);
addDescriptor(link.name, {
name: link.name,
kind: "link",
pose: link.pose,
defaultRelativeTo: SDF_MODEL_FRAME
});
}
for (const frame of frames) {
const attachedTo = normalizeSdfFrameName(frame.attachedTo, { modelName, defaultFrame: SDF_MODEL_FRAME });
addDescriptor(frame.name, {
name: frame.name,
kind: "frame",
pose: frame.pose,
attachedTo,
defaultRelativeTo: attachedTo
});
}
for (const joint of joints) {
addDescriptor(joint.name, {
name: joint.name,
kind: "joint",
pose: joint.pose,
childFrame: joint.childFrame,
defaultRelativeTo: joint.childFrame
});
}
const resolveFrameWorld = (rawFrameName, defaultFrame = SDF_MODEL_FRAME, resolving = new Set()) => {
const frameName = normalizeSdfFrameName(rawFrameName, { modelName, defaultFrame });
const descriptor = descriptors.get(frameName);
if (!descriptor) {
throw new Error(`SDF references unknown frame ${JSON.stringify(frameName)}`);
}
if (descriptor.worldTransform) {
return descriptor.worldTransform;
}
if (resolving.has(frameName)) {
throw new Error(`SDF frame graph contains a cycle at ${frameName}`);
}
resolving.add(frameName);
const pose = descriptor.pose || {
relativeTo: "",
transform: [...IDENTITY_TRANSFORM]
};
const relativeFrame = normalizeSdfFrameName(pose.relativeTo, {
modelName,
defaultFrame: descriptor.defaultRelativeTo || SDF_MODEL_FRAME
});
const relativeWorldTransform = resolveFrameWorld(relativeFrame, SDF_MODEL_FRAME, resolving);
const worldTransform = multiplyTransforms(relativeWorldTransform, pose.transform || IDENTITY_TRANSFORM);
descriptor.worldTransform = worldTransform;
resolving.delete(frameName);
return worldTransform;
};
const resolveAttachedLink = (rawFrameName, resolving = new Set()) => {
const frameName = normalizeSdfFrameName(rawFrameName, { modelName, defaultFrame: SDF_MODEL_FRAME });
if (linkNames.has(frameName)) {
return frameName;
}
if (frameName === SDF_MODEL_FRAME || frameName === SDF_WORLD_FRAME) {
return "";
}
const descriptor = descriptors.get(frameName);
if (!descriptor) {
throw new Error(`SDF references unknown frame ${JSON.stringify(frameName)}`);
}
if (resolving.has(frameName)) {
throw new Error(`SDF attached_to graph contains a cycle at ${frameName}`);
}
resolving.add(frameName);
if (descriptor.kind === "frame") {
return resolveAttachedLink(descriptor.attachedTo || SDF_MODEL_FRAME, resolving);
}
if (descriptor.kind === "joint") {
return resolveAttachedLink(descriptor.childFrame, resolving);
}
return "";
};
return {
descriptors,
linkNames,
resolveFrameWorld,
resolveAttachedLink
};
}
function finalizeMeshInstance(instance, { linkName, resolver, modelName }) {
const linkWorldTransform = resolver.resolveFrameWorld(linkName);
const pose = instance.pose || {
relativeTo: "",
transform: [...IDENTITY_TRANSFORM]
};
const poseFrame = normalizeSdfFrameName(pose.relativeTo, { modelName, defaultFrame: linkName });
const poseWorldTransform = multiplyTransforms(resolver.resolveFrameWorld(poseFrame), pose.transform);
const localPoseTransform = multiplyTransforms(invertRigidTransform(linkWorldTransform), poseWorldTransform);
const { pose: _pose, scaleTransform: meshScaleTransform, ...meshInstance } = instance;
return {
...meshInstance,
localTransform: multiplyTransforms(localPoseTransform, meshScaleTransform || IDENTITY_TRANSFORM)
};
}
function finalizeLinks(links, resolver, modelName) {
return links.map((link) => ({
name: link.name,
visuals: link.visuals.map((visual) => finalizeMeshInstance(visual, { linkName: link.name, resolver, modelName })),
collisions: link.collisions.map((collision) => finalizeMeshInstance(collision, { linkName: link.name, resolver, modelName }))
}));
}
function buildNativeJoint(rawJoint, resolver, modelName) {
const parentLink = resolver.resolveAttachedLink(rawJoint.parentFrame) || SDF_VIRTUAL_ROOT_LINK;
const childLink = resolver.resolveAttachedLink(rawJoint.childFrame);
if (!childLink) {
throw new Error(`SDF joint ${rawJoint.name} child frame ${JSON.stringify(rawJoint.childFrame)} is not attached to a link`);
}
if (parentLink === childLink) {
throw new Error(`SDF joint ${rawJoint.name} resolves parent and child to the same link ${childLink}`);
}
const parentLinkWorldTransform = parentLink === SDF_VIRTUAL_ROOT_LINK
? [...IDENTITY_TRANSFORM]
: resolver.resolveFrameWorld(parentLink);
const jointWorldTransform = resolver.resolveFrameWorld(rawJoint.name);
const childLinkWorldTransform = resolver.resolveFrameWorld(childLink);
const preMotionTransform = multiplyTransforms(invertRigidTransform(parentLinkWorldTransform), jointWorldTransform);
const postMotionTransform = multiplyTransforms(invertRigidTransform(jointWorldTransform), childLinkWorldTransform);
const axisFrame = normalizeSdfFrameName(rawJoint.axisExpressedIn, { modelName, defaultFrame: rawJoint.name });
const axis = rawJoint.type === "fixed"
? [1, 0, 0]
: normalizeAxisVector(transformDirectionBetweenFrames(
rawJoint.axis,
resolver.resolveFrameWorld(axisFrame),
jointWorldTransform
));
return {
name: rawJoint.name,
type: rawJoint.type,
parentLink,
childLink,
originTransform: multiplyTransforms(preMotionTransform, postMotionTransform),
preMotionTransform,
postMotionTransform,
axis,
axisInJointFrame: axis,
defaultValueDeg: rawJoint.defaultValueDeg,
minValueDeg: rawJoint.minValueDeg,
maxValueDeg: rawJoint.maxValueDeg,
mimic: null,
sdf: {
parentFrame: rawJoint.parentFrame,
childFrame: rawJoint.childFrame,
jointFrame: rawJoint.name,
axisExpressedIn: axisFrame
}
};
}
function buildVirtualRootJoint(linkName, resolver, index) {
const linkWorldTransform = resolver.resolveFrameWorld(linkName);
return {
name: `${SDF_VIRTUAL_ROOT_LINK}_to_${linkName}_${index}`,
type: "fixed",
parentLink: SDF_VIRTUAL_ROOT_LINK,
childLink: linkName,
originTransform: linkWorldTransform,
preMotionTransform: linkWorldTransform,
postMotionTransform: [...IDENTITY_TRANSFORM],
axis: [1, 0, 0],
axisInJointFrame: [1, 0, 0],
defaultValueDeg: 0,
minValueDeg: 0,
maxValueDeg: 0,
mimic: null,
sdf: {
parentFrame: SDF_MODEL_FRAME,
childFrame: linkName,
jointFrame: `${SDF_VIRTUAL_ROOT_LINK}_to_${linkName}_${index}`,
synthetic: true
}
};
}
function buildRootedSdfTree(links, joints, resolver) {
const linkNames = new Set(links.map((link) => link.name));
const childLinks = new Set(joints.map((joint) => joint.childLink));
const rootCandidates = [...linkNames].filter((linkName) => !childLinks.has(linkName));
const needsVirtualRoot = rootCandidates.length !== 1 || joints.some((joint) => joint.parentLink === SDF_VIRTUAL_ROOT_LINK);
if (!needsVirtualRoot) {
const rootLink = rootCandidates[0];
return {
links,
joints,
rootLink,
rootWorldTransform: resolver.resolveFrameWorld(rootLink),
syntheticRoot: false
};
}
const rootJoints = rootCandidates.map((linkName, index) => buildVirtualRootJoint(linkName, resolver, index + 1));
return {
links: [
{
name: SDF_VIRTUAL_ROOT_LINK,
visuals: [],
collisions: [],
synthetic: true
},
...links
],
joints: [...rootJoints, ...joints],
rootLink: SDF_VIRTUAL_ROOT_LINK,
rootWorldTransform: [...IDENTITY_TRANSFORM],
syntheticRoot: true
};
}
function validateTree(links, joints) {
const linkNames = new Set(links.map((link) => link.name));
const children = new Set();
const jointsByParent = new Map();
const jointNames = new Set();
for (const joint of joints) {
if (jointNames.has(joint.name)) {
throw new Error(`Duplicate SDF joint name: ${joint.name}`);
}
jointNames.add(joint.name);
if (children.has(joint.childLink)) {
throw new Error(`SDF link ${joint.childLink} has multiple parents`);
}
children.add(joint.childLink);
const current = jointsByParent.get(joint.parentLink) || [];
current.push(joint.childLink);
jointsByParent.set(joint.parentLink, current);
}
const rootCandidates = [...linkNames].filter((linkName) => !children.has(linkName));
if (rootCandidates.length !== 1) {
throw new Error(`SDF must form a single rooted joint tree; found roots ${JSON.stringify(rootCandidates)}`);
}
const rootLink = rootCandidates[0];
const visited = new Set();
const visiting = new Set();
const visit = (linkName) => {
if (visited.has(linkName)) {
return;
}
if (visiting.has(linkName)) {
throw new Error("SDF joint graph contains a cycle");
}
visiting.add(linkName);
for (const childLink of jointsByParent.get(linkName) || []) {
visit(childLink);
}
visiting.delete(linkName);
visited.add(linkName);
};
visit(rootLink);
if (visited.size !== links.length) {
const missing = links.map((link) => link.name).filter((linkName) => !visited.has(linkName));
throw new Error(`SDF leaves links disconnected from the root: ${JSON.stringify(missing)}`);
}
return rootLink;
}
function elementAttributeObject(element, names) {
return Object.fromEntries(
names.map((name) => [name, String(element?.getAttribute(name) || "").trim()])
.filter(([, value]) => value)
);
}
function parseIncludeMetadata(includeElement, index) {
return {
id: `include:${index + 1}`,
uri: childText(includeElement, "uri"),
name: childText(includeElement, "name")
};
}
function parsePluginMetadata(pluginElement, index) {
const name = pluginName(pluginElement);
const filename = pluginFilename(pluginElement);
return {
id: `plugin:${index + 1}`,
name,
filename,
customAnimation: filename === "cad-viewer-input-motion" || name === "cad_viewer_input_motion"
};
}
function parseSensorMetadata(sensorElement, index) {
return {
id: `sensor:${index + 1}`,
...elementAttributeObject(sensorElement, ["name", "type"])
};
}
function parseLightMetadata(lightElement, index) {
return {
id: `light:${index + 1}`,
...elementAttributeObject(lightElement, ["name", "type"])
};
}
function parsePhysicsMetadata(physicsElement, index) {
return {
id: `physics:${index + 1}`,
...elementAttributeObject(physicsElement, ["name", "type", "default"])
};
}
function collectSdfStaticMetadata(root) {
const includes = descendantElementsByTag(root, "include").map(parseIncludeMetadata);
const plugins = descendantElementsByTag(root, "plugin").map(parsePluginMetadata);
const sensors = descendantElementsByTag(root, "sensor").map(parseSensorMetadata);
const lights = descendantElementsByTag(root, "light").map(parseLightMetadata);
const physics = descendantElementsByTag(root, "physics").map(parsePhysicsMetadata);
const nestedModelCount = descendantElementsByTag(root, "model")
.filter((modelElement) => modelElement.parentNode && elementName(modelElement.parentNode) === "model")
.length;
const warnings = [];
if (includes.length) {
warnings.push("SDF includes are listed as static metadata; CAD Viewer does not resolve external models.");
}
if (plugins.length) {
warnings.push("SDF plugins are listed as static metadata; CAD Viewer does not execute simulator plugins.");
}
if (plugins.some((plugin) => plugin.customAnimation)) {
warnings.push("CAD Viewer input-motion plugins are ignored; SDF rendering is static unless joints are posed manually.");
}
if (sensors.length) {
warnings.push("SDF sensors are listed as static metadata; CAD Viewer does not simulate sensor output.");
}
if (lights.length) {
warnings.push("SDF lights are listed as static metadata; CAD Viewer does not execute simulator lighting behavior.");
}
if (physics.length) {
warnings.push("SDF physics settings are listed as static metadata; CAD Viewer does not run simulation physics.");
}
if (nestedModelCount) {
warnings.push("Nested SDF models are listed as static metadata; CAD Viewer renders the selected model's direct links.");
}
return {
includes,
plugins,
sensors,
lights,
physics,
nestedModelCount,
warnings
};
}
function selectSdfRenderModel(sdfRoot) {
const directModels = childElementsByTag(sdfRoot, "model");
if (directModels.length === 1) {
return {
documentKind: "model",
worldName: "",
model: directModels[0]
};
}
if (directModels.length > 1) {
throw new Error("SDF rendering currently requires one direct <model>; multiple top-level models are listed only as metadata");
}
const worlds = childElementsByTag(sdfRoot, "world");
if (worlds.length !== 1) {
throw new Error("SDF rendering requires one direct <model> or one <world> containing one direct <model>");
}
const world = worlds[0];
const worldModels = childElementsByTag(world, "model");
if (worldModels.length !== 1) {
throw new Error("SDF world rendering currently requires exactly one direct <model>");
}
return {
documentKind: "world",
worldName: String(world.getAttribute("name") || "").trim(),
model: worldModels[0]
};
}
export function parseSdf(xmlText, { sourceUrl } = {}) {
if (typeof DOMParser === "undefined") {
throw new Error("DOMParser is unavailable in this environment");
}
const document = new DOMParser().parseFromString(String(xmlText || ""), "application/xml");
const parseError = document.querySelector("parsererror");
if (parseError) {
throw new Error("Failed to parse SDF XML");
}
const sdfRoot = document.documentElement;
if (!sdfRoot || elementName(sdfRoot) !== "sdf") {
throw new Error("SDF root element must be <sdf>");
}
const version = String(sdfRoot.getAttribute("version") || "").trim();
if (!version) {
throw new Error("SDF root element must declare a version");
}
const staticMetadata = collectSdfStaticMetadata(sdfRoot);
const { documentKind, worldName, model } = selectSdfRenderModel(sdfRoot);
const robotName = String(model.getAttribute("name") || "").trim();
if (!robotName) {
throw new Error("SDF model name is required");
}
const modelPose = parsePose(model, `SDF model ${robotName}`);
const modelPoseFrame = normalizeSdfFrameName(modelPose.relativeTo, { modelName: robotName, defaultFrame: SDF_WORLD_FRAME });
if (modelPoseFrame !== SDF_WORLD_FRAME) {
throw new Error(`SDF model ${robotName} uses unsupported pose frame ${JSON.stringify(modelPose.relativeTo)}`);
}
const modelWorldTransform = modelPose.transform;
const links = childElementsByTag(model, "link").map((linkElement) => parseLink(linkElement, sourceUrl));
if (!links.length) {
throw new Error("SDF model must define at least one link");
}
const unsupportedVisualCount = links.flatMap((link) => link.visuals).filter((visual) => visual.unsupportedGeometry).length;
const unsupportedCollisionCount = links.flatMap((link) => link.collisions).filter((collision) => collision.unsupportedGeometry).length;
const linkNames = new Set();
for (const link of links) {
if (linkNames.has(link.name)) {
throw new Error(`Duplicate SDF link name: ${link.name}`);
}
linkNames.add(link.name);
}
const frames = childElementsByTag(model, "frame").map(parseFrame);
const rawJoints = childElementsByTag(model, "joint").map((jointElement) => parseJoint(jointElement, {
modelName: robotName
}));
const rawJointNames = new Set();
for (const joint of rawJoints) {
if (rawJointNames.has(joint.name)) {
throw new Error(`Duplicate SDF joint name: ${joint.name}`);
}
rawJointNames.add(joint.name);
}
const resolver = createFrameResolver({
modelName: robotName,
modelWorldTransform,
links,
frames,
joints: rawJoints
});
const resolvedLinks = finalizeLinks(links, resolver, robotName);
const resolvedJoints = rawJoints.map((joint) => buildNativeJoint(joint, resolver, robotName));
const rootedTree = buildRootedSdfTree(resolvedLinks, resolvedJoints, resolver);
const rootLink = validateTree(rootedTree.links, rootedTree.joints);
return {
robotName,
rootLink,
rootWorldTransform: rootedTree.rootWorldTransform,
links: rootedTree.links,
joints: rootedTree.joints,
motion: null,
sourceFormat: "sdf",
sdf: {
version,
documentKind,
worldName,
modelName: robotName,
nativeFrameSemantics: true,
syntheticRoot: rootedTree.syntheticRoot,
frameCount: frames.length,
linkCount: links.length,
jointCount: rawJoints.length,
rootLink,
unsupportedVisualCount,
unsupportedCollisionCount,
staticMetadata
},
srdf: null
};
}
@@ -0,0 +1,483 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseSdf } from "./parseSdf.js";
import { solveUrdfLinkWorldTransforms, transformPoint } from "./kinematics.js";
class FakeElement {
constructor(tagName, attributes = {}, children = [], text = "") {
this.nodeType = 1;
this.tagName = tagName;
this.localName = String(tagName || "").split(":").pop();
this.namespaceURI = null;
this._attributes = { ...attributes };
this.childNodes = children;
this._text = String(text || "");
for (const child of this.childNodes) {
if (child && typeof child === "object") {
child.parentNode = this;
}
}
}
getAttribute(name) {
return Object.hasOwn(this._attributes, name) ? this._attributes[name] : null;
}
get textContent() {
return `${this._text}${this.childNodes.map((child) => String(child?.textContent || "")).join("")}`;
}
}
class FakeDocument {
constructor(documentElement) {
this.documentElement = documentElement;
}
querySelector(selector) {
return selector === "parsererror" ? null : null;
}
}
function el(tagName, attributes = {}, children = [], text = "") {
return new FakeElement(tagName, attributes, children, text);
}
function textEl(tagName, text, attributes = {}) {
return el(tagName, attributes, [], text);
}
function withFakeDomParser(document, callback) {
const previous = globalThis.DOMParser;
globalThis.DOMParser = class FakeDomParser {
parseFromString() {
return document;
}
};
try {
return callback();
} finally {
globalThis.DOMParser = previous;
}
}
function meshVisual(linkName, uri, color = "0.168627 0.184314 0.2 1") {
return el("visual", { name: `${linkName}_visual` }, [
textEl("pose", "0 0 0 0 0 0", { relative_to: linkName }),
el("geometry", {}, [
el("mesh", {}, [
textEl("uri", uri),
textEl("scale", "0.001 0.001 0.001")
])
]),
el("material", {}, [
textEl("diffuse", color)
])
]);
}
function meshCollision(linkName, uri) {
return el("collision", { name: `${linkName}_collision` }, [
el("geometry", {}, [
el("mesh", {}, [
textEl("uri", uri)
])
])
]);
}
function link(name, { parent = "", pose = "0 0 0 0 0 0" } = {}) {
const children = [
textEl("pose", pose, parent ? { relative_to: parent } : {}),
meshVisual(name, `meshes/${name}.stl`),
meshCollision(name, `meshes/${name}_collision.stl`)
];
return el("link", { name }, children);
}
function joint(index, parent, child, type = "revolute") {
const axisChildren = [
textEl("xyz", "0 0 1")
];
if (type !== "continuous") {
axisChildren.push(el("limit", {}, [
textEl("lower", "-1.57079632679"),
textEl("upper", "1.57079632679")
]));
}
return el("joint", { name: `joint_${index}`, type }, [
textEl("parent", parent),
textEl("child", child),
textEl("pose", `${index * 0.01} 0 0 0 0 0`, { relative_to: parent }),
el("axis", {}, axisChildren)
]);
}
function sdfRoot(children, attributes = { version: "1.12" }) {
return el("sdf", attributes, children);
}
function parseWithRoot(root, sourceUrl = "/workspace/robots/so101.sdf") {
return withFakeDomParser(new FakeDocument(root), () => parseSdf("<sdf />", { sourceUrl }));
}
function roundedPoint(point) {
return point.map((value) => {
const rounded = Math.round(value * 1000) / 1000;
return Object.is(rounded, -0) ? 0 : rounded;
});
}
test("parseSdf reads SO101-style model-level SDF robot data", () => {
const links = [
link("base_link"),
...Array.from({ length: 7 }, (_, index) => link(`link_${index + 1}`, {
parent: index === 0 ? "base_link" : `link_${index}`
}))
];
const joints = Array.from({ length: 7 }, (_, index) => joint(
index + 1,
index === 0 ? "base_link" : `link_${index}`,
`link_${index + 1}`,
index === 6 ? "continuous" : "revolute"
));
const root = sdfRoot([
el("model", { name: "so101_new_calib" }, [...links, ...joints])
]);
const sdfData = parseWithRoot(root);
assert.equal(sdfData.robotName, "so101_new_calib");
assert.equal(sdfData.rootLink, "base_link");
assert.equal(sdfData.links.length, 8);
assert.equal(sdfData.joints.length, 7);
assert.equal(sdfData.links[0].visuals[0].meshUrl, "/workspace/robots/meshes/base_link.stl");
assert.equal(sdfData.links[0].visuals[0].color, "#2b2f33");
assert.equal(sdfData.links[0].collisions[0].meshUrl, "/workspace/robots/meshes/base_link_collision.stl");
assert.deepEqual(sdfData.joints[0].axis, [0, 0, 1]);
assert.equal(Math.round(sdfData.joints[0].minValueDeg), -90);
assert.equal(Math.round(sdfData.joints[0].maxValueDeg), 90);
assert.equal(sdfData.joints[6].type, "continuous");
assert.equal(sdfData.motion, null);
assert.equal(sdfData.srdf, null);
});
test("parseSdf preserves remote origins when resolving hosted mesh URIs", () => {
const root = sdfRoot([
el("model", { name: "hosted_robot" }, [
link("base_link")
])
]);
const sdfData = parseWithRoot(root, "https://blob.example.test/models2/robots/so101/robot.sdf");
assert.equal(
sdfData.links[0].visuals[0].meshUrl,
"https://blob.example.test/models2/robots/so101/meshes/base_link.stl"
);
assert.equal(
sdfData.links[0].collisions[0].meshUrl,
"https://blob.example.test/models2/robots/so101/meshes/base_link_collision.stl"
);
});
test("parseSdf preserves CAD occurrence ids encoded in visual names", () => {
const root = sdfRoot([
el("model", { name: "sample" }, [
el("link", { name: "occ_node_001_base" }, [
el("visual", { name: "o1_2_10_visual" }, [
el("geometry", {}, [
el("mesh", {}, [
textEl("uri", "meshes/base.stl")
])
])
])
])
])
]);
const sdfData = parseWithRoot(root);
assert.equal(sdfData.links[0].visuals[0].instanceId, "o1_2_10_visual");
assert.equal(sdfData.links[0].visuals[0].occurrenceId, "o1.2.10");
});
test("parseSdf resolves SDF link poses through native frame semantics", () => {
const root = sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("tool_link", { pose: "2 0 0 0 0 0" }),
el("joint", { name: "base_to_tool", type: "fixed" }, [
textEl("parent", "base_link"),
textEl("child", "tool_link")
])
])
]);
const sdfData = parseWithRoot(root);
const linkWorldTransforms = solveUrdfLinkWorldTransforms(sdfData);
assert.deepEqual(roundedPoint(transformPoint(linkWorldTransforms.get("tool_link"), [0, 0, 0])), [2, 0, 0]);
assert.equal(sdfData.sdf.nativeFrameSemantics, true);
});
test("parseSdf preserves a native SDF joint frame offset from the child link", () => {
const root = sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("door_link", { pose: "2 0 0 0 0 0" }),
el("joint", { name: "hinge", type: "revolute" }, [
textEl("parent", "base_link"),
textEl("child", "door_link"),
textEl("pose", "-1 0 0 0 0 0"),
el("axis", {}, [
textEl("xyz", "0 0 1"),
el("limit", {}, [
textEl("lower", "-3.14159265359"),
textEl("upper", "3.14159265359")
])
])
])
])
]);
const sdfData = parseWithRoot(root);
const linkWorldTransforms = solveUrdfLinkWorldTransforms(sdfData, { hinge: 90 });
assert.deepEqual(roundedPoint(transformPoint(linkWorldTransforms.get("door_link"), [0, 0, 0])), [1, 1, 0]);
});
test("parseSdf converts joint axes expressed in another SDF frame into the joint frame", () => {
const root = sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("slider_link", { pose: "0 0 0 0 0 1.57079632679" }),
el("joint", { name: "slide", type: "prismatic" }, [
textEl("parent", "base_link"),
textEl("child", "slider_link"),
el("axis", {}, [
textEl("xyz", "1 0 0", { expressed_in: "base_link" }),
el("limit", {}, [
textEl("lower", "-10"),
textEl("upper", "10")
])
])
])
])
]);
const sdfData = parseWithRoot(root);
const linkWorldTransforms = solveUrdfLinkWorldTransforms(sdfData, { slide: 2 });
assert.deepEqual(roundedPoint(transformPoint(linkWorldTransforms.get("slider_link"), [0, 0, 0])), [2, 0, 0]);
});
test("parseSdf rejects missing roots, missing models, and ambiguous model selections", () => {
assert.throws(
() => parseWithRoot(el("robot", { name: "not_sdf" })),
/root element must be <sdf>/
);
assert.throws(
() => parseWithRoot(sdfRoot([])),
/requires one direct <model> or one <world>/
);
assert.throws(
() => parseWithRoot(sdfRoot([el("model", { name: "a" }), el("model", { name: "b" })])),
/multiple top-level models/
);
assert.throws(
() => parseWithRoot(sdfRoot([el("world", { name: "default" })])),
/world rendering currently requires exactly one direct <model>/
);
});
test("parseSdf renders a single-model SDF world as static robot structure", () => {
const root = sdfRoot([
el("world", { name: "default" }, [
el("model", { name: "robot" }, [
link("base_link")
])
])
]);
const sdfData = parseWithRoot(root);
assert.equal(sdfData.robotName, "robot");
assert.equal(sdfData.sdf.documentKind, "world");
assert.equal(sdfData.sdf.worldName, "default");
assert.equal(sdfData.motion, null);
});
test("parseSdf rejects duplicate links and duplicate joints", () => {
assert.throws(
() => parseWithRoot(sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("base_link")
])
])),
/Duplicate SDF link name/
);
assert.throws(
() => parseWithRoot(sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("tool_link", { parent: "base_link" }),
el("joint", { name: "duplicate_joint", type: "fixed" }, [
textEl("parent", "base_link"),
textEl("child", "tool_link")
]),
el("joint", { name: "duplicate_joint", type: "fixed" }, [
textEl("parent", "base_link"),
textEl("child", "tool_link")
])
])
])),
/Duplicate SDF joint name/
);
});
test("parseSdf reports simulator-only SDF elements as static metadata", () => {
const root = sdfRoot([
el("world", { name: "default" }, [
el("include", {}, [
textEl("uri", "model://other_robot"),
textEl("name", "other_robot")
]),
el("light", { name: "sun", type: "directional" }),
el("physics", { name: "fast", type: "ode", default: "true" }),
el("model", { name: "robot" }, [
link("base_link"),
el("sensor", { name: "camera", type: "camera" }),
el("plugin", { name: "gz_controller", filename: "gz-sim-joint-controller-system" }),
el("model", { name: "nested_payload" }, [
link("payload_link")
])
])
])
]);
const sdfData = parseWithRoot(root);
const metadata = sdfData.sdf.staticMetadata;
assert.equal(metadata.includes.length, 1);
assert.equal(metadata.plugins.length, 1);
assert.equal(metadata.sensors.length, 1);
assert.equal(metadata.lights.length, 1);
assert.equal(metadata.physics.length, 1);
assert.equal(metadata.nestedModelCount, 1);
assert.equal(metadata.plugins[0].filename, "gz-sim-joint-controller-system");
assert.equal(sdfData.motion, null);
assert.ok(metadata.warnings.some((warning) => warning.includes("does not execute simulator plugins")));
});
test("parseSdf rejects unsupported joint types", () => {
assert.throws(
() => parseWithRoot(sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("tool_link", { parent: "base_link" }),
el("joint", { name: "ball_joint", type: "ball" }, [
textEl("parent", "base_link"),
textEl("child", "tool_link")
])
])
])),
/Unsupported SDF joint type/
);
});
test("parseSdf ignores CAD Viewer input motion plugin channels as static metadata", () => {
const root = sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("wheel_link", { parent: "base_link" }),
joint(1, "base_link", "wheel_link", "continuous"),
el("plugin", { name: "cad_viewer_input_motion", filename: "cad-viewer-input-motion" }, [
textEl("time_scale", "1.5"),
el("channel", {
joint: "joint_1",
label: "Input wheel",
waveform: "linear",
rate_deg_per_sec: "120",
offset_deg: "5"
})
])
])
]);
const sdfData = parseWithRoot(root);
assert.equal(sdfData.motion, null);
assert.equal(sdfData.sdf.staticMetadata.plugins.length, 1);
assert.equal(sdfData.sdf.staticMetadata.plugins[0].customAnimation, true);
assert.ok(sdfData.sdf.staticMetadata.warnings.some((warning) => warning.includes("input-motion plugins are ignored")));
});
test("parseSdf ignores CAD Viewer link pose playback tracks as static metadata", () => {
const root = sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
link("door_link", { parent: "base_link" }),
joint(1, "base_link", "door_link", "fixed"),
el("plugin", { name: "cad_viewer_input_motion", filename: "cad-viewer-input-motion" }, [
textEl("time_scale", "2"),
el("link_pose_playback", { loop: "true" }, [
el("track", { link: "door_link", label: "Door" }, [
el("key", { time: "0" }, [
textEl("pose", "0 0 0 0 0 0", { relative_to: "base_link" })
]),
el("key", { time: "1" }, [
textEl("pose", "1 0 0 0 0 1.57079632679", { relative_to: "base_link" })
])
])
])
])
])
]);
const sdfData = parseWithRoot(root);
assert.equal(sdfData.motion, null);
assert.equal(sdfData.sdf.staticMetadata.plugins.length, 1);
assert.equal(sdfData.sdf.staticMetadata.plugins[0].customAnimation, true);
});
test("parseSdf keeps unsupported geometry as static placeholders", () => {
const sdfData = parseWithRoot(sdfRoot([
el("model", { name: "robot" }, [
el("link", { name: "base_link" }, [
el("visual", {}, [
el("geometry", {}, [el("mesh")])
]),
el("collision", {}, [
el("geometry", {}, [el("box")])
])
])
])
]));
assert.equal(sdfData.sdf.unsupportedVisualCount, 1);
assert.equal(sdfData.sdf.unsupportedCollisionCount, 1);
assert.equal(sdfData.links[0].visuals[0].unsupportedGeometry, "mesh");
assert.equal(sdfData.links[0].collisions[0].unsupportedGeometry, "box");
});
test("parseSdf rejects unsupported pose frames", () => {
assert.throws(
() => parseWithRoot(sdfRoot([
el("model", { name: "robot" }, [
link("base_link"),
el("link", { name: "tool_link" }, [
textEl("pose", "1 0 0 0 0 0", { relative_to: "unrelated_frame" }),
meshVisual("tool_link", "meshes/tool_link.stl")
]),
el("joint", { name: "base_to_tool", type: "fixed" }, [
textEl("parent", "base_link"),
textEl("child", "tool_link")
])
])
])),
/unknown frame|unsupported pose frame/
);
});
@@ -0,0 +1,468 @@
const SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf";
const LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer";
function isPlainObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function localName(node) {
return String(node?.localName || node?.tagName || "").split(":").pop();
}
function childElementsByTag(parent, tagName) {
return Array.from(parent?.childNodes || []).filter((node) => node?.nodeType === 1 && localName(node) === tagName);
}
function parseSrdfStringArray(values, context) {
const seen = new Set();
return values.map((entry) => String(entry || "").trim()).filter((entry) => {
if (!entry) {
throw new Error(`${context} cannot include empty values`);
}
if (seen.has(entry)) {
throw new Error(`${context} includes duplicate ${entry}`);
}
seen.add(entry);
return true;
});
}
function appendUnique(target, values) {
const seen = new Set(target);
for (const value of values) {
if (!seen.has(value)) {
target.push(value);
seen.add(value);
}
}
}
function allJointNamesForChain(urdfData, baseLink, tipLink) {
if (!baseLink || !tipLink || baseLink === tipLink) {
return [];
}
const joints = Array.isArray(urdfData?.joints) ? urdfData.joints : [];
const jointsByParent = new Map();
for (const joint of joints) {
const parentLink = String(joint?.parentLink || "").trim();
const childLink = String(joint?.childLink || "").trim();
if (!parentLink || !childLink) {
continue;
}
const current = jointsByParent.get(parentLink) || [];
current.push(joint);
jointsByParent.set(parentLink, current);
}
const stack = [[baseLink, []]];
const visited = new Set();
while (stack.length) {
const [linkName, path] = stack.pop();
if (linkName === tipLink) {
return path;
}
if (visited.has(linkName)) {
continue;
}
visited.add(linkName);
for (const joint of [...(jointsByParent.get(linkName) || [])].reverse()) {
const childLink = String(joint?.childLink || "").trim();
const jointName = String(joint?.name || "").trim();
if (childLink && jointName) {
stack.push([childLink, [...path, jointName]]);
}
}
}
return [];
}
function jointNamesForGroup(group, urdfData, groupsByName, visiting = new Set()) {
const jointByName = new Map((Array.isArray(urdfData?.joints) ? urdfData.joints : [])
.map((joint) => [String(joint?.name || ""), joint])
.filter(([name]) => name));
const names = [];
const explicitJointNames = Array.isArray(group?.jointNames) ? group.jointNames : [];
if (explicitJointNames.length) {
return explicitJointNames.filter((jointName) => {
const joint = jointByName.get(String(jointName || ""));
return joint && String(joint.type || "") !== "fixed" && !joint.mimic;
});
}
for (const chain of Array.isArray(group?.chains) ? group.chains : []) {
appendUnique(
names,
allJointNamesForChain(urdfData, String(chain?.baseLink || ""), String(chain?.tipLink || ""))
.filter((jointName) => {
const joint = jointByName.get(jointName);
return joint && String(joint.type || "") !== "fixed" && !joint.mimic;
})
);
}
const groupName = String(group?.name || "");
if (groupName) {
visiting.add(groupName);
}
for (const subgroupName of Array.isArray(group?.subgroups) ? group.subgroups : []) {
const key = String(subgroupName || "").trim();
if (!key || visiting.has(key)) {
continue;
}
const subgroup = groupsByName.get(key);
if (subgroup) {
appendUnique(names, jointNamesForGroup(subgroup, urdfData, groupsByName, visiting));
}
}
return names;
}
function linkNamesForGroup(group, urdfData, groupsByName, visiting = new Set()) {
const jointByName = new Map((Array.isArray(urdfData?.joints) ? urdfData.joints : [])
.map((joint) => [String(joint?.name || ""), joint])
.filter(([name]) => name));
const links = new Set(Array.isArray(group?.linkNames) ? group.linkNames.map((link) => String(link || "")).filter(Boolean) : []);
const addJointLinks = (jointName) => {
const joint = jointByName.get(String(jointName || ""));
const childLink = String(joint?.childLink || "").trim();
if (childLink) {
links.add(childLink);
}
};
for (const jointName of Array.isArray(group?.jointNames) ? group.jointNames : []) {
addJointLinks(jointName);
}
for (const chain of Array.isArray(group?.chains) ? group.chains : []) {
const baseLink = String(chain?.baseLink || "").trim();
const tipLink = String(chain?.tipLink || "").trim();
if (tipLink) {
links.add(tipLink);
}
for (const jointName of allJointNamesForChain(urdfData, baseLink, tipLink)) {
addJointLinks(jointName);
}
}
const groupName = String(group?.name || "");
if (groupName) {
visiting.add(groupName);
}
for (const subgroupName of Array.isArray(group?.subgroups) ? group.subgroups : []) {
const key = String(subgroupName || "").trim();
if (!key || visiting.has(key)) {
continue;
}
const subgroup = groupsByName.get(key);
if (subgroup) {
for (const linkName of linkNamesForGroup(subgroup, urdfData, groupsByName, visiting)) {
links.add(linkName);
}
}
}
return links;
}
function linkAdjacentToAnyLink(urdfData, parentLink, childLinks) {
const joints = Array.isArray(urdfData?.joints) ? urdfData.joints : [];
return joints.some((joint) => {
const jointParent = String(joint?.parentLink || "").trim();
const jointChild = String(joint?.childLink || "").trim();
return (jointParent === parentLink && childLinks.has(jointChild)) || (jointChild === parentLink && childLinks.has(jointParent));
});
}
function disabledCollisionSource(reason) {
const normalized = String(reason || "").trim().toLowerCase();
if (normalized.includes("adjacent")) {
return "adjacent";
}
if (["never", "always", "sample", "default"].some((token) => normalized.includes(token))) {
return "sampled";
}
if (normalized.includes("setup") || normalized.includes("assistant")) {
return "setup_assistant";
}
if (normalized.includes("assum")) {
return "assumed";
}
return "manual";
}
function parseLinkedUrdfRef(robot) {
const urdfElement = childElementsByTag(robot, "urdf").find((element) => (
element.namespaceURI === SRDF_METADATA_NAMESPACE ||
element.namespaceURI === LEGACY_EXPLORER_NAMESPACE ||
String(element.tagName || "").startsWith("tcad:") ||
String(element.tagName || "").startsWith("explorer:")
));
const urdf = String(urdfElement?.getAttribute("path") || "").trim();
if (!urdf) {
throw new Error("SRDF must include <tcad:urdf path=\"...\"/> metadata; legacy <explorer:urdf/> is also accepted");
}
if (urdf.includes("\\") || urdf.startsWith("/") || urdf.split("/").some((part) => !part || part === ".")) {
throw new Error(`SRDF tcad:urdf path is invalid: ${urdf}`);
}
if (!urdf.toLowerCase().endsWith(".urdf")) {
throw new Error(`SRDF tcad:urdf path must end in .urdf: ${urdf}`);
}
return urdf;
}
function parsePlanningGroups(robot, urdfData) {
const linkNames = new Set((Array.isArray(urdfData?.links) ? urdfData.links : []).map((link) => String(link?.name || "")).filter(Boolean));
const jointNames = new Set((Array.isArray(urdfData?.joints) ? urdfData.joints : []).map((joint) => String(joint?.name || "")).filter(Boolean));
const groupNames = new Set();
const groups = childElementsByTag(robot, "group").map((groupElement) => {
const name = String(groupElement.getAttribute("name") || "").trim();
if (!name) {
throw new Error("SRDF planning group name is required");
}
if (groupNames.has(name)) {
throw new Error(`Duplicate SRDF planning group: ${name}`);
}
groupNames.add(name);
const parsedJointNames = parseSrdfStringArray(
childElementsByTag(groupElement, "joint").map((joint) => joint.getAttribute("name")),
`SRDF planning group ${name} jointNames`
);
const parsedLinkNames = parseSrdfStringArray(
childElementsByTag(groupElement, "link").map((link) => link.getAttribute("name")),
`SRDF planning group ${name} linkNames`
);
for (const jointName of parsedJointNames) {
if (!jointNames.has(jointName)) {
throw new Error(`SRDF planning group ${name} references missing joint ${jointName}`);
}
}
for (const linkName of parsedLinkNames) {
if (!linkNames.has(linkName)) {
throw new Error(`SRDF planning group ${name} references missing link ${linkName}`);
}
}
const chains = childElementsByTag(groupElement, "chain").map((chain) => {
const baseLink = String(chain.getAttribute("base_link") || "").trim();
const tipLink = String(chain.getAttribute("tip_link") || "").trim();
if (!linkNames.has(baseLink) || !linkNames.has(tipLink)) {
throw new Error(`SRDF planning group ${name} chain references missing link`);
}
return { baseLink, tipLink };
});
const subgroups = parseSrdfStringArray(
childElementsByTag(groupElement, "group").map((group) => group.getAttribute("name")),
`SRDF planning group ${name} subgroups`
);
return {
name,
jointNames: parsedJointNames,
linkNames: parsedLinkNames,
chains,
subgroups
};
});
if (!groups.length) {
throw new Error("SRDF must define at least one planning group");
}
for (const group of groups) {
for (const subgroup of group.subgroups) {
if (!groupNames.has(subgroup)) {
throw new Error(`SRDF planning group ${group.name} references missing subgroup ${subgroup}`);
}
}
}
return groups;
}
function groupTipLink(group) {
if (Array.isArray(group?.linkNames) && group.linkNames.length) {
return String(group.linkNames[group.linkNames.length - 1] || "");
}
if (Array.isArray(group?.chains) && group.chains.length) {
return String(group.chains[group.chains.length - 1]?.tipLink || "");
}
return "";
}
function endEffectorLink(endEffector, groupsByName) {
return groupTipLink(groupsByName.get(endEffector.group)) || endEffector.parentLink;
}
function parseEndEffectors(robot, urdfData, planningGroups) {
const linkNames = new Set((Array.isArray(urdfData?.links) ? urdfData.links : []).map((link) => String(link?.name || "")).filter(Boolean));
const groupsByName = new Map(planningGroups.map((group) => [group.name, group]));
const endEffectorNames = new Set();
return childElementsByTag(robot, "end_effector").map((element) => {
const name = String(element.getAttribute("name") || "").trim();
if (!name) {
throw new Error("SRDF end effector name is required");
}
if (endEffectorNames.has(name)) {
throw new Error(`Duplicate SRDF end effector: ${name}`);
}
endEffectorNames.add(name);
const parentLink = String(element.getAttribute("parent_link") || "").trim();
const group = String(element.getAttribute("group") || "").trim();
const parentGroup = String(element.getAttribute("parent_group") || "").trim();
if (!linkNames.has(parentLink)) {
throw new Error(`SRDF end effector ${name} references missing parent_link ${parentLink || "(missing)"}`);
}
if (!groupsByName.has(group)) {
throw new Error(`SRDF end effector ${name} references missing group ${group || "(missing)"}`);
}
if (parentGroup && !groupsByName.has(parentGroup)) {
throw new Error(`SRDF end effector ${name} references missing parent_group ${parentGroup}`);
}
const endEffector = { name, parentLink, group, parentGroup };
const link = endEffectorLink(endEffector, groupsByName);
if (!linkNames.has(link)) {
throw new Error(`SRDF end effector ${name} references missing link ${link || "(missing)"}`);
}
const endEffectorLinks = linkNamesForGroup(groupsByName.get(group), urdfData, groupsByName);
if (parentGroup) {
const parentGroupLinks = linkNamesForGroup(groupsByName.get(parentGroup), urdfData, groupsByName);
const overlap = [...endEffectorLinks].filter((linkName) => parentGroupLinks.has(linkName)).sort();
if (overlap.length) {
throw new Error(`SRDF end effector ${name} group shares link(s) with parent_group: ${overlap.join(", ")}`);
}
if (!parentGroupLinks.has(parentLink)) {
throw new Error(`SRDF end effector ${name} parent_link is not in parent_group ${parentGroup}`);
}
}
if (endEffectorLinks.size && !endEffectorLinks.has(parentLink) && !linkAdjacentToAnyLink(urdfData, parentLink, endEffectorLinks)) {
throw new Error(`SRDF end effector ${name} parent_link is not adjacent to its group`);
}
return {
...endEffector,
link
};
});
}
function parseGroupStates(robot, urdfData, planningGroups) {
const groupNames = new Set(planningGroups.map((group) => group.name));
const groupsByName = new Map(planningGroups.map((group) => [group.name, group]));
const jointByName = new Map((Array.isArray(urdfData?.joints) ? urdfData.joints : []).map((joint) => [String(joint?.name || ""), joint]).filter(([name]) => name));
const stateNamesByGroup = new Set();
return childElementsByTag(robot, "group_state").map((element) => {
const name = String(element.getAttribute("name") || "").trim();
const group = String(element.getAttribute("group") || "").trim();
if (!name || !groupNames.has(group)) {
throw new Error(`SRDF group_state ${name || "(missing)"} references missing group ${group || "(missing)"}`);
}
const stateKey = `${group}/${name}`;
if (stateNamesByGroup.has(stateKey)) {
throw new Error(`Duplicate SRDF group_state ${stateKey}`);
}
stateNamesByGroup.add(stateKey);
const groupJointNames = new Set(jointNamesForGroup(groupsByName.get(group), urdfData, groupsByName));
const seenJointNames = new Set();
const jointValuesByName = {};
for (const jointElement of childElementsByTag(element, "joint")) {
const jointName = String(jointElement.getAttribute("name") || "").trim();
const value = Number(jointElement.getAttribute("value"));
const joint = jointByName.get(jointName);
if (!joint || !Number.isFinite(value)) {
throw new Error(`SRDF group_state ${name} has invalid joint value ${jointName || "(missing)"}`);
}
if (seenJointNames.has(jointName)) {
throw new Error(`SRDF group_state ${name} includes duplicate joint ${jointName}`);
}
seenJointNames.add(jointName);
if (!groupJointNames.has(jointName)) {
throw new Error(`SRDF group_state ${name} joint ${jointName} is not in group ${group}`);
}
if (String(joint.type || "") === "fixed" || joint.mimic) {
throw new Error(`SRDF group_state ${name} cannot set fixed or mimic joint ${jointName}`);
}
if (String(joint.type || "") !== "continuous") {
const lower = String(joint.type || "") === "revolute" ? Number(joint.minValueDeg) * (Math.PI / 180) : Number(joint.minValueDeg);
const upper = String(joint.type || "") === "revolute" ? Number(joint.maxValueDeg) * (Math.PI / 180) : Number(joint.maxValueDeg);
if (Number.isFinite(lower) && value < lower) {
throw new Error(`SRDF group_state ${name} joint ${jointName} is below its URDF lower limit`);
}
if (Number.isFinite(upper) && value > upper) {
throw new Error(`SRDF group_state ${name} joint ${jointName} is above its URDF upper limit`);
}
}
jointValuesByName[jointName] = value;
}
return { name, group, jointValuesByName, jointValuesByNameRad: jointValuesByName };
});
}
function parseDisabledCollisionPairs(robot, urdfData) {
const linkNames = new Set((Array.isArray(urdfData?.links) ? urdfData.links : []).map((link) => String(link?.name || "")).filter(Boolean));
const seenPairs = new Set();
return childElementsByTag(robot, "disable_collisions").map((element) => {
const link1 = String(element.getAttribute("link1") || "").trim();
const link2 = String(element.getAttribute("link2") || "").trim();
const reason = String(element.getAttribute("reason") || "").trim();
if (!linkNames.has(link1) || !linkNames.has(link2)) {
throw new Error("SRDF disabled collision pair references missing link");
}
if (link1 === link2) {
throw new Error("SRDF disabled collision pair cannot repeat the same link");
}
const pairKey = [link1, link2].sort().join("/");
if (seenPairs.has(pairKey)) {
throw new Error(`Duplicate SRDF disabled collision pair ${pairKey}`);
}
seenPairs.add(pairKey);
if (!reason) {
throw new Error(`SRDF disabled collision pair ${pairKey} requires a reason`);
}
return {
link1,
link2,
reason,
source: disabledCollisionSource(reason)
};
});
}
export function motionFromSrdf(srdf) {
if (!isPlainObject(srdf)) {
return null;
}
return {
transport: "moveit2Server",
planningGroups: srdf.planningGroups,
endEffectors: srdf.endEffectors,
groupStates: srdf.groupStates,
disabledCollisionPairs: srdf.disabledCollisionPairs,
srdf
};
}
export function parseSrdf(xmlText, { sourceUrl = "", urdfData = null } = {}) {
if (typeof DOMParser === "undefined") {
throw new Error("DOMParser is unavailable in this environment");
}
const document = new DOMParser().parseFromString(String(xmlText || ""), "application/xml");
const parseError = document.querySelector("parsererror");
if (parseError) {
throw new Error("Failed to parse SRDF XML");
}
const robot = document.documentElement;
if (!robot || robot.tagName !== "robot") {
throw new Error("SRDF root element must be <robot>");
}
const robotName = String(robot.getAttribute("name") || "").trim();
if (!robotName) {
throw new Error("SRDF robot name is required");
}
if (urdfData?.robotName && urdfData.robotName !== robotName) {
throw new Error("SRDF robot name must match the linked URDF robot name");
}
const urdf = parseLinkedUrdfRef(robot);
const planningGroups = parsePlanningGroups(robot, urdfData);
const endEffectors = parseEndEffectors(robot, urdfData, planningGroups);
const groupStates = parseGroupStates(robot, urdfData, planningGroups);
const disabledCollisionPairs = parseDisabledCollisionPairs(robot, urdfData);
return {
kind: "texttocad-srdf",
urdf,
srdf: sourceUrl,
robotName,
planningGroups,
endEffectors,
groupStates,
disabledCollisionPairs
};
}
@@ -0,0 +1,173 @@
import assert from "node:assert/strict";
import test from "node:test";
import { motionFromSrdf, parseSrdf } from "./parseSrdf.js";
const SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf";
const LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer";
class FakeElement {
constructor(tagName, attributes = {}, children = [], namespaceURI = null) {
this.nodeType = 1;
this.tagName = tagName;
this.localName = String(tagName || "").split(":").pop();
this.namespaceURI = namespaceURI;
this._attributes = { ...attributes };
this.childNodes = children;
}
getAttribute(name) {
return Object.hasOwn(this._attributes, name) ? this._attributes[name] : null;
}
}
class FakeDocument {
constructor(documentElement) {
this.documentElement = documentElement;
}
querySelector(selector) {
return selector === "parsererror" ? null : null;
}
}
function withFakeDomParser(document, callback) {
const previous = globalThis.DOMParser;
globalThis.DOMParser = class FakeDomParser {
parseFromString() {
return document;
}
};
try {
return callback();
} finally {
globalThis.DOMParser = previous;
}
}
function sampleUrdfData() {
return {
robotName: "sample_robot",
rootLink: "base_link",
links: [
{ name: "base_link" },
{ name: "arm_link" },
{ name: "wrist_link" },
{ name: "tool_link" }
],
joints: [
{ name: "base_to_arm", type: "revolute", parentLink: "base_link", childLink: "arm_link", minValueDeg: -90, maxValueDeg: 90 },
{ name: "arm_to_wrist", type: "revolute", parentLink: "arm_link", childLink: "wrist_link", minValueDeg: -90, maxValueDeg: 90 },
{ name: "wrist_to_tool", type: "fixed", parentLink: "wrist_link", childLink: "tool_link" }
]
};
}
test("parseSrdf reads MoveIt2 semantics and linked URDF metadata directly from SRDF XML", () => {
const robot = new FakeElement("robot", { name: "sample_robot" }, [
new FakeElement("tcad:urdf", { path: "sample_robot.urdf" }, [], SRDF_METADATA_NAMESPACE),
new FakeElement("group", { name: "manipulator" }, [
new FakeElement("chain", { base_link: "base_link", tip_link: "wrist_link" })
]),
new FakeElement("group", { name: "gripper_group" }, [
new FakeElement("link", { name: "tool_link" })
]),
new FakeElement("end_effector", {
name: "gripper",
parent_link: "wrist_link",
group: "gripper_group",
parent_group: "manipulator"
}),
new FakeElement("group_state", { name: "home", group: "manipulator" }, [
new FakeElement("joint", { name: "base_to_arm", value: "0" }),
new FakeElement("joint", { name: "arm_to_wrist", value: "0.25" })
]),
new FakeElement("disable_collisions", {
link1: "base_link",
link2: "arm_link",
reason: "Adjacent"
})
]);
const srdfData = withFakeDomParser(new FakeDocument(robot), () => parseSrdf("<robot />", {
sourceUrl: "/workspace/sample_robot.srdf",
urdfData: sampleUrdfData()
}));
assert.equal(srdfData.kind, "texttocad-srdf");
assert.equal(srdfData.urdf, "sample_robot.urdf");
assert.equal(srdfData.srdf, "/workspace/sample_robot.srdf");
assert.equal(srdfData.planningGroups[0].name, "manipulator");
assert.equal(srdfData.planningGroups[0].chains[0].tipLink, "wrist_link");
assert.equal(srdfData.endEffectors[0].link, "tool_link");
assert.equal(srdfData.groupStates[0].jointValuesByName.arm_to_wrist, 0.25);
assert.equal(srdfData.groupStates[0].jointValuesByNameRad.arm_to_wrist, 0.25);
assert.equal(srdfData.disabledCollisionPairs[0].reason, "Adjacent");
assert.equal(srdfData.disabledCollisionPairs[0].source, "adjacent");
});
test("parseSrdf accepts legacy explorer URDF metadata", () => {
const robot = new FakeElement("robot", { name: "sample_robot" }, [
new FakeElement("explorer:urdf", { path: "sample_robot.urdf" }, [], LEGACY_EXPLORER_NAMESPACE),
new FakeElement("group", { name: "manipulator" }, [
new FakeElement("joint", { name: "base_to_arm" })
])
]);
const srdfData = withFakeDomParser(new FakeDocument(robot), () => parseSrdf("<robot />", {
sourceUrl: "/workspace/sample_robot.srdf",
urdfData: sampleUrdfData()
}));
assert.equal(srdfData.urdf, "sample_robot.urdf");
assert.equal(srdfData.planningGroups[0].jointNames[0], "base_to_arm");
});
test("motionFromSrdf converts direct SRDF data into MoveIt2 CAD Viewer controls", () => {
const srdfData = {
planningGroups: [{ name: "manipulator" }],
endEffectors: [{ name: "gripper" }],
groupStates: [{ name: "home", group: "manipulator", jointValuesByName: { base_to_arm: 0.5 } }],
disabledCollisionPairs: []
};
const motion = motionFromSrdf(srdfData);
assert.equal(motion.transport, "moveit2Server");
assert.equal(motion.srdf, srdfData);
assert.deepEqual(motion.endEffectors, srdfData.endEffectors);
assert.deepEqual(motion.groupStates, srdfData.groupStates);
});
test("parseSrdf rejects missing linked URDF metadata", () => {
const robot = new FakeElement("robot", { name: "sample_robot" }, [
new FakeElement("group", { name: "manipulator" }, [
new FakeElement("joint", { name: "base_to_arm" })
])
]);
assert.throws(
() => withFakeDomParser(new FakeDocument(robot), () => parseSrdf("<robot />", {
sourceUrl: "/workspace/sample_robot.srdf",
urdfData: sampleUrdfData()
})),
/tcad:urdf/
);
});
test("parseSrdf rejects robot names that do not match the linked URDF", () => {
const robot = new FakeElement("robot", { name: "other_robot" }, [
new FakeElement("tcad:urdf", { path: "sample_robot.urdf" }, [], SRDF_METADATA_NAMESPACE),
new FakeElement("group", { name: "manipulator" }, [
new FakeElement("joint", { name: "base_to_arm" })
])
]);
assert.throws(
() => withFakeDomParser(new FakeDocument(robot), () => parseSrdf("<robot />", {
sourceUrl: "/workspace/sample_robot.srdf",
urdfData: sampleUrdfData()
})),
/must match the linked URDF robot name/
);
});
@@ -0,0 +1,517 @@
import { multiplyTransforms } from "./kinematics.js";
const IDENTITY_TRANSFORM = Object.freeze([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
function childElementsByTag(parent, tagName) {
return Array.from(parent?.childNodes || []).filter((node) => node?.nodeType === 1 && node.tagName === tagName);
}
function parseNumberList(value, count, fallback) {
if (typeof value !== "string" || !value.trim()) {
return [...fallback];
}
const parsed = value.trim().split(/\s+/).map((entry) => Number(entry));
if (parsed.length !== count || parsed.some((entry) => !Number.isFinite(entry))) {
throw new Error(`Expected ${count} numeric values, received ${JSON.stringify(value)}`);
}
return parsed;
}
function parseRequiredNumberList(value, count, context) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${context} must declare ${count} numeric values`);
}
const parsed = value.trim().split(/\s+/).map((entry) => Number(entry));
if (parsed.length !== count || parsed.some((entry) => !Number.isFinite(entry))) {
throw new Error(`${context} must declare ${count} numeric values`);
}
return parsed;
}
function parsePositiveNumberAttribute(element, attributeName, context) {
const value = Number(element?.getAttribute(attributeName));
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${context} must declare a positive ${attributeName}`);
}
return value;
}
function translationTransform(x, y, z) {
return [
1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1
];
}
function scaleTransform(x, y, z) {
return [
x, 0, 0, 0,
0, y, 0, 0,
0, 0, z, 0,
0, 0, 0, 1
];
}
function rotationTransformFromRpy(roll, pitch, yaw) {
const sr = Math.sin(roll);
const cr = Math.cos(roll);
const sp = Math.sin(pitch);
const cp = Math.cos(pitch);
const sy = Math.sin(yaw);
const cy = Math.cos(yaw);
return [
cy * cp, (cy * sp * sr) - (sy * cr), (cy * sp * cr) + (sy * sr), 0,
sy * cp, (sy * sp * sr) + (cy * cr), (sy * sp * cr) - (cy * sr), 0,
-sp, cp * sr, cp * cr, 0,
0, 0, 0, 1
];
}
function parseOriginTransform(originElement) {
if (!originElement) {
return [...IDENTITY_TRANSFORM];
}
const [x, y, z] = parseNumberList(originElement.getAttribute("xyz"), 3, [0, 0, 0]);
const [roll, pitch, yaw] = parseNumberList(originElement.getAttribute("rpy"), 3, [0, 0, 0]);
return multiplyTransforms(
translationTransform(x, y, z),
rotationTransformFromRpy(roll, pitch, yaw)
);
}
function parseScaleTransform(meshElement) {
const [x, y, z] = parseNumberList(meshElement?.getAttribute("scale"), 3, [1, 1, 1]);
return scaleTransform(x, y, z);
}
function normalizeAbsoluteUrl(url) {
if (url instanceof URL) {
return url.toString();
}
return new URL(url, globalThis.window?.location?.href || "http://localhost/").toString();
}
function urlHasExplicitOrigin(value) {
try {
const url = new URL(String(value || ""));
return Boolean(url.origin && url.origin !== "null");
} catch {
return false;
}
}
function assetRefHasExplicitOrigin(value) {
const rawValue = String(value || "").trim();
if (!rawValue || rawValue.startsWith("package://")) {
return false;
}
return urlHasExplicitOrigin(rawValue);
}
function resolvedMeshUrlString(resolvedUrl, { filename, sourceUrl }) {
if (urlHasExplicitOrigin(sourceUrl) || assetRefHasExplicitOrigin(filename)) {
return resolvedUrl.toString();
}
return `${resolvedUrl.pathname}${resolvedUrl.search}`;
}
function normalizeFileRefSegments(value) {
const rawValue = String(value || "").replace(/\\/g, "/");
const absolute = rawValue.startsWith("/");
const parts = [];
for (const part of rawValue.split("/")) {
if (!part || part === ".") {
continue;
}
if (part === "..") {
if (parts.length && parts[parts.length - 1] !== "..") {
parts.pop();
} else if (!absolute) {
parts.push(part);
}
continue;
}
parts.push(part);
}
return `${absolute ? "/" : ""}${parts.join("/")}`;
}
function dirnameFileRef(value) {
const normalized = String(value || "").replace(/\\/g, "/");
const index = normalized.lastIndexOf("/");
return index >= 0 ? normalized.slice(0, index + 1) : "";
}
function resolveLocalAssetFileRef(sourceFileRef, filename) {
const rawFilename = String(filename || "").trim();
if (!rawFilename || /^[a-z][a-z0-9+.-]*:/i.test(rawFilename)) {
return "";
}
if (rawFilename.startsWith("/")) {
return normalizeFileRefSegments(rawFilename);
}
return normalizeFileRefSegments(`${dirnameFileRef(sourceFileRef)}${rawFilename}`);
}
function resolveCadAssetMeshUrl(filename, sourceUrl) {
const source = new URL(normalizeAbsoluteUrl(sourceUrl));
if (source.pathname !== "/__cad/asset") {
return "";
}
const sourceFileRef = source.searchParams.get("file") || "";
const meshFileRef = resolveLocalAssetFileRef(sourceFileRef, filename);
if (!meshFileRef) {
return "";
}
const resolved = new URL("/__cad/asset", source);
resolved.searchParams.set("file", meshFileRef);
for (const [key, value] of source.searchParams.entries()) {
if (key !== "file") {
resolved.searchParams.set(key, value);
}
}
return `${resolved.pathname}${resolved.search}`;
}
function resolveMeshUrl(filename, sourceUrl) {
const assetUrl = resolveCadAssetMeshUrl(filename, sourceUrl);
if (assetUrl) {
return assetUrl;
}
const normalizedSourceUrl = normalizeAbsoluteUrl(sourceUrl);
let resolvedUrl;
if (filename.startsWith("package://")) {
resolvedUrl = new URL(filename.slice("package://".length).replace(/^\/+/, ""), new URL("/", normalizedSourceUrl));
} else {
resolvedUrl = new URL(filename, normalizedSourceUrl);
}
return resolvedMeshUrlString(resolvedUrl, { filename, sourceUrl });
}
function labelForMeshFilename(filename) {
const parts = String(filename || "").split("/");
return parts[parts.length - 1] || "mesh";
}
function parsePrimitiveGeometry(geometryElement, context) {
const boxElement = childElementsByTag(geometryElement, "box")[0];
if (boxElement) {
const size = parseRequiredNumberList(boxElement.getAttribute("size"), 3, `${context} box size`);
if (size.some((value) => value <= 0)) {
throw new Error(`${context} box size values must be positive`);
}
return {
type: "box",
size
};
}
const cylinderElement = childElementsByTag(geometryElement, "cylinder")[0];
if (cylinderElement) {
return {
type: "cylinder",
radius: parsePositiveNumberAttribute(cylinderElement, "radius", `${context} cylinder`),
length: parsePositiveNumberAttribute(cylinderElement, "length", `${context} cylinder`)
};
}
const sphereElement = childElementsByTag(geometryElement, "sphere")[0];
if (sphereElement) {
return {
type: "sphere",
radius: parsePositiveNumberAttribute(sphereElement, "radius", `${context} sphere`)
};
}
return null;
}
function parseRgbaColor(rgbaText, context) {
const values = parseNumberList(rgbaText, 4, [0, 0, 0, 1]);
if (values.some((value) => value < 0 || value > 1)) {
throw new Error(`${context} must use rgba values between 0 and 1`);
}
return `#${values.slice(0, 3).map((value) => {
const component = Math.round(value * 255);
return component.toString(16).padStart(2, "0");
}).join("")}`;
}
function materialColorFromElement(materialElement, context) {
const colorElement = childElementsByTag(materialElement, "color")[0];
const rgbaText = String(colorElement?.getAttribute("rgba") || "").trim();
if (!rgbaText) {
return "";
}
return parseRgbaColor(rgbaText, context);
}
function parseNamedMaterialColors(robotElement) {
const namedMaterials = new Map();
for (const materialElement of childElementsByTag(robotElement, "material")) {
const name = String(materialElement.getAttribute("name") || "").trim();
if (!name) {
continue;
}
const color = materialColorFromElement(materialElement, `URDF material ${name}`);
if (!color) {
continue;
}
namedMaterials.set(name, color);
}
return namedMaterials;
}
function resolveVisualColor(visualElement, namedMaterialColors, { linkName, visualIndex }) {
const materialElement = childElementsByTag(visualElement, "material")[0];
if (!materialElement) {
return "";
}
const inlineColor = materialColorFromElement(materialElement, `URDF link ${linkName} visual ${visualIndex} material`);
if (inlineColor) {
return inlineColor;
}
const materialName = String(materialElement.getAttribute("name") || "").trim();
return materialName ? String(namedMaterialColors.get(materialName) || "") : "";
}
function parseJointMimic(jointElement, jointName) {
const mimicElement = childElementsByTag(jointElement, "mimic")[0];
if (!mimicElement) {
return null;
}
const joint = String(mimicElement.getAttribute("joint") || "").trim();
if (!joint) {
throw new Error(`URDF mimic joint ${jointName} must reference another joint`);
}
const multiplierText = String(mimicElement.getAttribute("multiplier") ?? "1").trim() || "1";
const offsetText = String(mimicElement.getAttribute("offset") ?? "0").trim() || "0";
const multiplier = Number(multiplierText);
const offset = Number(offsetText);
if (!Number.isFinite(multiplier) || !Number.isFinite(offset)) {
throw new Error(`URDF mimic joint ${jointName} has invalid multiplier or offset`);
}
return {
joint,
multiplier,
offset
};
}
function parseJoint(jointElement, linkNames) {
const name = String(jointElement.getAttribute("name") || "").trim();
if (!name) {
throw new Error("URDF joint name is required");
}
const type = String(jointElement.getAttribute("type") || "").trim().toLowerCase();
if (!["fixed", "continuous", "revolute", "prismatic"].includes(type)) {
throw new Error(`Unsupported URDF joint type: ${type || "(missing)"}`);
}
const parentElement = childElementsByTag(jointElement, "parent")[0];
const childElement = childElementsByTag(jointElement, "child")[0];
const parentLink = String(parentElement?.getAttribute("link") || "").trim();
const childLink = String(childElement?.getAttribute("link") || "").trim();
if (!parentLink || !childLink) {
throw new Error(`URDF joint ${name} must declare parent and child links`);
}
if (!linkNames.has(parentLink) || !linkNames.has(childLink)) {
throw new Error(`URDF joint ${name} references missing links`);
}
const axis = type === "fixed"
? [1, 0, 0]
: parseNumberList(childElementsByTag(jointElement, "axis")[0]?.getAttribute("xyz"), 3, [1, 0, 0]);
let minValueDeg = 0;
let maxValueDeg = 0;
if (type === "continuous") {
minValueDeg = -180;
maxValueDeg = 180;
} else if (type === "revolute" || type === "prismatic") {
const limitElement = childElementsByTag(jointElement, "limit")[0];
if (!limitElement) {
throw new Error(`URDF ${type} joint ${name} requires <limit>`);
}
const lower = Number(limitElement.getAttribute("lower"));
const upper = Number(limitElement.getAttribute("upper"));
if (!Number.isFinite(lower) || !Number.isFinite(upper)) {
throw new Error(`URDF ${type} joint ${name} has invalid limits`);
}
if (type === "revolute") {
minValueDeg = (lower * 180) / Math.PI;
maxValueDeg = (upper * 180) / Math.PI;
} else {
minValueDeg = lower;
maxValueDeg = upper;
}
}
return {
name,
type,
parentLink,
childLink,
originTransform: parseOriginTransform(childElementsByTag(jointElement, "origin")[0]),
axis,
defaultValueDeg: 0,
minValueDeg,
maxValueDeg,
mimic: parseJointMimic(jointElement, name)
};
}
function validateMimicJoints(joints) {
const jointNames = new Set(joints.map((joint) => joint.name));
for (const joint of joints) {
if (!joint.mimic) {
continue;
}
if (!jointNames.has(joint.mimic.joint)) {
throw new Error(`URDF mimic joint ${joint.name} references missing joint ${joint.mimic.joint}`);
}
}
}
function validateTree(links, joints) {
const linkNames = new Set(links.map((link) => link.name));
const children = new Set();
const jointsByParent = new Map();
const jointNames = new Set();
for (const joint of joints) {
if (jointNames.has(joint.name)) {
throw new Error(`Duplicate URDF joint name: ${joint.name}`);
}
jointNames.add(joint.name);
if (children.has(joint.childLink)) {
throw new Error(`URDF link ${joint.childLink} has multiple parents`);
}
children.add(joint.childLink);
const current = jointsByParent.get(joint.parentLink) || [];
current.push(joint.childLink);
jointsByParent.set(joint.parentLink, current);
}
const rootCandidates = [...linkNames].filter((linkName) => !children.has(linkName));
if (rootCandidates.length !== 1) {
throw new Error(`URDF must form a single rooted tree; found roots ${JSON.stringify(rootCandidates)}`);
}
const rootLink = rootCandidates[0];
const visited = new Set();
const visiting = new Set();
const visit = (linkName) => {
if (visited.has(linkName)) {
return;
}
if (visiting.has(linkName)) {
throw new Error("URDF joint graph contains a cycle");
}
visiting.add(linkName);
for (const childLink of jointsByParent.get(linkName) || []) {
visit(childLink);
}
visiting.delete(linkName);
visited.add(linkName);
};
visit(rootLink);
if (visited.size !== links.length) {
const missing = links.map((link) => link.name).filter((linkName) => !visited.has(linkName));
throw new Error(`URDF leaves links disconnected from the root: ${JSON.stringify(missing)}`);
}
return rootLink;
}
export function parseUrdf(xmlText, { sourceUrl } = {}) {
if (typeof DOMParser === "undefined") {
throw new Error("DOMParser is unavailable in this environment");
}
const document = new DOMParser().parseFromString(String(xmlText || ""), "application/xml");
const parseError = document.querySelector("parsererror");
if (parseError) {
throw new Error("Failed to parse URDF XML");
}
const robot = document.documentElement;
if (!robot || robot.tagName !== "robot") {
throw new Error("URDF root element must be <robot>");
}
const robotName = String(robot.getAttribute("name") || "").trim();
if (!robotName) {
throw new Error("URDF robot name is required");
}
const namedMaterialColors = parseNamedMaterialColors(robot);
const links = childElementsByTag(robot, "link").map((linkElement) => {
const name = String(linkElement.getAttribute("name") || "").trim();
if (!name) {
throw new Error("URDF link name is required");
}
const visuals = childElementsByTag(linkElement, "visual").map((visualElement, index) => {
const geometryElement = childElementsByTag(visualElement, "geometry")[0];
const meshElement = geometryElement ? childElementsByTag(geometryElement, "mesh")[0] : null;
const visualBase = {
id: `${name}:v${index + 1}`,
color: resolveVisualColor(visualElement, namedMaterialColors, {
linkName: name,
visualIndex: index + 1
})
};
if (meshElement) {
const filename = String(meshElement.getAttribute("filename") || "").trim();
if (!filename) {
throw new Error(`URDF link ${name} visual ${index + 1} is missing a mesh filename`);
}
return {
...visualBase,
label: labelForMeshFilename(filename),
meshUrl: resolveMeshUrl(filename, sourceUrl || "/"),
localTransform: multiplyTransforms(
parseOriginTransform(childElementsByTag(visualElement, "origin")[0]),
parseScaleTransform(meshElement)
)
};
}
const primitive = geometryElement
? parsePrimitiveGeometry(geometryElement, `URDF link ${name} visual ${index + 1}`)
: null;
if (!primitive) {
throw new Error(`URDF link ${name} uses unsupported visual geometry`);
}
return {
...visualBase,
label: primitive.type,
primitive,
localTransform: parseOriginTransform(childElementsByTag(visualElement, "origin")[0])
};
});
return {
name,
visuals
};
});
const linkNames = new Set();
for (const link of links) {
if (linkNames.has(link.name)) {
throw new Error(`Duplicate URDF link name: ${link.name}`);
}
linkNames.add(link.name);
}
const parsedJoints = childElementsByTag(robot, "joint").map((jointElement) => parseJoint(jointElement, linkNames));
const joints = parsedJoints;
validateMimicJoints(joints);
const rootLink = validateTree(links, joints);
return {
robotName,
rootLink,
rootWorldTransform: [...IDENTITY_TRANSFORM],
links,
joints,
motion: null,
srdf: null
};
}

Some files were not shown because too many files have changed in this diff Show More