first commit
This commit is contained in:
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user