first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,87 @@
# cadjs
`cadjs` is the shared JavaScript runtime for CAD Viewer, documentation previews,
and generated snapshot browser assets. It owns reusable parsing, scene-building,
explicit-CAD rendering, STEP topology, selector, mesh, robot-description, and
artifact helpers without depending on React or application chrome.
Implicit CAD model loading, shader rendering, snapshots, SDF sampling, and
mesh exports live in the sibling `implicitjs` package. Consumers that need
implicit CAD should depend on `implicitjs` directly alongside `cadjs`.
## Install
In this workbench, consumers link the package directly:
```json
{
"dependencies": {
"cadjs": "file:../packages/cadjs"
}
}
```
The package exports source files so local consumers can alias or install it and
pick up edits without copying generated bundles.
## Layout
- `src/index.js`: public package entrypoint.
- `src/common/`: browser-safe render pipeline used by interactive viewer,
docs previews, and snapshot browser runtime.
- `src/lib/`: lower-level first-party helpers for assets, formats, mesh
decoding, selector runtimes, STEP sidecars, and URDF/SRDF/SDF.
- `src/lib/viewer/`: shared non-React 3D viewer runtime helpers such as picking,
clipping, drawing geometry, reference geometry, stage theme, scene scale, and
visual state.
- `scripts/run-tests.mjs`: package test discovery and Node test runner wrapper.
- `docs/`: reference docs for package-owned APIs.
Tests live beside the modules they cover as `*.test.js` or `*.test.mjs`.
## Boundaries
Keep this package UI-framework agnostic. Do not add React components, Tailwind
helpers, browser workbench state, or CAD Viewer chrome utilities here. Those
belong in consumer apps such as `viewer/`.
Prefer moving logic into `cadjs` when it is reusable across:
- interactive CAD Viewer rendering,
- docs or marketing previews,
- generated snapshot browser runtime,
- testable CAD parsing or sidecar preparation.
Prefer moving implicit model, shader, snapshot, SDF, or mesh-export behavior
into `implicitjs` instead.
Keep app-specific workflows in the app. A useful rule of thumb: `cadjs` should
understand CAD data and rendering state, while `viewer/` should understand user
interface and product workflow.
## Commands
From this package directory:
```bash
npm test
```
From the workbench repository root:
```bash
npm --prefix packages/cadjs test
```
The test command discovers all `*.test.js` and `*.test.mjs` files under
`src/`. To run one file:
```bash
node scripts/run-tests.mjs src/lib/themeSettings.test.js
```
## Reference
- [Render pipeline](./docs/render-pipeline.md): `loadSource`, `buildModel`,
`renderModel`, and `captureModel` stages shared by viewer, docs, and
snapshots.
@@ -0,0 +1,362 @@
# Render Pipeline
`cadjs` exposes a staged render pipeline for shared viewer, docs, and generated
snapshot browser-runtime work:
```js
const source = await loadSource(input, sourceOptions);
const model = buildModel(THREE, source, modelOptions);
const viewport = renderModel(THREE, model, viewportOptions);
const result = await captureModel(viewport, captureOptions);
```
The stages keep ownership narrow:
- `loadSource` owns source and sidecar loading plus file-kind validation.
- `buildModel` owns the CAD object graph, records, selection, clipping,
materials, topology/display edges, and STEP parameter effects.
- `renderModel` owns renderer, scene, camera, lighting, background, floor,
framing, resizing, and render loop concerns.
- `captureModel` owns deterministic snapshot outputs without filesystem writes.
The CAD skill's Python snapshot CLI remains responsible for job parsing, path
resolution, Playwright routing, and writing returned outputs to disk.
## Modules
### `common/source.js`
```js
import {
loadSource,
stepParameterFrameRuntime
} from "cadjs/common/source.js";
```
`loadSource(input, options)` returns a normalized render source:
```js
{
kind,
meshData,
selectorRuntime,
displayEdgeRuntime,
stepParameterSource,
resolved,
url,
glbUrl,
cadPath
}
```
Accepted input fields:
- `kind`: `step`, `stp`, `glb`, `stl`, `3mf`, or inferred from a URL.
- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.
- `url`: source URL for non-STEP GLB loading.
- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.
- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.
- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller
already owns sidecar loading.
- `stepParameters`: raw STEP render values or animation envelope.
- `stepParameterUrl` or `resolved.stepParameterUrl`: `.step.js`/`.stp.js`
parameter sidecar URL.
STEP-only options are rejected for non-STEP sources. The old shared `params`
field is rejected; use `stepParameters`.
Use `stepParameterFrameRuntime(stepParameterSource, frameIndex)` to turn the
loaded STEP parameter source into the runtime object accepted by `buildModel`
for a still or animation frame.
### `common/cadScene.js`
```js
import {
buildModel,
fitCameraToModel
} from "cadjs/common/cadScene.js";
```
`buildModel(THREE, source, settings)` returns a model API:
```js
{
source,
meshData,
root,
modelGroup,
edgesGroup,
displayRecords,
records,
bounds,
radius,
runtime,
update(nextSettings),
dispose()
}
```
`source` can be a `loadSource()` result or raw mesh data. The model owns the
Three.js object graph and its mutable state.
Common settings:
- `theme`: normalized or raw theme settings.
- `displayMode`: `solid`, `rendered`, `transparent`, `hidden_edges`,
`hidden_lines_removed`, `unshaded`, or `wireframe`.
- `scale`/`sceneScale`: CAD or robot scene scale.
- `selection`: internal selection/filtering state. `focus`, `refs`, and `hide`
filter rendered parts before records are built. Viewer-only fields such as
`selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.
- `clip`: normalized clip-plane settings.
- `stepParameters`: STEP parameter runtime object, usually from
`stepParameterFrameRuntime()`.
- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.
- `renderPartsIndividually`: build per-part records instead of a whole mesh.
- `edgeRendering`: declarative edge rendering configuration.
Declarative screen-space edge rendering:
```js
buildModel(THREE, source, {
edgeRendering: {
mode: "screen-space",
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
wireframeEdgeColor: "#111827"
}
});
```
The model keeps screen-space line material bookkeeping internal through
`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.
Callers should not provide callbacks that create edge objects.
`model.update(nextSettings)` merges mutable settings, rebuilds geometry only
when needed, reapplies material/selection/clip/STEP parameter state, and returns
the same model API. `model.dispose()` releases model-owned scene objects and
STEP parameter cleanup hooks.
`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic
camera framing helper used by interactive rendering.
### `common/renderModel.js`
Use this module for interactive browser canvases, including the docs hero.
```js
import { renderModel } from "cadjs/common/renderModel.js";
```
`renderModel(THREE, model, options)` returns an interactive viewport API:
```js
{
THREE,
model,
renderer,
scene,
camera,
ready,
resize(),
render(),
start(),
stop(),
capturePng(),
dispose()
}
```
Common options:
- `canvas`: existing canvas for the renderer.
- `hostElement`/`container`: element used for responsive sizing.
- `renderer`: caller-owned renderer. If omitted, one is created.
- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are
created.
- `theme`/`themeSettings`: background and lighting settings.
- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,
`logarithmicDepthBuffer`, `shadows`: renderer controls.
- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.
- `pixelRatio`, `maxPixelRatio`: output density controls.
- `autoResize`: set `false` to disable `ResizeObserver`.
- `autoStart`: set `true` to start an animation loop.
- `autoRender`: set `false` to prevent the initial render.
- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.
- `disposeModel`: set `false` when the caller will dispose the model.
`dispose()` stops animation, disconnects resize observation, removes the model
root from the scene, and disposes the created renderer/model unless ownership
was explicitly retained by options.
### `common/renderMeshScene.js`
Use this module for deterministic headless snapshot rendering.
```js
import {
renderJobContext,
modelOptionsForRenderJob,
renderModel,
captureModel,
renderMeshJob
} from "cadjs/common/renderMeshScene.js";
```
`renderJobContext(meshData, job)` normalizes snapshot-owned render policy:
appearance, display, scene scale, outputs, STEP topology edge visibility, and
warnings.
`modelOptionsForRenderJob(context, job)` converts that policy into
`buildModel()` settings.
Snapshot `renderModel(THREE, model, { job, context })` returns a headless
viewport:
```js
{
THREE,
model,
scene,
renderer,
orthographicCamera,
perspectiveCamera,
context,
sceneBuildStarted,
ready,
dispose()
}
```
This `renderModel` is intentionally separate from `common/renderModel.js`.
It uses snapshot sizing, theme environment, floor, orthographic/perspective
camera presets, and deterministic renderer settings.
`captureModel(viewport, { job })` returns data only:
- `mode: "view"`: PNG data URLs in `outputs`.
- `mode: "orbit"`: the caller composes frames into GIF output.
- `mode: "section"`: PNG data URLs or SVG text in `outputs`.
- `mode: "list"`: part list and bounds.
It does not write files. The CAD skill snapshot CLI writes the returned data to
disk. Source checkouts use `packages/cadjs`; generated snapshot browser assets
bundle this entrypoint into `skills/cad/scripts/snapshot/runtime`.
`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,
builds a model, renders/captures it, and disposes owned resources.
## STEP Parameters
Shared render APIs use the name `stepParameters`.
Raw render values can be direct parameter values:
```json
{
"drive": 180,
"ringVisible": false
}
```
Or an animation envelope:
```json
{
"values": {
"ringVisible": true
},
"animate": {
"drive": { "from": 0, "to": 1260 }
},
"durationSeconds": 6,
"fps": 18,
"loop": true
}
```
`common/stepParameters.js` validates these values against the loaded STEP
parameter sidecar schema, normalizes defaults, and computes per-frame values.
`loadSource()` uses it to populate `source.stepParameterSource`; callers can
then use `stepParameterFrameRuntime()` when passing parameters into
`buildModel()`.
## Examples
Interactive viewer/docs usage:
```js
import * as THREE from "three";
import { loadSource, stepParameterFrameRuntime } from "cadjs/common/source.js";
import { buildModel } from "cadjs/common/cadScene.js";
import { renderModel } from "cadjs/common/renderModel.js";
const source = await loadSource({
kind: "step",
glbUrl: "/models/.part.step.glb",
stepParameterUrl: "/models/.part.step.js",
cadPath: "models/part.step",
stepParameters: { drive: 180 }
});
const model = buildModel(THREE, source, {
theme,
displayMode: "solid",
stepParameters: stepParameterFrameRuntime(source.stepParameterSource, 0)
});
const viewport = renderModel(THREE, model, {
canvas,
hostElement: canvas.parentElement,
theme,
autoStart: true
});
```
Headless snapshot usage:
```js
import * as THREE from "three";
import { loadSource } from "cadjs/common/source.js";
import { buildModel } from "cadjs/common/cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "cadjs/common/renderMeshScene.js";
const source = await loadSource(job);
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
const viewport = renderModel(THREE, model, { job, context });
try {
const result = await captureModel(viewport, { job });
// Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.
} finally {
viewport.dispose();
}
```
Orbit GIF jobs default to 6 fps over 12 seconds for a calmer review spin
without increasing default render frame count.
Override with `orbit.fps` and `orbit.durationSeconds` when a shorter or denser
render is needed.
## Ownership Rules
- Do not write files from shared render APIs. Return data to the owning CLI or
application layer.
- Do not expose object-construction callbacks for edges. Use declarative
`edgeRendering`.
- Keep STEP-only options explicitly STEP-named and reject them for non-STEP
sources.
- Dispose viewports and models that you create.
- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new
shared render code instead of loading assets or constructing render scenes
inline.
+28
View File
@@ -0,0 +1,28 @@
{
"name": "cadjs",
"version": "0.3.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cadjs",
"version": "0.3.9",
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
},
"node_modules/gifenc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
"license": "MIT"
},
"node_modules/three": {
"version": "0.160.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
"license": "MIT"
}
}
}
@@ -0,0 +1,23 @@
{
"name": "cadjs",
"version": "0.3.9",
"private": true,
"type": "module",
"description": "Reusable CAD render/viewer runtime for docs, CAD Viewer, and generated snapshot browser assets.",
"exports": {
".": "./src/index.js",
"./common/*": "./src/common/*",
"./lib/*": "./src/lib/*"
},
"scripts": {
"test": "node scripts/run-tests.mjs"
},
"sideEffects": [
"./src/common/headlessRenderEntry.js",
"./src/common/render.html"
],
"dependencies": {
"gifenc": "^1.0.3",
"three": "0.160.0"
}
}
@@ -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,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,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,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,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,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;
}
File diff suppressed because it is too large Load Diff
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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 @@
export * from "../../common/stepModule.js";
@@ -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,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,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,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,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,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,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,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,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
};
}
@@ -0,0 +1,209 @@
export const DEFAULT_AUTO_ZOOM_PADDING = 1.04;
const EPSILON = 1e-6;
function toNumber(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function normalizePartId(value) {
return String(value || "").trim();
}
function partIdMatchesFilter(partId, filter) {
const normalizedPartId = normalizePartId(partId);
if (!filter?.size) {
return true;
}
if (filter.has("__model__")) {
return true;
}
if (!normalizedPartId) {
return false;
}
for (const candidate of filter) {
const normalizedCandidate = normalizePartId(
String(candidate || "").startsWith("#")
? String(candidate || "").slice(1)
: candidate
);
if (
normalizedPartId === normalizedCandidate ||
normalizedPartId.startsWith(`${normalizedCandidate}.`)
) {
return true;
}
}
return false;
}
function isNumericBounds(bounds) {
return Array.isArray(bounds?.min) &&
Array.isArray(bounds?.max) &&
bounds.min.length >= 3 &&
bounds.max.length >= 3;
}
function vectorComponents(value) {
if (value?.isVector3) {
return [value.x, value.y, value.z];
}
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
return [toNumber(value[0]), toNumber(value[1]), toNumber(value[2])];
}
return [0, 0, 0];
}
function normalizedVector(THREE, value) {
if (!value) {
return null;
}
const [x, y, z] = vectorComponents(value);
const vector = new THREE.Vector3(x, y, z);
return vector.lengthSq() > EPSILON ? vector.normalize() : null;
}
export function mergeBoundsList(boundsList = []) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
let count = 0;
for (const bounds of Array.isArray(boundsList) ? boundsList : []) {
if (!isNumericBounds(bounds)) {
continue;
}
count += 1;
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], toNumber(bounds.min[axis]));
max[axis] = Math.max(max[axis], toNumber(bounds.max[axis]));
}
}
return count > 0 && min.every(Number.isFinite) && max.every(Number.isFinite)
? { min, max }
: null;
}
export function shiftedBounds(bounds, translation = null) {
if (!isNumericBounds(bounds)) {
return null;
}
const offset = vectorComponents(translation);
return {
min: bounds.min.slice(0, 3).map((value, index) => toNumber(value) + offset[index]),
max: bounds.max.slice(0, 3).map((value, index) => toNumber(value) + offset[index])
};
}
export function displayRecordsBounds(records = [], {
partIds = null,
translationByRecord = null
} = {}) {
const filter = partIds instanceof Set && partIds.size > 0 ? partIds : null;
const boundsList = [];
for (const record of Array.isArray(records) ? records : []) {
if (!record || !isNumericBounds(record.partBounds)) {
continue;
}
const partId = normalizePartId(record.partId);
if (filter && !partIdMatchesFilter(partId, filter)) {
continue;
}
const translation = translationByRecord instanceof Map
? translationByRecord.get(record)
: null;
boundsList.push(shiftedBounds(record.partBounds, translation));
}
return mergeBoundsList(boundsList);
}
export function focusedDisplayRecordsBounds(records = [], {
partIds = null,
translationByRecord = null,
fallbackBounds = null
} = {}) {
const filter = partIds instanceof Set && partIds.size > 0 ? partIds : null;
if (!filter) {
return displayRecordsBounds(records, { translationByRecord }) || fallbackBounds;
}
const recordBounds = displayRecordsBounds(records, { partIds: filter, translationByRecord });
return filter.has("__model__") ? (recordBounds || fallbackBounds) : recordBounds;
}
export function boundsCenterAndRadius(THREE, bounds, {
offset = null
} = {}) {
if (!THREE?.Vector3 || !isNumericBounds(bounds)) {
return null;
}
const center = new THREE.Vector3(
(toNumber(bounds.min[0]) + toNumber(bounds.max[0])) / 2,
(toNumber(bounds.min[1]) + toNumber(bounds.max[1])) / 2,
(toNumber(bounds.min[2]) + toNumber(bounds.max[2])) / 2
);
if (offset?.isVector3) {
center.add(offset);
}
const radius = new THREE.Vector3(
Math.max(toNumber(bounds.max[0]) - toNumber(bounds.min[0]), 0),
Math.max(toNumber(bounds.max[1]) - toNumber(bounds.min[1]), 0),
Math.max(toNumber(bounds.max[2]) - toNumber(bounds.min[2]), 0)
).length() / 2;
return { center, radius };
}
export function fitDistanceForRadius(camera, radius, {
aspect = camera?.aspect || 1,
minRadius = 0,
padding = DEFAULT_AUTO_ZOOM_PADDING
} = {}) {
const fov = Math.max(toNumber(camera?.fov, 48), EPSILON);
const safeRadius = Math.max(
toNumber(radius) * Math.max(toNumber(padding, DEFAULT_AUTO_ZOOM_PADDING), EPSILON),
toNumber(minRadius)
);
const verticalHalfFov = (fov * Math.PI) / 360;
const horizontalHalfFov = Math.atan(Math.tan(verticalHalfFov) * Math.max(toNumber(aspect, 1), EPSILON));
const limitingHalfFov = Math.max(Math.min(verticalHalfFov, horizontalHalfFov), EPSILON);
return safeRadius / Math.sin(limitingHalfFov);
}
export function autoZoomFrameForBounds(THREE, {
camera,
controls,
bounds,
modelOffset = null,
frameAspect = camera?.aspect || 1,
minRadius = 0,
padding = DEFAULT_AUTO_ZOOM_PADDING,
defaultDirection = [2.1, -1.65, 1.08],
viewDirection = null,
viewUp = null
} = {}) {
if (!THREE?.Vector3 || !camera || !controls) {
return null;
}
const frame = boundsCenterAndRadius(THREE, bounds, { offset: modelOffset });
if (!frame || !Number.isFinite(frame.radius)) {
return null;
}
const offset = camera.position.clone().sub(controls.target);
const direction = normalizedVector(THREE, viewDirection) || (offset.lengthSq() > EPSILON
? offset.normalize()
: new THREE.Vector3(...defaultDirection).normalize());
const up = normalizedVector(THREE, viewUp) || camera.up.clone();
const distance = fitDistanceForRadius(camera, frame.radius, {
aspect: frameAspect,
minRadius,
padding
});
const position = frame.center.clone().add(direction.multiplyScalar(distance));
return {
center: frame.center,
radius: frame.radius,
distance,
position,
target: frame.center.clone(),
up,
zoom: camera.zoom
};
}
@@ -0,0 +1,129 @@
export const STEP_CLIP_AXES = Object.freeze(["x", "y", "z"]);
const DEFAULT_STEP_CLIP_OFFSETS = Object.freeze({
x: 0,
y: 0,
z: 0
});
export const DEFAULT_STEP_CLIP_SETTINGS = Object.freeze({
enabled: false,
axis: "x",
offset: DEFAULT_STEP_CLIP_OFFSETS.x,
offsets: DEFAULT_STEP_CLIP_OFFSETS,
invert: false
});
const AXIS_INDEX = Object.freeze({
x: 0,
y: 1,
z: 2
});
const ACTIVE_CLIP_OFFSET_EPSILON = 1e-6;
function normalizeNumber(value, fallback) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
export function normalizeStepClipSettings(value = null) {
const source = value && typeof value === "object" ? value : {};
const axis = STEP_CLIP_AXES.includes(String(source.axis || "").toLowerCase())
? String(source.axis).toLowerCase()
: DEFAULT_STEP_CLIP_SETTINGS.axis;
const sourceOffsets = source.offsets && typeof source.offsets === "object" ? source.offsets : {};
const legacyOffset = normalizeNumber(source.offset, DEFAULT_STEP_CLIP_SETTINGS.offset);
const offsets = Object.fromEntries(
STEP_CLIP_AXES.map((clipAxis) => [
clipAxis,
clamp(
normalizeNumber(
sourceOffsets[clipAxis],
clipAxis === axis ? legacyOffset : DEFAULT_STEP_CLIP_OFFSETS[clipAxis]
),
0,
1
)
])
);
const activeOffset = offsets[axis];
const offsetEnabled = activeOffset > ACTIVE_CLIP_OFFSET_EPSILON;
const requestedEnabled = source.enabled === true
? true
: source.enabled === false
? false
: offsetEnabled;
return {
enabled: requestedEnabled && offsetEnabled,
axis,
offset: activeOffset,
offsets,
invert: source.invert === true
};
}
export function stepClipSettingsEqual(left, right) {
const a = normalizeStepClipSettings(left);
const b = normalizeStepClipSettings(right);
return (
a.enabled === b.enabled &&
a.axis === b.axis &&
Math.abs(a.offset - b.offset) < 1e-6 &&
STEP_CLIP_AXES.every((axis) => Math.abs((a.offsets?.[axis] ?? DEFAULT_STEP_CLIP_OFFSETS[axis]) - (b.offsets?.[axis] ?? DEFAULT_STEP_CLIP_OFFSETS[axis])) < 1e-6) &&
a.invert === b.invert
);
}
export function axisIndex(axis) {
return AXIS_INDEX[String(axis || "").toLowerCase()] ?? AXIS_INDEX.x;
}
export function clipAxisBounds(bounds, axis) {
const index = axisIndex(axis);
const min = normalizeNumber(bounds?.min?.[index], 0);
const max = normalizeNumber(bounds?.max?.[index], min);
return min <= max ? { min, max } : { min: max, max: min };
}
export function clipAxisPosition(bounds, settings) {
const normalized = normalizeStepClipSettings(settings);
const { min, max } = clipAxisBounds(bounds, normalized.axis);
return min + ((max - min) * normalized.offset);
}
export function pointVisibleByClipPlane(clipPlane, point, epsilon = 1e-5) {
if (!clipPlane || !point || typeof clipPlane.distanceToPoint !== "function") {
return true;
}
const tolerance = Math.abs(normalizeNumber(epsilon, 1e-5));
return clipPlane.distanceToPoint(point) >= -tolerance;
}
export function buildStepClipPatch(settings, patch) {
const current = normalizeStepClipSettings(settings);
const rawPatch = patch && typeof patch === "object" ? patch : {};
const hasExplicitEnabled = Object.prototype.hasOwnProperty.call(rawPatch, "enabled");
const hasOffsetPatch = Object.prototype.hasOwnProperty.call(rawPatch, "offset") ||
(rawPatch.offsets && typeof rawPatch.offsets === "object");
const patchedAxis = STEP_CLIP_AXES.includes(String(rawPatch.axis || "").toLowerCase())
? String(rawPatch.axis).toLowerCase()
: current.axis;
const offsets = {
...current.offsets,
...(rawPatch.offsets && typeof rawPatch.offsets === "object" ? rawPatch.offsets : {})
};
if (Object.prototype.hasOwnProperty.call(rawPatch, "offset")) {
offsets[patchedAxis] = rawPatch.offset;
}
const activeOffset = clamp(normalizeNumber(offsets[patchedAxis], 0), 0, 1);
return normalizeStepClipSettings({
...current,
...rawPatch,
...(!hasExplicitEnabled && hasOffsetPatch ? { enabled: activeOffset > ACTIVE_CLIP_OFFSET_EPSILON } : {}),
axis: patchedAxis,
offsets,
offset: offsets[patchedAxis]
});
}
@@ -0,0 +1,6 @@
export const VIEWER_PICK_MODE = {
NONE: "none",
AUTO: "auto",
ASSEMBLY: "assembly",
PARTS: "parts"
};
@@ -0,0 +1,32 @@
export const DERIVED_DISPLAY_EDGE_TRIANGLE_LIMIT = 500000;
function isNumericArray(value, stride = 1) {
return (
(Array.isArray(value) || ArrayBuffer.isView(value)) &&
value.length >= stride &&
value.length % stride === 0
);
}
export function shouldBuildDerivedDisplayEdges(meshData, {
triangleLimit = DERIVED_DISPLAY_EDGE_TRIANGLE_LIMIT
} = {}) {
if (isNumericArray(meshData?.edge_indices, 2)) {
return true;
}
const triangleCount = Math.floor((meshData?.indices?.length || 0) / 3);
return triangleCount <= triangleLimit;
}
export function shouldShowRecordDisplayEdges({
edgesVisible = false,
topologyDisplayEdgesVisible = false,
displayEdgesVisible = false,
wireframeMode = false
} = {}) {
return Boolean(
edgesVisible &&
!topologyDisplayEdgesVisible &&
(displayEdgesVisible || wireframeMode)
);
}
@@ -0,0 +1,801 @@
import { DRAWING_TOOL } from "./drawingTools.js";
import {
drawingPointToPixels,
getDrawingStrokePoints,
getFillStrokePoints,
isClosedDrawingStroke,
isFillBoundaryStroke,
isFillStroke,
isSurfaceLineStroke,
pointInPolygon2d
} from "./drawingGeometry.js";
export const DRAWING_STROKE_COLOR = "#ef4444";
export const DRAWING_STROKE_HALO = "rgba(255, 255, 255, 0.94)";
export const DRAWING_STROKE_WIDTH = 4;
export const DRAWING_STROKE_HALO_WIDTH = 8;
export const DRAWING_ARROW_HEAD_LENGTH = 18;
export const DRAWING_MIN_POINT_DISTANCE_PX = 2.5;
export const DRAWING_MIN_STROKE_LENGTH_PX = 4;
export const DRAWING_ERASE_THRESHOLD_PX = 16;
export const DRAWING_FILL_COLOR = "rgba(239, 68, 68, 0.22)";
export const DRAWING_GUESSED_FILL_COLOR = "rgba(239, 68, 68, 0.16)";
export const DRAWING_FILL_ANALYSIS_MAX_DIMENSION = 420;
export const DRAWING_FILL_ANALYSIS_MIN_DIMENSION = 96;
export const DRAWING_FILL_CONNECT_GAP_PX = 28;
export const DRAWING_FILL_RAY_COUNT = 72;
export const DRAWING_FILL_MIN_REGION_PIXELS = 56;
export const DRAWING_FILL_MAX_REGION_RATIO = 0.92;
export const SURFACE_LINE_COLOR = "#ef4444";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function pointsEqual2d(a, b, epsilon = 1e-4) {
return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon;
}
export function maxDrawingStrokeOrdinal(strokes) {
let maxOrdinal = 0;
for (const stroke of Array.isArray(strokes) ? strokes : []) {
const match = /^stroke-(\d+)$/.exec(String(stroke?.id || ""));
if (!match) {
continue;
}
const nextOrdinal = Number(match[1]);
if (Number.isFinite(nextOrdinal) && nextOrdinal > maxOrdinal) {
maxOrdinal = nextOrdinal;
}
}
return maxOrdinal;
}
export function buildRectanglePixelCorners(start, end) {
return [
[start[0], start[1]],
[end[0], start[1]],
[end[0], end[1]],
[start[0], end[1]]
];
}
export function buildCirclePixelPolygon(center, edge, segmentCount = 56) {
const radius = Math.hypot(edge[0] - center[0], edge[1] - center[1]);
if (radius < 1e-4) {
return [center];
}
const segments = Math.max(segmentCount, 12);
const points = [];
for (let index = 0; index <= segments; index += 1) {
const angle = (index / segments) * Math.PI * 2;
points.push([
center[0] + Math.cos(angle) * radius,
center[1] + Math.sin(angle) * radius
]);
}
return points;
}
export function getDrawingBoundaryPixelPoints(stroke, width, height) {
const pixelPoints = getDrawingStrokePoints(stroke).map((point) => drawingPointToPixels(point, width, height));
if (pixelPoints.length < 2) {
return pixelPoints;
}
if (stroke.tool === DRAWING_TOOL.RECTANGLE) {
const corners = buildRectanglePixelCorners(pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
return [...corners, corners[0]];
}
if (stroke.tool === DRAWING_TOOL.CIRCLE) {
return buildCirclePixelPolygon(pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
}
return pixelPoints;
}
function drawArrowHead(context, start, end, lineWidth) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const lengthPx = Math.hypot(dx, dy);
if (lengthPx < 1) {
return;
}
const ux = dx / lengthPx;
const uy = dy / lengthPx;
const angle = Math.PI / 7;
const headLength = Math.max(DRAWING_ARROW_HEAD_LENGTH, lineWidth * 3.25);
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const left = [
end[0] - headLength * (ux * cos - uy * sin),
end[1] - headLength * (uy * cos + ux * sin)
];
const right = [
end[0] - headLength * (ux * cos + uy * sin),
end[1] - headLength * (uy * cos - ux * sin)
];
context.beginPath();
context.moveTo(end[0], end[1]);
context.lineTo(left[0], left[1]);
context.moveTo(end[0], end[1]);
context.lineTo(right[0], right[1]);
context.stroke();
}
function drawPointDot(context, point, lineWidth) {
context.beginPath();
context.arc(point[0], point[1], lineWidth * 0.65, 0, Math.PI * 2);
context.fill();
}
function drawPolylineStroke(context, pixelPoints) {
context.beginPath();
pixelPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point[0], point[1]);
return;
}
context.lineTo(point[0], point[1]);
});
context.stroke();
}
function drawRectangleStroke(context, start, end) {
const x = Math.min(start[0], end[0]);
const y = Math.min(start[1], end[1]);
const width = Math.abs(end[0] - start[0]);
const height = Math.abs(end[1] - start[1]);
context.strokeRect(x, y, width, height);
}
function drawCircleStroke(context, center, edge) {
const radius = Math.hypot(edge[0] - center[0], edge[1] - center[1]);
context.beginPath();
context.arc(center[0], center[1], radius, 0, Math.PI * 2);
context.stroke();
}
function drawFillStroke(context, stroke, width, height, { color, alpha = 1 }) {
const points = getFillStrokePoints(stroke);
if (points.length < 3) {
return;
}
const pixelPoints = points.map((point) => drawingPointToPixels(point, width, height));
context.save();
context.globalAlpha = alpha;
context.fillStyle = color;
context.beginPath();
pixelPoints.forEach((point, index) => {
if (index === 0) {
context.moveTo(point[0], point[1]);
return;
}
context.lineTo(point[0], point[1]);
});
context.closePath();
context.fill();
context.restore();
}
function drawLineStroke(context, stroke, width, height, { color, lineWidth, alpha = 1 }) {
if (isFillStroke(stroke)) {
return;
}
const points = getDrawingStrokePoints(stroke);
if (!points.length) {
return;
}
const pixelPoints = points.map((point) => drawingPointToPixels(point, width, height));
context.save();
context.globalAlpha = alpha;
context.strokeStyle = color;
context.fillStyle = color;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = lineWidth;
if (pixelPoints.length === 1) {
drawPointDot(context, pixelPoints[0], lineWidth);
context.restore();
return;
}
if (stroke.tool === DRAWING_TOOL.RECTANGLE) {
drawRectangleStroke(context, pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
context.restore();
return;
}
if (stroke.tool === DRAWING_TOOL.CIRCLE) {
drawCircleStroke(context, pixelPoints[0], pixelPoints[pixelPoints.length - 1]);
context.restore();
return;
}
drawPolylineStroke(context, pixelPoints);
if (stroke.tool === DRAWING_TOOL.ARROW || stroke.tool === DRAWING_TOOL.DOUBLE_ARROW) {
drawArrowHead(context, pixelPoints[pixelPoints.length - 2], pixelPoints[pixelPoints.length - 1], lineWidth);
}
if (stroke.tool === DRAWING_TOOL.DOUBLE_ARROW) {
drawArrowHead(context, pixelPoints[1], pixelPoints[0], lineWidth);
}
context.restore();
}
function createOffscreenCanvas(width, height) {
if (typeof OffscreenCanvas !== "undefined") {
return new OffscreenCanvas(width, height);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
function getOpenBoundaryEndpoints(stroke, width, height) {
if (!isFillBoundaryStroke(stroke) || isClosedDrawingStroke(stroke)) {
return [];
}
const pixelPoints = getDrawingBoundaryPixelPoints(stroke, width, height);
if (pixelPoints.length < 2) {
return [];
}
const allowSelfConnect = stroke?.tool === DRAWING_TOOL.FREEHAND && pixelPoints.length > 2;
return [
{
strokeId: String(stroke?.id || ""),
point: pixelPoints[0],
allowSelfConnect
},
{
strokeId: String(stroke?.id || ""),
point: pixelPoints[pixelPoints.length - 1],
allowSelfConnect
}
];
}
export function pairNearbyBoundaryEndpoints(endpoints, maxDistance) {
if (!Array.isArray(endpoints) || endpoints.length < 2 || maxDistance <= 0) {
return [];
}
const candidates = [];
for (let leftIndex = 0; leftIndex < endpoints.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < endpoints.length; rightIndex += 1) {
const left = endpoints[leftIndex];
const right = endpoints[rightIndex];
const sameStroke = left.strokeId && left.strokeId === right.strokeId;
if (sameStroke && !(left.allowSelfConnect && right.allowSelfConnect)) {
continue;
}
const distance = Math.hypot(left.point[0] - right.point[0], left.point[1] - right.point[1]);
if (distance <= maxDistance) {
candidates.push({ leftIndex, rightIndex, distance });
}
}
}
candidates.sort((left, right) => left.distance - right.distance);
const used = new Set();
const pairs = [];
for (const candidate of candidates) {
if (used.has(candidate.leftIndex) || used.has(candidate.rightIndex)) {
continue;
}
used.add(candidate.leftIndex);
used.add(candidate.rightIndex);
pairs.push([
endpoints[candidate.leftIndex].point,
endpoints[candidate.rightIndex].point
]);
}
return pairs;
}
export function buildBoundaryMaskFromStrokes(strokes, width, height, { gapPx = 0, lineWidth = DRAWING_STROKE_WIDTH } = {}) {
const canvas = createOffscreenCanvas(width, height);
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return null;
}
context.clearRect(0, 0, width, height);
const boundaryStrokes = Array.isArray(strokes) ? strokes.filter(isFillBoundaryStroke) : [];
for (const stroke of boundaryStrokes) {
drawLineStroke(context, stroke, width, height, {
color: "#000000",
lineWidth,
alpha: 1
});
}
if (gapPx > 0) {
const connectors = pairNearbyBoundaryEndpoints(
boundaryStrokes.flatMap((stroke) => getOpenBoundaryEndpoints(stroke, width, height)),
Math.max(gapPx, lineWidth * 1.15)
);
if (connectors.length) {
context.save();
context.strokeStyle = "#000000";
context.lineWidth = lineWidth;
context.lineCap = "round";
for (const [start, end] of connectors) {
context.beginPath();
context.moveTo(start[0], start[1]);
context.lineTo(end[0], end[1]);
context.stroke();
}
context.restore();
}
}
const { data } = context.getImageData(0, 0, width, height);
const mask = new Uint8Array(width * height);
for (let index = 0; index < mask.length; index += 1) {
mask[index] = data[index * 4 + 3] > 20 ? 1 : 0;
}
return mask;
}
export function findNearestOpenSeed(boundaryMask, width, height, seedX, seedY, maxRadius = 5) {
const x = clamp(Math.round(seedX), 0, width - 1);
const y = clamp(Math.round(seedY), 0, height - 1);
for (let radius = 0; radius <= maxRadius; radius += 1) {
let best = null;
let bestDistance = Infinity;
for (let dy = -radius; dy <= radius; dy += 1) {
for (let dx = -radius; dx <= radius; dx += 1) {
const px = x + dx;
const py = y + dy;
if (px < 0 || py < 0 || px >= width || py >= height) {
continue;
}
if (boundaryMask[py * width + px]) {
continue;
}
const distance = Math.hypot(dx, dy);
if (distance < bestDistance) {
bestDistance = distance;
best = [px, py];
}
}
}
if (best) {
return best;
}
}
return null;
}
export function floodFillInterior(boundaryMask, width, height, seedPoint) {
const start = findNearestOpenSeed(boundaryMask, width, height, seedPoint[0], seedPoint[1]);
if (!start) {
return null;
}
const fillMask = new Uint8Array(width * height);
const queue = new Int32Array(width * height);
let head = 0;
let tail = 0;
const startIndex = start[1] * width + start[0];
queue[tail++] = startIndex;
fillMask[startIndex] = 1;
let area = 0;
let touchesEdge = false;
while (head < tail) {
const index = queue[head++];
area += 1;
const x = index % width;
const y = (index / width) | 0;
if (x === 0 || y === 0 || x === width - 1 || y === height - 1) {
touchesEdge = true;
}
if (x > 0) {
const leftIndex = index - 1;
if (!boundaryMask[leftIndex] && !fillMask[leftIndex]) {
fillMask[leftIndex] = 1;
queue[tail++] = leftIndex;
}
}
if (x + 1 < width) {
const rightIndex = index + 1;
if (!boundaryMask[rightIndex] && !fillMask[rightIndex]) {
fillMask[rightIndex] = 1;
queue[tail++] = rightIndex;
}
}
if (y > 0) {
const upIndex = index - width;
if (!boundaryMask[upIndex] && !fillMask[upIndex]) {
fillMask[upIndex] = 1;
queue[tail++] = upIndex;
}
}
if (y + 1 < height) {
const downIndex = index + width;
if (!boundaryMask[downIndex] && !fillMask[downIndex]) {
fillMask[downIndex] = 1;
queue[tail++] = downIndex;
}
}
}
return {
mask: fillMask,
area,
touchesEdge,
seed: start
};
}
function polygonArea2d(points) {
let area = 0;
for (let index = 0, previous = points.length - 1; index < points.length; previous = index, index += 1) {
area += points[previous][0] * points[index][1] - points[index][0] * points[previous][1];
}
return area / 2;
}
function removeDuplicatePolygonPoints(points) {
const next = [];
for (const point of points) {
if (!next.length || !pointsEqual2d(next[next.length - 1], point)) {
next.push(point);
}
}
if (next.length > 1 && pointsEqual2d(next[0], next[next.length - 1])) {
next.pop();
}
return next;
}
function removeCollinearPolygonPoints(points) {
const loop = removeDuplicatePolygonPoints(points);
if (loop.length < 3) {
return loop;
}
const next = [];
for (let index = 0; index < loop.length; index += 1) {
const previous = loop[(index + loop.length - 1) % loop.length];
const current = loop[index];
const following = loop[(index + 1) % loop.length];
const cross =
(current[0] - previous[0]) * (following[1] - current[1]) -
(current[1] - previous[1]) * (following[0] - current[0]);
if (Math.abs(cross) > 1e-4) {
next.push(current);
}
}
return next.length >= 3 ? next : loop;
}
function downsamplePolygon(points, maxPoints = 160) {
if (points.length <= maxPoints) {
return points;
}
const step = points.length / maxPoints;
const next = [];
let cursor = 0;
for (let index = 0; index < maxPoints; index += 1) {
next.push(points[Math.floor(cursor) % points.length]);
cursor += step;
}
return removeDuplicatePolygonPoints(next);
}
function pointKey(point) {
return `${point[0]},${point[1]}`;
}
export function traceMaskLoops(mask, width, height) {
const segments = [];
const adjacency = new Map();
const addSegment = (start, end) => {
const index = segments.length;
segments.push([start, end]);
const key = pointKey(start);
const entries = adjacency.get(key);
if (entries) {
entries.push(index);
return;
}
adjacency.set(key, [index]);
};
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (!mask[y * width + x]) {
continue;
}
if (y === 0 || !mask[(y - 1) * width + x]) {
addSegment([x, y], [x + 1, y]);
}
if (x === width - 1 || !mask[y * width + x + 1]) {
addSegment([x + 1, y], [x + 1, y + 1]);
}
if (y === height - 1 || !mask[(y + 1) * width + x]) {
addSegment([x + 1, y + 1], [x, y + 1]);
}
if (x === 0 || !mask[y * width + x - 1]) {
addSegment([x, y + 1], [x, y]);
}
}
}
const used = new Uint8Array(segments.length);
const loops = [];
for (let index = 0; index < segments.length; index += 1) {
if (used[index]) {
continue;
}
const loop = [];
let currentIndex = index;
let guard = 0;
while (currentIndex !== -1 && !used[currentIndex] && guard < segments.length + 4) {
used[currentIndex] = 1;
const [start, end] = segments[currentIndex];
if (!loop.length) {
loop.push(start);
}
loop.push(end);
if (pointsEqual2d(end, loop[0])) {
break;
}
const nextCandidates = adjacency.get(pointKey(end)) || [];
currentIndex = nextCandidates.find((candidateIndex) => !used[candidateIndex]) ?? -1;
guard += 1;
}
if (loop.length >= 4 && pointsEqual2d(loop[0], loop[loop.length - 1])) {
const normalizedLoop = removeCollinearPolygonPoints(loop.slice(0, -1));
if (normalizedLoop.length >= 3) {
loops.push(normalizedLoop);
}
}
}
return loops;
}
export function normalizePolygonPoints(points, width, height) {
return points.map((point) => ({
x: clamp(point[0] / width, 0, 1),
y: clamp(point[1] / height, 0, 1)
}));
}
export function buildPolygonFromFilledMask(mask, width, height, seedPoint) {
const loops = traceMaskLoops(mask, width, height);
if (!loops.length) {
return null;
}
const seed = [seedPoint[0] + 0.5, seedPoint[1] + 0.5];
const containingLoops = loops.filter((loop) => pointInPolygon2d(seed, loop));
const sourceLoops = containingLoops.length ? containingLoops : loops;
const chosen = sourceLoops.reduce((best, current) => {
if (!best) {
return current;
}
return Math.abs(polygonArea2d(current)) > Math.abs(polygonArea2d(best)) ? current : best;
}, null);
if (!chosen) {
return null;
}
const simplified = downsamplePolygon(removeCollinearPolygonPoints(chosen));
return simplified.length >= 3 ? normalizePolygonPoints(simplified, width, height) : null;
}
function findNearestValidDistance(distances, index, direction) {
for (let offset = 1; offset < distances.length; offset += 1) {
const nextIndex = (index + direction * offset + distances.length) % distances.length;
if (Number.isFinite(distances[nextIndex])) {
return {
value: distances[nextIndex],
offset
};
}
}
return null;
}
export function buildGuessedFillPolygon(boundaryMask, width, height, seedPoint) {
const start = findNearestOpenSeed(boundaryMask, width, height, seedPoint[0], seedPoint[1]);
if (!start) {
return null;
}
const maxDistance = Math.hypot(width, height);
const distances = Array.from({ length: DRAWING_FILL_RAY_COUNT }, () => null);
for (let index = 0; index < DRAWING_FILL_RAY_COUNT; index += 1) {
const angle = (index / DRAWING_FILL_RAY_COUNT) * Math.PI * 2;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
for (let distance = 1; distance < maxDistance; distance += 1) {
const x = Math.round(start[0] + dx * distance);
const y = Math.round(start[1] + dy * distance);
if (x < 0 || y < 0 || x >= width || y >= height) {
break;
}
if (boundaryMask[y * width + x]) {
distances[index] = Math.max(distance - 1.5, 1);
break;
}
}
}
const validDistances = distances.filter(Number.isFinite);
if (validDistances.length < Math.max(12, Math.floor(DRAWING_FILL_RAY_COUNT / 4))) {
return null;
}
const orderedDistances = [...validDistances].sort((left, right) => left - right);
const medianDistance = orderedDistances[Math.floor(orderedDistances.length / 2)] || 1;
const resolvedDistances = distances.map((value, index) => {
if (Number.isFinite(value)) {
return value;
}
const previous = findNearestValidDistance(distances, index, -1);
const next = findNearestValidDistance(distances, index, 1);
if (previous && next) {
const total = previous.offset + next.offset;
return (previous.value * next.offset + next.value * previous.offset) / Math.max(total, 1);
}
if (previous) {
return previous.value;
}
if (next) {
return next.value;
}
return medianDistance;
});
let smoothedDistances = resolvedDistances;
for (let pass = 0; pass < 2; pass += 1) {
smoothedDistances = smoothedDistances.map((value, index) => {
const previous = smoothedDistances[(index + smoothedDistances.length - 1) % smoothedDistances.length];
const next = smoothedDistances[(index + 1) % smoothedDistances.length];
return (previous + value * 2 + next) / 4;
});
}
const polygon = smoothedDistances.map((distance, index) => {
const angle = (index / DRAWING_FILL_RAY_COUNT) * Math.PI * 2;
return [
clamp(start[0] + Math.cos(angle) * distance, 0, width),
clamp(start[1] + Math.sin(angle) * distance, 0, height)
];
});
const simplified = downsamplePolygon(removeCollinearPolygonPoints(polygon), DRAWING_FILL_RAY_COUNT);
if (simplified.length < 3) {
return null;
}
const area = Math.abs(polygonArea2d(simplified));
if (area < DRAWING_FILL_MIN_REGION_PIXELS || area > width * height * DRAWING_FILL_MAX_REGION_RATIO) {
return null;
}
return normalizePolygonPoints(simplified, width, height);
}
export function buildFillStrokeAtPoint(point, strokes, canvas) {
const boundaryStrokes = Array.isArray(strokes) ? strokes.filter(isFillBoundaryStroke) : [];
if (!boundaryStrokes.length) {
return null;
}
const canvasWidth = canvas.width || 1;
const canvasHeight = canvas.height || 1;
const maxCanvasDimension = Math.max(canvasWidth, canvasHeight, 1);
const analysisScale = Math.min(1, DRAWING_FILL_ANALYSIS_MAX_DIMENSION / maxCanvasDimension);
const analysisWidth = Math.max(DRAWING_FILL_ANALYSIS_MIN_DIMENSION, Math.round(canvasWidth * analysisScale));
const analysisHeight = Math.max(DRAWING_FILL_ANALYSIS_MIN_DIMENSION, Math.round(canvasHeight * analysisScale));
const seedPoint = [point.x * (analysisWidth - 1), point.y * (analysisHeight - 1)];
const lineWidth = Math.max(3, DRAWING_STROKE_WIDTH * analysisScale + 3);
const gapStrategies = [
{ gapPx: 0, guessed: false },
{ gapPx: DRAWING_FILL_CONNECT_GAP_PX * 0.45 * analysisScale, guessed: true },
{ gapPx: DRAWING_FILL_CONNECT_GAP_PX * analysisScale, guessed: true }
];
for (const strategy of gapStrategies) {
const boundaryMask = buildBoundaryMaskFromStrokes(boundaryStrokes, analysisWidth, analysisHeight, {
gapPx: strategy.gapPx,
lineWidth
});
if (!boundaryMask) {
continue;
}
const filledRegion = floodFillInterior(boundaryMask, analysisWidth, analysisHeight, seedPoint);
if (!filledRegion || filledRegion.area < DRAWING_FILL_MIN_REGION_PIXELS) {
continue;
}
if (filledRegion.touchesEdge || filledRegion.area > analysisWidth * analysisHeight * DRAWING_FILL_MAX_REGION_RATIO) {
continue;
}
const fillPoints = buildPolygonFromFilledMask(
filledRegion.mask,
analysisWidth,
analysisHeight,
filledRegion.seed
);
if (fillPoints?.length >= 3) {
return {
tool: DRAWING_TOOL.FILL,
points: [point],
fillPoints,
guessed: strategy.guessed
};
}
}
const fallbackBoundaryMask = buildBoundaryMaskFromStrokes(boundaryStrokes, analysisWidth, analysisHeight, {
gapPx: DRAWING_FILL_CONNECT_GAP_PX * analysisScale,
lineWidth
});
if (!fallbackBoundaryMask) {
return null;
}
const fillPoints = buildGuessedFillPolygon(fallbackBoundaryMask, analysisWidth, analysisHeight, seedPoint);
if (!fillPoints?.length || fillPoints.length < 3) {
return null;
}
return {
tool: DRAWING_TOOL.FILL,
points: [point],
fillPoints,
guessed: true
};
}
export function redrawDrawingCanvas(canvas, strokes, draftStroke = null) {
if (!canvas) {
return;
}
const context = canvas.getContext("2d");
if (!context) {
return;
}
const width = canvas.width;
const height = canvas.height;
context.clearRect(0, 0, width, height);
const allStrokes = Array.isArray(strokes) ? strokes : [];
for (const stroke of allStrokes) {
if (!isFillStroke(stroke)) {
continue;
}
drawFillStroke(context, stroke, width, height, {
color: stroke?.guessed ? DRAWING_GUESSED_FILL_COLOR : DRAWING_FILL_COLOR,
alpha: 1
});
}
for (const stroke of allStrokes) {
if (isSurfaceLineStroke(stroke)) {
continue;
}
drawLineStroke(context, stroke, width, height, {
color: DRAWING_STROKE_HALO,
lineWidth: DRAWING_STROKE_HALO_WIDTH
});
drawLineStroke(context, stroke, width, height, {
color: DRAWING_STROKE_COLOR,
lineWidth: DRAWING_STROKE_WIDTH
});
}
if (draftStroke) {
drawLineStroke(context, draftStroke, width, height, {
color: DRAWING_STROKE_HALO,
lineWidth: DRAWING_STROKE_HALO_WIDTH,
alpha: 0.78
});
drawLineStroke(context, draftStroke, width, height, {
color: DRAWING_STROKE_COLOR,
lineWidth: DRAWING_STROKE_WIDTH,
alpha: 0.9
});
}
}
@@ -0,0 +1,169 @@
import { DRAWING_TOOL } from "./drawingTools.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
export function distancePointToSegment2d(point, start, end) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) {
return Math.hypot(point[0] - start[0], point[1] - start[1]);
}
const t = clamp(
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy),
0,
1
);
const projected = [start[0] + dx * t, start[1] + dy * t];
return Math.hypot(point[0] - projected[0], point[1] - projected[1]);
}
export function pointInPolygon2d(point, polygon) {
let inside = false;
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
const a = polygon[index];
const b = polygon[previous];
if (distancePointToSegment2d(point, a, b) <= 1e-4) {
return true;
}
const intersects =
(a[1] > point[1]) !== (b[1] > point[1]) &&
point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1] || 1e-9) + a[0];
if (intersects) {
inside = !inside;
}
}
return inside;
}
export function buildDrawingPoint(event, canvas) {
const rect = canvas.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
return {
x: clamp((event.clientX - rect.left) / width, 0, 1),
y: clamp((event.clientY - rect.top) / height, 0, 1)
};
}
export function drawingPointToPixels(point, width, height) {
return [point.x * width, point.y * height];
}
export function strokeLengthInPixels(stroke, width, height) {
const points = Array.isArray(stroke?.points) ? stroke.points : [];
if (points.length < 2) {
return 0;
}
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = drawingPointToPixels(points[index - 1], width, height);
const current = drawingPointToPixels(points[index], width, height);
total += Math.hypot(current[0] - previous[0], current[1] - previous[1]);
}
return total;
}
export function getDrawingStrokePoints(stroke) {
return Array.isArray(stroke?.points)
? stroke.points.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y))
: [];
}
export function getFillStrokePoints(stroke) {
return Array.isArray(stroke?.fillPoints)
? stroke.fillPoints.filter((point) => point && Number.isFinite(point.x) && Number.isFinite(point.y))
: [];
}
export function isFillStroke(stroke) {
return stroke?.tool === DRAWING_TOOL.FILL;
}
export function isSurfaceLineStroke(stroke) {
return stroke?.tool === DRAWING_TOOL.SURFACE_LINE && !!stroke?.surfaceLine;
}
export function isClosedDrawingStroke(stroke) {
return stroke?.tool === DRAWING_TOOL.RECTANGLE || stroke?.tool === DRAWING_TOOL.CIRCLE;
}
export function isFillBoundaryStroke(stroke) {
return !!stroke && stroke.tool !== DRAWING_TOOL.ERASE && stroke.tool !== DRAWING_TOOL.FILL && stroke.tool !== DRAWING_TOOL.SURFACE_LINE;
}
export function drawingToolNeedsTwoPoints(tool) {
return (
tool === DRAWING_TOOL.LINE ||
tool === DRAWING_TOOL.SURFACE_LINE ||
tool === DRAWING_TOOL.ARROW ||
tool === DRAWING_TOOL.DOUBLE_ARROW ||
tool === DRAWING_TOOL.RECTANGLE ||
tool === DRAWING_TOOL.CIRCLE
);
}
export function distanceToStrokeInPixels(point, stroke, width, height) {
if (isFillStroke(stroke)) {
const pixelPoint = drawingPointToPixels(point, width, height);
const polygon = getFillStrokePoints(stroke).map((entry) => drawingPointToPixels(entry, width, height));
if (polygon.length < 3) {
return Infinity;
}
if (pointInPolygon2d(pixelPoint, polygon)) {
return 0;
}
let minimum = Infinity;
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index];
const end = polygon[(index + 1) % polygon.length];
minimum = Math.min(minimum, distancePointToSegment2d(pixelPoint, start, end));
}
return minimum;
}
const points = getDrawingStrokePoints(stroke);
if (!points.length) {
return Infinity;
}
const pixelPoint = drawingPointToPixels(point, width, height);
const pixelPoints = points.map((entry) => drawingPointToPixels(entry, width, height));
if (pixelPoints.length === 1) {
const pointPixels = pixelPoints[0];
return Math.hypot(pixelPoint[0] - pointPixels[0], pixelPoint[1] - pointPixels[1]);
}
if (stroke.tool === DRAWING_TOOL.RECTANGLE) {
const start = pixelPoints[0];
const end = pixelPoints[pixelPoints.length - 1];
const corners = [
[start[0], start[1]],
[end[0], start[1]],
[end[0], end[1]],
[start[0], end[1]]
];
let minimum = Infinity;
for (let index = 0; index < corners.length; index += 1) {
const a = corners[index];
const b = corners[(index + 1) % corners.length];
minimum = Math.min(minimum, distancePointToSegment2d(pixelPoint, a, b));
}
return minimum;
}
if (stroke.tool === DRAWING_TOOL.CIRCLE) {
const center = pixelPoints[0];
const edge = pixelPoints[pixelPoints.length - 1];
const radius = Math.hypot(edge[0] - center[0], edge[1] - center[1]);
return Math.abs(Math.hypot(pixelPoint[0] - center[0], pixelPoint[1] - center[1]) - radius);
}
let minimum = Infinity;
for (let index = 1; index < pixelPoints.length; index += 1) {
const start = pixelPoints[index - 1];
const end = pixelPoints[index];
minimum = Math.min(minimum, distancePointToSegment2d(pixelPoint, start, end));
}
return minimum;
}
@@ -0,0 +1,11 @@
export const DRAWING_TOOL = Object.freeze({
FREEHAND: "freehand",
LINE: "line",
SURFACE_LINE: "surface-line",
ARROW: "arrow",
DOUBLE_ARROW: "double-arrow",
RECTANGLE: "rectangle",
CIRCLE: "circle",
FILL: "fill",
ERASE: "erase"
});
@@ -0,0 +1,463 @@
import { MAX_EXPLODED_VIEW_DEPTH, normalizeExplodedViewSettings } from "../../common/displaySettings.js";
const EPSILON = 1e-6;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const AXIS_INDEX = Object.freeze({ x: 0, y: 1, z: 2 });
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function boundsCenter(THREE, bounds, fallback = null) {
const min = Array.isArray(bounds?.min) || ArrayBuffer.isView(bounds?.min) ? bounds.min : null;
const max = Array.isArray(bounds?.max) || ArrayBuffer.isView(bounds?.max) ? bounds.max : null;
if (!min || !max) {
return fallback?.clone?.() || new THREE.Vector3();
}
return new THREE.Vector3(
(toNumber(min[0]) + toNumber(max[0])) / 2,
(toNumber(min[1]) + toNumber(max[1])) / 2,
(toNumber(min[2]) + toNumber(max[2])) / 2
);
}
function boundsRadius(THREE, bounds) {
const min = Array.isArray(bounds?.min) || ArrayBuffer.isView(bounds?.min) ? bounds.min : null;
const max = Array.isArray(bounds?.max) || ArrayBuffer.isView(bounds?.max) ? bounds.max : null;
if (!min || !max) {
return 0;
}
return new THREE.Vector3(
Math.max(toNumber(max[0]) - toNumber(min[0]), 0),
Math.max(toNumber(max[1]) - toNumber(min[1]), 0),
Math.max(toNumber(max[2]) - toNumber(min[2]), 0)
).length() / 2;
}
function boundsSize(bounds, axis) {
const min = Array.isArray(bounds?.min) || ArrayBuffer.isView(bounds?.min) ? bounds.min : null;
const max = Array.isArray(bounds?.max) || ArrayBuffer.isView(bounds?.max) ? bounds.max : null;
if (!min || !max) {
return 0;
}
return Math.max(toNumber(max[axis]) - toNumber(min[axis]), 0);
}
function boundsMaxSize(bounds) {
return Math.max(
boundsSize(bounds, 0),
boundsSize(bounds, 1),
boundsSize(bounds, 2)
);
}
function shiftedBounds(bounds, translation = [0, 0, 0], amount = 1) {
if (!bounds || !Array.isArray(bounds.min) || !Array.isArray(bounds.max)) {
return null;
}
return {
min: bounds.min.map((value, index) => toNumber(value) + toNumber(translation[index]) * amount),
max: bounds.max.map((value, index) => toNumber(value) + toNumber(translation[index]) * amount)
};
}
function mergeBounds(boundsList) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
let count = 0;
for (const bounds of Array.isArray(boundsList) ? boundsList : []) {
if (!bounds || !Array.isArray(bounds.min) || !Array.isArray(bounds.max)) {
continue;
}
count += 1;
for (let axis = 0; axis < 3; axis += 1) {
min[axis] = Math.min(min[axis], toNumber(bounds.min[axis]));
max[axis] = Math.max(max[axis], toNumber(bounds.max[axis]));
}
}
return count > 0 && min.every(Number.isFinite) && max.every(Number.isFinite) ? { min, max } : null;
}
function translationVector(THREE, value, fallback = null) {
if (value?.isVector3) {
return value;
}
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
return new THREE.Vector3(
toNumber(value[0]),
toNumber(value[1]),
toNumber(value[2])
);
}
return fallback;
}
function occurrenceSegments(partId) {
const text = String(partId || "").trim();
const match = text.match(/^o\d+(?:\.\d+)*/i);
return match ? match[0].split(".").filter(Boolean) : [];
}
function commonOccurrencePrefix(records = []) {
const paths = records
.map((record) => occurrenceSegments(record?.partId))
.filter((segments) => segments.length > 1);
if (!paths.length) {
return [];
}
const prefix = [];
const shortest = Math.min(...paths.map((segments) => segments.length));
for (let index = 0; index < shortest; index += 1) {
const value = paths[0][index];
if (!paths.every((segments) => segments[index] === value)) {
break;
}
prefix.push(value);
}
return prefix.length >= shortest ? prefix.slice(0, -1) : prefix;
}
export function explodedViewGroupKey(partId, {
depth = 1,
commonPrefix = []
} = {}) {
const text = String(partId || "").trim();
const segments = occurrenceSegments(text);
if (!segments.length) {
return text;
}
const safeDepth = Math.max(1, Math.min(Math.round(Number(depth)) || 1, MAX_EXPLODED_VIEW_DEPTH));
const prefixLength = Math.min(commonPrefix.length, Math.max(segments.length - 1, 0));
const groupLength = Math.min(segments.length, prefixLength + safeDepth);
return segments.slice(0, groupLength).join(".") || text;
}
function recordCanExplode(record) {
const partId = String(record?.partId || "").trim();
return Boolean(record?.mesh && partId && partId !== "__model__");
}
function groupRecords(THREE, records, settings) {
const commonPrefix = commonOccurrencePrefix(records);
const groupsByKey = new Map();
records.forEach((record, recordIndex) => {
const groupKey = explodedViewGroupKey(record?.partId, {
depth: settings.depth,
commonPrefix
}) || String(record?.partId || `part:${recordIndex}`);
const bounds = record?.partBounds && Array.isArray(record.partBounds.min) && Array.isArray(record.partBounds.max)
? record.partBounds
: null;
const existing = groupsByKey.get(groupKey) || {
key: groupKey,
records: [],
recordIndex,
boundsList: []
};
existing.records.push(record);
if (bounds) {
existing.boundsList.push(bounds);
}
groupsByKey.set(groupKey, existing);
});
return [...groupsByKey.values()].map((group) => {
const bounds = mergeBounds(group.boundsList) || group.records[0]?.partBounds || null;
return {
...group,
bounds,
center: boundsCenter(THREE, bounds),
radius: boundsRadius(THREE, bounds)
};
});
}
function createAxisVector(THREE, axis, direction) {
const vector = new THREE.Vector3();
vector.setComponent(AXIS_INDEX[axis] ?? 2, direction === "negative" ? -1 : 1);
return vector;
}
function fallbackDirection(THREE, index, count) {
const normalizedCount = Math.max(1, count);
const y = normalizedCount > 1
? 1 - ((index + 0.5) / normalizedCount) * 2
: 0;
const radius = Math.sqrt(Math.max(0, 1 - y * y));
const angle = index * GOLDEN_ANGLE;
const vector = new THREE.Vector3(
Math.cos(angle) * radius,
Math.sin(angle) * radius,
y
);
if (vector.lengthSq() <= EPSILON) {
vector.set(1, 0, 0);
}
return vector.normalize();
}
function radialGroupDirection(THREE, group, modelCenter, index, count, direction) {
const vector = group.center?.isVector3
? group.center.clone().sub(modelCenter)
: new THREE.Vector3();
if (vector.lengthSq() <= EPSILON) {
vector.copy(fallbackDirection(THREE, index, count));
} else {
vector.normalize();
}
return direction === "negative" ? vector.multiplyScalar(-1) : vector;
}
function createRadialExplodedViewRecordStates(THREE, groups, bounds, settings) {
const modelCenter = boundsCenter(THREE, bounds);
const modelRadius = Math.max(boundsRadius(THREE, bounds), EPSILON);
const modelMaxSize = Math.max(boundsMaxSize(bounds), EPSILON);
const spacing = Math.max(toNumber(settings.spacing, 1.45), 0.25);
const baseDistance = Math.max(modelRadius * 0.32 * spacing, modelRadius * 0.12);
const states = [];
const radialGroups = groups
.filter((group) => group.records.length > 0 && group.bounds)
.sort((left, right) => left.recordIndex - right.recordIndex);
radialGroups.forEach((group, groupIndex) => {
const direction = radialGroupDirection(
THREE,
group,
modelCenter,
groupIndex,
radialGroups.length,
settings.direction
);
const groupRadius = boundsRadius(THREE, group.bounds);
const travel = baseDistance + Math.min(groupRadius * 0.18 * spacing, modelRadius * 0.2 * spacing);
const translation = direction.clone().multiplyScalar(travel);
for (const record of group.records) {
states.push({
record,
partId: String(record.partId || "").trim(),
groupKey: group.key,
layerIndex: groupIndex,
direction: direction.clone(),
distance: travel,
translation: translation.clone(),
matrix: new THREE.Matrix4()
});
}
});
if (settings.keepBaseGrounded !== false) {
const baseMin = Array.isArray(bounds?.min) || ArrayBuffer.isView(bounds?.min) ? bounds.min : null;
const baseMinZ = baseMin ? toNumber(baseMin[2]) : null;
const explodedBounds = mergeBounds(
states.map((state) => shiftedBounds(state.record?.partBounds, state.translation.toArray(), 1))
);
const explodedMinZ = Array.isArray(explodedBounds?.min) ? toNumber(explodedBounds.min[2]) : null;
if (
Number.isFinite(baseMinZ) &&
Number.isFinite(explodedMinZ) &&
explodedMinZ < baseMinZ - EPSILON
) {
const lift = Math.min(baseMinZ - explodedMinZ, modelMaxSize * spacing);
for (const state of states) {
state.translation.z += lift;
state.distance = state.translation.length();
state.direction = state.distance > EPSILON
? state.translation.clone().normalize()
: state.direction;
}
}
}
return states;
}
export function createExplodedViewRecordStates(THREE, records = [], bounds = null, options = {}) {
if (!THREE?.Vector3 || !THREE?.Matrix4) {
return [];
}
const settings = normalizeExplodedViewSettings(options);
const radialAxis = settings.axis === "radial";
const axisIndex = AXIS_INDEX[settings.axis] ?? 2;
const explodableRecords = (Array.isArray(records) ? records : []).filter(recordCanExplode);
if (explodableRecords.length < 2) {
return [];
}
const modelRadius = Math.max(boundsRadius(THREE, bounds), EPSILON);
const modelSpan = Math.max(boundsSize(bounds, axisIndex), EPSILON);
const baseGroups = groupRecords(THREE, explodableRecords, settings)
.filter((group) => group.records.length > 0 && group.bounds);
if (baseGroups.length < 2) {
return [];
}
if (radialAxis) {
return createRadialExplodedViewRecordStates(THREE, baseGroups, bounds, settings);
}
const groups = baseGroups
.sort((left, right) => {
const leftCenter = toNumber(left.center?.getComponent?.(axisIndex));
const rightCenter = toNumber(right.center?.getComponent?.(axisIndex));
if (Math.abs(leftCenter - rightCenter) > EPSILON) {
return leftCenter - rightCenter;
}
return left.recordIndex - right.recordIndex;
});
const maxGroupThickness = Math.max(
...groups.map((group) => boundsSize(group.bounds, axisIndex)),
modelSpan * 0.08,
EPSILON
);
const sortedThicknesses = groups
.map((group) => boundsSize(group.bounds, axisIndex))
.filter((value) => value > EPSILON)
.sort((left, right) => left - right);
const medianGroupThickness = sortedThicknesses.length
? sortedThicknesses[Math.floor(sortedThicknesses.length / 2)]
: maxGroupThickness;
const layerTolerance = Math.max(modelSpan * 0.025, maxGroupThickness * 0.18, EPSILON);
const spacing = Math.max(toNumber(settings.spacing, 1.45), 0.25);
const minimumGap = Math.max(modelSpan * 0.075, medianGroupThickness * 0.35, modelRadius * 0.035, EPSILON) * spacing;
const axisVector = createAxisVector(THREE, settings.axis, settings.direction);
const layers = [];
for (const group of groups) {
const groupCenter = toNumber(group.center.getComponent(axisIndex));
const previousLayer = layers[layers.length - 1];
if (settings.mergeCoplanar === true && previousLayer && Math.abs(groupCenter - previousLayer.center) <= layerTolerance) {
previousLayer.groups.push(group);
previousLayer.bounds = mergeBounds([previousLayer.bounds, group.bounds]) || previousLayer.bounds;
previousLayer.center = (previousLayer.center * (previousLayer.groups.length - 1) + groupCenter) / previousLayer.groups.length;
} else {
layers.push({
center: groupCenter,
bounds: group.bounds,
groups: [group]
});
}
}
const layerGap = (previousLayer, layer) => {
const previousThickness = previousLayer ? boundsSize(previousLayer.bounds, axisIndex) : medianGroupThickness;
const currentThickness = layer ? boundsSize(layer.bounds, axisIndex) : medianGroupThickness;
return Math.max(minimumGap, Math.min(previousThickness, currentThickness) * 0.22 * spacing);
};
let previousMax = null;
let previousLayer = null;
const states = [];
layers.forEach((layer, layerIndex) => {
const layerMin = toNumber(layer.bounds.min[axisIndex]);
const layerMax = toNumber(layer.bounds.max[axisIndex]);
let axisDistance = 0;
if (settings.keepBaseGrounded !== false && layerIndex === 0) {
axisDistance = 0;
previousMax = layerMax;
} else {
const targetMin = previousMax === null ? layerMin : previousMax + layerGap(previousLayer, layer);
axisDistance = Math.max(0, targetMin - layerMin);
previousMax = layerMax + axisDistance;
}
previousLayer = layer;
for (const group of layer.groups) {
const translation = axisVector.clone().multiplyScalar(axisDistance);
for (const record of group.records) {
states.push({
record,
partId: String(record.partId || "").trim(),
groupKey: group.key,
layerIndex,
direction: translation.lengthSq() > EPSILON ? translation.clone().normalize() : axisVector.clone(),
distance: translation.length(),
translation,
matrix: new THREE.Matrix4()
});
}
}
});
return states;
}
export function explodedViewStateTranslationAtProgress(THREE, state, progress = 0) {
if (!THREE?.Vector3) {
return null;
}
const amount = clamp(toNumber(progress), 0, 1);
const targetTranslation = translationVector(
THREE,
state?.toTranslation,
translationVector(
THREE,
state?.translation,
new THREE.Vector3(0, 0, toNumber(state?.distance))
)
);
const fromTranslation = translationVector(THREE, state?.fromTranslation, null);
if (!fromTranslation && amount <= EPSILON) {
return null;
}
const x = fromTranslation
? fromTranslation.x + (targetTranslation.x - fromTranslation.x) * amount
: targetTranslation.x * amount;
const y = fromTranslation
? fromTranslation.y + (targetTranslation.y - fromTranslation.y) * amount
: targetTranslation.y * amount;
const z = fromTranslation
? fromTranslation.z + (targetTranslation.z - fromTranslation.z) * amount
: targetTranslation.z * amount;
if (
fromTranslation &&
Math.abs(x) <= EPSILON &&
Math.abs(y) <= EPSILON &&
Math.abs(z) <= EPSILON
) {
return null;
}
return new THREE.Vector3(x, y, z);
}
export function applyExplodedViewProgress(THREE, states = [], progress = 0) {
for (const state of Array.isArray(states) ? states : []) {
const record = state?.record;
if (!record) {
continue;
}
const translation = explodedViewStateTranslationAtProgress(THREE, state, progress);
if (!translation) {
record.explodedViewMatrix = null;
continue;
}
const matrix = state.matrix instanceof THREE.Matrix4 ? state.matrix : new THREE.Matrix4();
matrix.makeTranslation(translation.x, translation.y, translation.z);
state.matrix = matrix;
record.explodedViewMatrix = matrix;
}
}
export function clearExplodedViewRecords(records = []) {
for (const record of Array.isArray(records) ? records : []) {
if (record) {
record.explodedViewMatrix = null;
}
}
}
export function explodedViewBoundsFromStates(THREE, states = [], fallbackBounds = null, progress = 1) {
if (!THREE?.Vector3) {
return fallbackBounds;
}
const amount = clamp(toNumber(progress), 0, 1);
const boundsList = (Array.isArray(states) ? states : [])
.map((state) => shiftedBounds(state?.record?.partBounds, state?.translation?.toArray?.() || [0, 0, 0], amount));
return mergeBounds(boundsList) || fallbackBounds;
}
export function easeExplodedViewProgress(value) {
const amount = clamp(toNumber(value), 0, 1);
return 1 - (1 - amount) ** 3;
}
@@ -0,0 +1,128 @@
import {
applyDisplayRecordTransform,
buildStepClipPlane,
readBoundsCenter,
syncMaterialClipPlanes
} from "../../common/cadScene.js";
import {
clampSceneModelRadius,
getShadowCameraSettings,
normalizeSceneScaleMode,
VIEWER_SCENE_SCALE
} from "./sceneScale.js";
export {
applyDisplayRecordTransform,
buildStepClipPlane,
readBoundsCenter,
syncMaterialClipPlanes
};
export function toNumber(value, fallback = 0) {
return Number.isFinite(Number(value)) ? Number(value) : fallback;
}
export function normalizeRuntimeModelKey(value) {
return String(value ?? "").trim();
}
export function runtimeModelKeyMatches(runtime, modelKey) {
if (!runtime?.hasVisibleModel) {
return false;
}
const activeModelKey = normalizeRuntimeModelKey(runtime.activeModelKey);
const currentModelKey = normalizeRuntimeModelKey(modelKey);
return activeModelKey || currentModelKey
? activeModelKey === currentModelKey
: true;
}
export function resolveRuntimeModelFloorZ(bounds, modelPosition, sceneScaleMode) {
const positionZ = toNumber(modelPosition?.z);
if (normalizeSceneScaleMode(sceneScaleMode) === VIEWER_SCENE_SCALE.URDF) {
return positionZ;
}
const boundsMin = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
return toNumber(boundsMin[2]) + positionZ;
}
export function applyRuntimeModelBounds(THREE, runtime, bounds, sceneScaleMode, {
shadowMapSize = 2048
} = {}) {
const boundsMin = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const boundsMax = Array.isArray(bounds?.max) ? bounds.max : [0, 0, 0];
const radius = clampSceneModelRadius(
new THREE.Vector3(
toNumber(boundsMax[0]) - toNumber(boundsMin[0]),
toNumber(boundsMax[1]) - toNumber(boundsMin[1]),
toNumber(boundsMax[2]) - toNumber(boundsMin[2])
).length() / 2,
sceneScaleMode
);
runtime.modelBounds = {
min: boundsMin,
max: boundsMax
};
runtime.modelRadius = radius;
if (runtime.keyLight?.shadow?.camera) {
const keyLightDistance = typeof runtime.keyLight.position?.length === "function"
? runtime.keyLight.position.length()
: 0;
const shadowSettings = getShadowCameraSettings(sceneScaleMode, {
radius,
keyLightDistance
});
runtime.keyLight.shadow.mapSize.set(shadowMapSize, shadowMapSize);
runtime.keyLight.shadow.bias = -0.00025;
runtime.keyLight.shadow.normalBias = shadowSettings.normalBias;
runtime.keyLight.shadow.radius = shadowSettings.radius;
runtime.keyLight.shadow.camera.left = -shadowSettings.extent;
runtime.keyLight.shadow.camera.right = shadowSettings.extent;
runtime.keyLight.shadow.camera.top = shadowSettings.extent;
runtime.keyLight.shadow.camera.bottom = -shadowSettings.extent;
runtime.keyLight.shadow.camera.near = 0.1;
runtime.keyLight.shadow.camera.far = shadowSettings.far;
runtime.keyLight.shadow.camera.updateProjectionMatrix?.();
}
return {
boundsMin,
boundsMax,
radius
};
}
export function syncObjectClipPlanes(object, clipPlanes) {
if (!object?.traverse) {
return;
}
object.traverse((child) => {
syncMaterialClipPlanes(child?.material, clipPlanes);
});
}
export function syncRuntimeStepClipPlane(runtime, clipSettings) {
if (!runtime?.THREE) {
return;
}
const clipPlane = buildStepClipPlane(
runtime.THREE,
clipSettings,
runtime.modelBounds,
runtime.modelGroup?.position
);
const clipPlanes = clipPlane ? [clipPlane] : [];
runtime.activeClipPlane = clipPlane;
runtime.activeClipPlanes = clipPlanes;
if (runtime.renderer) {
runtime.renderer.localClippingEnabled = true;
}
for (const record of Array.isArray(runtime.displayRecords) ? runtime.displayRecords : []) {
syncMaterialClipPlanes(record.material, clipPlanes);
syncMaterialClipPlanes(record.edgeMaterial, clipPlanes);
}
syncObjectClipPlanes(runtime.facePickGroup, clipPlanes);
syncObjectClipPlanes(runtime.edgePickGroup, clipPlanes);
syncObjectClipPlanes(runtime.vertexPickGroup, clipPlanes);
syncObjectClipPlanes(runtime.surfaceLineGroup, clipPlanes);
syncObjectClipPlanes(runtime.topologyDisplayEdgeLine, clipPlanes);
}
@@ -0,0 +1,311 @@
import { syncLineMaterialOpacity } from "../../common/renderEdges.js";
import {
CAD_DISPLAY_MODE,
displayModeUsesTransparentSurfaces
} from "../../common/displaySettings.js";
import { REFERENCE_SELECTED_COLOR } from "./referenceGeometry.js";
import { BASE_VIEWER_THEME } from "./stageTheme.js";
import { readSourceColor } from "./surfaceMaterials.js";
const CAD_EDGE_OPACITY = 0.84;
const PART_HIGHLIGHT_SURFACE_RENDER_ORDER = 23;
const PART_HIGHLIGHT_EDGE_RENDER_ORDER = 26;
const PART_HOVER_OPACITY_BOOST = 0.08;
const PART_SELECTED_OPACITY_BOOST = 0.12;
export const FOCUSED_DIMMED_SURFACE_OPACITY = 0.035;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
export function getPartHighlightColors(THREE, {
edgeSettings = null
} = {}) {
const highlightColor = String(edgeSettings?.highlightColor || REFERENCE_SELECTED_COLOR).trim() || REFERENCE_SELECTED_COLOR;
return {
hoveredSurfaceColor: new THREE.Color(highlightColor),
hoveredEdgeColor: new THREE.Color(highlightColor),
selectedSurfaceColor: new THREE.Color(highlightColor),
selectedEdgeColor: new THREE.Color(highlightColor)
};
}
export function normalizePartIdList(value) {
return (Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean);
}
function normalizePartSelector(value) {
const text = String(value || "").trim();
return text.startsWith("#") ? text.slice(1).trim() : text;
}
function partIdMatchesSet(partId, set) {
if (!set?.size) {
return false;
}
if (set.has("__model__")) {
return true;
}
const normalizedPartId = normalizePartSelector(partId);
if (!normalizedPartId) {
return false;
}
for (const candidate of set) {
const normalizedCandidate = normalizePartSelector(candidate);
if (
normalizedCandidate &&
(
normalizedPartId === normalizedCandidate ||
normalizedPartId.startsWith(`${normalizedCandidate}.`)
)
) {
return true;
}
}
return false;
}
export function referenceMatchesFocusedPart(reference, focusedPartIdSet) {
if (!focusedPartIdSet?.size) {
return true;
}
const partId = String(reference?.partId || "").trim();
return partId
? partIdMatchesSet(partId, focusedPartIdSet)
: focusedPartIdSet.has("__model__");
}
function baseObjectRenderOrder(record, object, fieldName) {
if (!object) {
return 0;
}
if (!Number.isFinite(Number(record[fieldName]))) {
record[fieldName] = Number.isFinite(Number(object.renderOrder)) ? Number(object.renderOrder) : 0;
}
return record[fieldName];
}
function syncHighlightRenderOrder(record, object, fieldName, highlighted, highlightRenderOrder) {
if (!object) {
return;
}
const baseRenderOrder = baseObjectRenderOrder(record, object, fieldName);
object.renderOrder = highlighted ? highlightRenderOrder : baseRenderOrder;
}
function syncSurfaceTransparency(record, forceTransparent, opacity, {
writeTransparentDepth = true
} = {}) {
const material = record?.material;
if (!material) {
return;
}
if (!Object.hasOwn(record, "baseDepthWrite")) {
record.baseDepthWrite = material.depthWrite !== false;
}
const nextTransparent = forceTransparent || opacity < 0.999;
if (material.transparent !== nextTransparent) {
material.transparent = nextTransparent;
material.needsUpdate = true;
}
material.depthWrite = nextTransparent && !writeTransparentDepth ? false : record.baseDepthWrite;
}
const CAD_SURFACE_EDGE_OPACITY_UNIFORMS = Object.freeze({
feature: "cadSurfaceFeatureOpacity",
tangent: "cadSurfaceTangentOpacity",
seam: "cadSurfaceSeamOpacity",
degenerate: "cadSurfaceDegenerateOpacity"
});
const CAD_SURFACE_EDGE_COLOR_UNIFORMS = Object.freeze({
feature: "cadSurfaceFeatureColor",
tangent: "cadSurfaceTangentColor",
seam: "cadSurfaceSeamColor",
degenerate: "cadSurfaceDegenerateColor"
});
function syncCadSurfaceEdgeHighlight(THREE, record, edgeColor, edgeOpacity = null) {
const material = record?.material;
const userData = material?.userData;
if (!material || userData?.cadSurfaceEdges !== true) {
return;
}
const nextColor = edgeColor?.isColor
? edgeColor
: readSourceColor(THREE, edgeColor) || userData.cadSurfaceEdgeBaseColor;
if (nextColor?.isColor) {
userData.cadSurfaceEdgeColor = nextColor.clone();
const colorUniform = userData.cadSurfaceEdgeShader?.uniforms?.cadSurfaceEdgeColor;
if (colorUniform?.value?.copy) {
colorUniform.value.copy(nextColor);
}
}
const highlightedOpacity = edgeOpacity !== null && edgeOpacity !== undefined && Number.isFinite(Number(edgeOpacity))
? clamp(Number(edgeOpacity), 0, 1)
: null;
const baseClassSettings = userData.cadSurfaceEdgeBaseClassSettings || {};
const uniforms = userData.cadSurfaceEdgeShader?.uniforms || null;
const overrideClassColor = highlightedOpacity !== null ||
(nextColor?.isColor && userData.cadSurfaceEdgeBaseColor?.isColor && !nextColor.equals(userData.cadSurfaceEdgeBaseColor));
for (const [classId, uniformName] of Object.entries(CAD_SURFACE_EDGE_COLOR_UNIFORMS)) {
const baseClassColor = readSourceColor(THREE, baseClassSettings[classId]?.color) ||
userData.cadSurfaceEdgeBaseColor;
const nextClassColor = overrideClassColor ? nextColor : baseClassColor;
if (nextClassColor?.isColor && uniforms?.[uniformName]?.value?.copy) {
uniforms[uniformName].value.copy(nextClassColor);
}
}
for (const [classId, uniformName] of Object.entries(CAD_SURFACE_EDGE_OPACITY_UNIFORMS)) {
const baseOpacity = Number(baseClassSettings[classId]?.opacity);
const nextOpacity = highlightedOpacity === null
? (Number.isFinite(baseOpacity) ? baseOpacity : null)
: highlightedOpacity;
if (nextOpacity === null) {
continue;
}
userData[`cadSurfaceEdge${classId}Opacity`] = nextOpacity;
if (uniforms?.[uniformName]) {
uniforms[uniformName].value = nextOpacity;
}
}
}
export function applyPartVisualState(THREE, records, {
viewerTheme,
edgeSettings,
hiddenPartIds,
hoveredPartId,
focusedPartId,
selectedPartIds,
showEdges,
displayMode = CAD_DISPLAY_MODE.SOLID
}) {
const hidden = new Set(Array.isArray(hiddenPartIds) ? hiddenPartIds : []);
const selected = new Set(Array.isArray(selectedPartIds) ? selectedPartIds : []);
const hovered = new Set(
(Array.isArray(hoveredPartId) ? hoveredPartId : [hoveredPartId])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const baseEdgeColor = edgeSettings?.color || viewerTheme?.edge || BASE_VIEWER_THEME.edge;
const defaultSurfaceOpacity = Number.isFinite(Number(viewerTheme?.surfaceOpacity))
? Number(viewerTheme.surfaceOpacity)
: 1;
const focusIds = new Set(normalizePartIdList(focusedPartId));
const hasFocus = focusIds.size > 0;
const baseEdgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (viewerTheme?.edgeOpacity ?? BASE_VIEWER_THEME.edgeOpacity ?? CAD_EDGE_OPACITY);
const transparentDisplayMode = displayModeUsesTransparentSurfaces(displayMode);
const highlightEdgeOpacity = Number.isFinite(Number(edgeSettings?.highlightOpacity))
? clamp(Number(edgeSettings.highlightOpacity), 0, 1)
: 1;
const {
hoveredSurfaceColor,
hoveredEdgeColor,
selectedSurfaceColor,
selectedEdgeColor
} = getPartHighlightColors(THREE, { edgeSettings });
for (const record of Array.isArray(records) ? records : []) {
const effectStyle = record.effectStyle && typeof record.effectStyle === "object" ? record.effectStyle : {};
const effectHidden = record.effectVisible === false;
const effectColor = readSourceColor(THREE, effectStyle.color);
const effectEdgeColor = readSourceColor(THREE, effectStyle.edgeColor);
const effectEmissive = readSourceColor(THREE, effectStyle.emissive);
const isHidden = partIdMatchesSet(record.partId, hidden);
const isSelected = !isHidden && (partIdMatchesSet(record.partId, selected) || record.effectHighlighted === true);
const isHovered = !isHidden && !effectHidden && partIdMatchesSet(record.partId, hovered);
const isFocused = !isHidden && !effectHidden && hasFocus && partIdMatchesSet(record.partId, focusIds);
const isDimmed = !isHidden && !effectHidden && hasFocus && !isFocused;
const isHighlighted = isSelected || isHovered;
record.mesh.visible = !effectHidden && !isHidden;
if (record.edges) {
record.edges.visible = showEdges && !effectHidden && !isHidden;
}
syncHighlightRenderOrder(record, record.mesh, "baseMeshRenderOrder", isHighlighted, PART_HIGHLIGHT_SURFACE_RENDER_ORDER);
syncHighlightRenderOrder(record, record.edges, "baseEdgeRenderOrder", isHighlighted, PART_HIGHLIGHT_EDGE_RENDER_ORDER);
const baseSurfaceOpacity = Number.isFinite(Number(record.baseOpacity))
? Number(record.baseOpacity)
: defaultSurfaceOpacity;
const effectOpacity = Number.isFinite(Number(effectStyle.opacity))
? clamp(Number(effectStyle.opacity), 0, 1)
: 1;
const effectEdgeOpacity = Number.isFinite(Number(effectStyle.edgeOpacity))
? clamp(Number(effectStyle.edgeOpacity), 0, 1)
: effectOpacity;
const highlightedEdgeOpacity = (isSelected || isHovered) ? highlightEdgeOpacity * effectEdgeOpacity : null;
const dimmedSurfaceOpacity = Math.min(baseSurfaceOpacity * effectOpacity, FOCUSED_DIMMED_SURFACE_OPACITY);
const baseEffectSurfaceOpacity = baseSurfaceOpacity * effectOpacity;
const highlightedSurfaceOpacity = isHighlighted
? transparentDisplayMode
? clamp(
baseEffectSurfaceOpacity + (isSelected ? PART_SELECTED_OPACITY_BOOST : PART_HOVER_OPACITY_BOOST) * effectOpacity,
0,
1
)
: clamp(highlightEdgeOpacity * effectOpacity, 0, 1)
: baseEffectSurfaceOpacity;
const nextSurfaceOpacity = isHidden ? 0 : isDimmed ? dimmedSurfaceOpacity : highlightedSurfaceOpacity;
syncSurfaceTransparency(record, isHidden || isDimmed || isHighlighted, nextSurfaceOpacity, {
writeTransparentDepth: !isHidden && !isDimmed && !transparentDisplayMode
});
record.material.opacity = nextSurfaceOpacity;
if (record.baseColor && record.material.color) {
record.material.color.copy(
isSelected
? selectedSurfaceColor
: isHovered
? hoveredSurfaceColor
: effectColor || record.baseColor
);
}
if ("emissive" in record.material && record.material.emissive) {
if (isSelected) {
record.material.emissive.copy(selectedSurfaceColor);
} else if (isHovered) {
record.material.emissive.copy(hoveredSurfaceColor);
} else if (record.baseEmissiveColor && record.baseEmissiveIntensity > 0) {
record.material.emissive.copy(record.baseEmissiveColor);
} else {
record.material.emissive.set(0x000000);
}
record.material.emissiveIntensity = isSelected
? 0.08
: isHovered
? 0.12
: effectEmissive
? clamp(Number(effectStyle.emissiveIntensity) || 0.22, 0, 2)
: clamp(Number(record.baseEmissiveIntensity) || 0, 0, 2);
if (!isSelected && !isHovered && effectEmissive) {
record.material.emissive.copy(effectEmissive);
}
}
const nextEdgeColor = isSelected
? selectedEdgeColor
: isHovered
? hoveredEdgeColor
: effectEdgeColor || baseEdgeColor;
syncCadSurfaceEdgeHighlight(THREE, record, nextEdgeColor, highlightedEdgeOpacity);
if (record.edgeMaterial) {
record.edgeMaterial.color.set(nextEdgeColor);
syncLineMaterialOpacity(record.edgeMaterial, isSelected
? highlightEdgeOpacity * effectEdgeOpacity
: isHovered
? highlightEdgeOpacity * effectEdgeOpacity
: isHidden || isDimmed
? nextSurfaceOpacity
: baseEdgeOpacity * effectEdgeOpacity);
}
}
}
@@ -0,0 +1,47 @@
export function worldUnitsPerPixelAtDistance(camera, viewportHeightPx, distance) {
const safeViewportHeightPx = Number(viewportHeightPx);
const safeDistance = Number(distance);
if (!camera || !Number.isFinite(safeViewportHeightPx) || safeViewportHeightPx <= 0 || !Number.isFinite(safeDistance) || safeDistance <= 0) {
return null;
}
if (camera.isPerspectiveCamera) {
const verticalFovRadians = (Number(camera.fov) || 0) * (Math.PI / 180);
if (!(verticalFovRadians > 0 && verticalFovRadians < Math.PI)) {
return null;
}
return (2 * safeDistance * Math.tan(verticalFovRadians / 2)) / safeViewportHeightPx;
}
if (camera.isOrthographicCamera) {
const zoom = Number(camera.zoom) || 1;
const top = Number(camera.top);
const bottom = Number(camera.bottom);
if (!(zoom > 0) || !Number.isFinite(top) || !Number.isFinite(bottom)) {
return null;
}
return Math.abs(top - bottom) / zoom / safeViewportHeightPx;
}
return null;
}
export function screenLimitedPickThreshold({
baseThreshold,
thresholdScale = 1,
maxScreenDistancePx,
camera,
viewportHeightPx,
distance
} = {}) {
const scaledBaseThreshold = Math.max((Number(baseThreshold) || 0) * (Number(thresholdScale) || 0), 0);
if (!(scaledBaseThreshold > 0)) {
return 0;
}
const screenDistancePx = Number(maxScreenDistancePx);
if (!(screenDistancePx > 0)) {
return scaledBaseThreshold;
}
const unitsPerPixel = worldUnitsPerPixelAtDistance(camera, viewportHeightPx, distance);
if (!(Number.isFinite(unitsPerPixel) && unitsPerPixel > 0)) {
return scaledBaseThreshold;
}
return Math.min(scaledBaseThreshold, unitsPerPixel * screenDistancePx);
}
@@ -0,0 +1,666 @@
export const REFERENCE_HOVER_COLOR = "#8dc5ff";
export const REFERENCE_SELECTED_COLOR = "#4f9dff";
export const REFERENCE_CORNER_COLOR = "#2563eb";
export const REFERENCE_HIGHLIGHT_WIDTH_MULTIPLIER = 3;
export const REFERENCE_HOVER_HIGHLIGHT_WIDTH_MULTIPLIER = REFERENCE_HIGHLIGHT_WIDTH_MULTIPLIER;
export const REFERENCE_HOVER_FILL_OPACITY = 0.3;
export const REFERENCE_SELECTED_FILL_OPACITY = 0.24;
function isPlanarFaceReference(reference) {
const surfaceType = String(reference?.pickData?.surfaceType || reference?.pickData?.surface?.type || "")
.trim()
.toLowerCase();
return !surfaceType || surfaceType.includes("plane") || surfaceType.includes("planar");
}
export function faceFillOffset(runtime, reference) {
if (!isPlanarFaceReference(reference)) {
return [0, 0, 0];
}
const normal = Array.isArray(reference?.pickData?.normal) ? reference.pickData.normal : null;
if (!runtime?.camera || !runtime?.modelGroup || !normal || normal.length < 3) {
return [0, 0, 0];
}
const normalLength = Math.hypot(normal[0], normal[1], normal[2]);
if (normalLength <= 1e-9) {
return [0, 0, 0];
}
const normalizedNormal = [
normal[0] / normalLength,
normal[1] / normalLength,
normal[2] / normalLength
];
const center = Array.isArray(reference?.pickData?.center) ? reference.pickData.center : [0, 0, 0];
const modelOffset = runtime.modelGroup.position;
const worldCenter = [
Number(center[0] || 0) + Number(modelOffset?.x || 0),
Number(center[1] || 0) + Number(modelOffset?.y || 0),
Number(center[2] || 0) + Number(modelOffset?.z || 0)
];
const toCamera = [
runtime.camera.position.x - worldCenter[0],
runtime.camera.position.y - worldCenter[1],
runtime.camera.position.z - worldCenter[2]
];
const facingSign =
((normalizedNormal[0] * toCamera[0]) + (normalizedNormal[1] * toCamera[1]) + (normalizedNormal[2] * toCamera[2])) >= 0
? 1
: -1;
const magnitude = Math.max(Number(runtime.modelRadius || 1) * 0.00075, 0.015);
return [
normalizedNormal[0] * facingSign * magnitude,
normalizedNormal[1] * facingSign * magnitude,
normalizedNormal[2] * facingSign * magnitude
];
}
export function buildFaceFillGeometryFromProxy(runtime, THREE, selectorRuntime, reference) {
const proxy = selectorRuntime?.proxy || {};
const triangleStart = Number(reference?.pickData?.triangleStart || 0);
const triangleCount = Number(reference?.pickData?.triangleCount || 0);
if (!(proxy.facePositions instanceof Float32Array) || !(proxy.faceIndices instanceof Uint32Array) || triangleCount <= 0) {
return null;
}
const indexSlice = proxy.faceIndices.slice(triangleStart * 3, (triangleStart + triangleCount) * 3);
if (!indexSlice.length) {
return null;
}
const offset = faceFillOffset(runtime, reference);
const positions = new Float32Array(indexSlice.length * 3);
let writeOffset = 0;
for (const vertexIndex of indexSlice) {
const sourceIndex = Number(vertexIndex) * 3;
positions[writeOffset] = proxy.facePositions[sourceIndex] + offset[0];
positions[writeOffset + 1] = proxy.facePositions[sourceIndex + 1] + offset[1];
positions[writeOffset + 2] = proxy.facePositions[sourceIndex + 2] + offset[2];
writeOffset += 3;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
return geometry;
}
export function buildFaceFillGeometryFromDisplayMeshes(runtime, THREE, reference) {
const rowIndex = Number(reference?.rowIndex);
if (!Number.isInteger(rowIndex) || !Array.isArray(runtime?.displayRecords)) {
return null;
}
const offset = faceFillOffset(runtime, reference);
const vertex = new THREE.Vector3();
const fillPositions = [];
for (const record of runtime.displayRecords) {
const mesh = record?.mesh;
const geometry = mesh?.geometry;
const faceIds = mesh?.userData?.faceIds;
const positions = geometry?.getAttribute?.("position");
const indices = geometry?.getIndex?.();
if (!(faceIds instanceof Uint32Array) || !positions || !indices || !indices.count) {
continue;
}
const triangleCount = Math.min(faceIds.length, Math.floor(indices.count / 3));
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
if (Number(faceIds[triangleIndex]) !== rowIndex) {
continue;
}
for (let corner = 0; corner < 3; corner += 1) {
const sourceIndex = indices.getX((triangleIndex * 3) + corner);
vertex.set(positions.getX(sourceIndex), positions.getY(sourceIndex), positions.getZ(sourceIndex));
vertex.applyMatrix4(mesh.matrix);
fillPositions.push(vertex.x + offset[0], vertex.y + offset[1], vertex.z + offset[2]);
}
}
}
if (!fillPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(fillPositions), 3));
return geometry;
}
export function buildEdgeLinePositionsFromProxy(selectorRuntime, reference) {
const proxy = selectorRuntime?.proxy || {};
const segmentStart = Number(reference?.pickData?.segmentStart || 0);
const segmentCount = Number(reference?.pickData?.segmentCount || 0);
if (!(proxy.edgePositions instanceof Float32Array) || !(proxy.edgeIndices instanceof Uint32Array) || segmentCount <= 0) {
return null;
}
const indexSlice = proxy.edgeIndices.slice(segmentStart * 2, (segmentStart + segmentCount) * 2);
if (!indexSlice.length) {
return null;
}
const linePositions = new Float32Array(segmentCount * 6);
let writeOffset = 0;
for (let index = 0; index + 1 < indexSlice.length; index += 2) {
const startIndex = indexSlice[index] * 3;
const endIndex = indexSlice[index + 1] * 3;
linePositions[writeOffset] = proxy.edgePositions[startIndex];
linePositions[writeOffset + 1] = proxy.edgePositions[startIndex + 1];
linePositions[writeOffset + 2] = proxy.edgePositions[startIndex + 2];
linePositions[writeOffset + 3] = proxy.edgePositions[endIndex];
linePositions[writeOffset + 4] = proxy.edgePositions[endIndex + 1];
linePositions[writeOffset + 5] = proxy.edgePositions[endIndex + 2];
writeOffset += 6;
}
return writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset);
}
export function buildAdjacentEdgeLinePositions(selectorRuntime, reference) {
const selectors = Array.isArray(reference?.pickData?.adjacentSelectors) ? reference.pickData.adjacentSelectors : [];
if (!selectors.length) {
return null;
}
const positions = [];
for (const selector of selectors) {
const edgeReference =
selectorRuntime?.referenceByDisplaySelector?.get?.(selector) ||
selectorRuntime?.referenceByNormalizedSelector?.get?.(selector) ||
null;
const edgePositions = buildEdgeLinePositionsFromProxy(selectorRuntime, edgeReference);
if (!edgePositions?.length) {
continue;
}
positions.push(...edgePositions);
}
return positions.length ? positions : null;
}
export function buildFaceBoundaryLinePositions(selectorRuntime, reference) {
return buildAdjacentEdgeLinePositions(selectorRuntime, reference);
}
export function createReferenceEdgeGeometryFromPoints(THREE, points) {
if (!Array.isArray(points) || points.length < 2) {
return null;
}
const linePositions = [];
for (let index = 1; index < points.length; index += 1) {
const start = points[index - 1];
const end = points[index];
if (!Array.isArray(start) || !Array.isArray(end) || start.length < 3 || end.length < 3) {
continue;
}
linePositions.push(
Number(start[0]),
Number(start[1]),
Number(start[2]),
Number(end[0]),
Number(end[1]),
Number(end[2])
);
}
if (!linePositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(linePositions), 3));
return geometry;
}
export function createReferenceFaceLoopGeometry(THREE, loops) {
if (!Array.isArray(loops) || !loops.length) {
return null;
}
const linePositions = [];
for (const loop of loops) {
if (!Array.isArray(loop) || loop.length < 2) {
continue;
}
for (let index = 0; index < loop.length; index += 1) {
const start = loop[index];
const end = loop[(index + 1) % loop.length];
if (!Array.isArray(start) || !Array.isArray(end) || start.length < 3 || end.length < 3) {
continue;
}
linePositions.push(
Number(start[0]),
Number(start[1]),
Number(start[2]),
Number(end[0]),
Number(end[1]),
Number(end[2])
);
}
}
if (!linePositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(linePositions), 3));
return geometry;
}
export function createReferenceFaceBoundaryGeometry(THREE, reference) {
const pickData = reference?.pickData || {};
const loopsMeta = Array.isArray(pickData?.loopsMeta) ? pickData.loopsMeta : [];
const boundaryLoops = loopsMeta
.filter((loopEntry) => Array.isArray(loopEntry?.points) && loopEntry.points.length >= 2)
.map((loopEntry) => loopEntry.points);
if (boundaryLoops.length) {
return createReferenceFaceLoopGeometry(THREE, boundaryLoops);
}
const fallbackLoops = Array.isArray(pickData?.loops)
? pickData.loops.filter((loop) => Array.isArray(loop) && loop.length >= 2)
: [];
return createReferenceFaceLoopGeometry(THREE, fallbackLoops);
}
function selectOuterLoopPoints(pickData = {}) {
const loopsMeta = Array.isArray(pickData?.loopsMeta) ? pickData.loopsMeta : [];
const explicitOuter = loopsMeta.find((loop) => loop?.isOuter && Array.isArray(loop?.points) && loop.points.length >= 3);
if (explicitOuter) {
return explicitOuter.points;
}
const loops = Array.isArray(pickData?.loops) ? pickData.loops : [];
return loops.find((loop) => Array.isArray(loop) && loop.length >= 3) || null;
}
function isFinitePoint2(point) {
return Array.isArray(point) && point.length >= 2 && Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1]));
}
function isFinitePoint3(point) {
return Array.isArray(point) && point.length >= 3 && Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1])) && Number.isFinite(Number(point[2]));
}
function distanceSquared2d(a, b) {
const dx = Number(a[0]) - Number(b[0]);
const dy = Number(a[1]) - Number(b[1]);
return dx * dx + dy * dy;
}
function distanceSquared3d(a, b) {
const dx = Number(a[0]) - Number(b[0]);
const dy = Number(a[1]) - Number(b[1]);
const dz = Number(a[2]) - Number(b[2]);
return dx * dx + dy * dy + dz * dz;
}
function sanitizeLoopPair(loop3d, loop2d) {
const count = Math.min(
Array.isArray(loop3d) ? loop3d.length : 0,
Array.isArray(loop2d) ? loop2d.length : 0
);
const points3d = [];
const points2d = [];
for (let index = 0; index < count; index += 1) {
const point3d = loop3d[index];
const point2d = loop2d[index];
if (!isFinitePoint3(point3d) || !isFinitePoint2(point2d)) {
continue;
}
const normalized3d = [Number(point3d[0]), Number(point3d[1]), Number(point3d[2])];
const normalized2d = [Number(point2d[0]), Number(point2d[1])];
const previous3d = points3d[points3d.length - 1];
const previous2d = points2d[points2d.length - 1];
if (previous3d && previous2d) {
const duplicate3d = distanceSquared3d(previous3d, normalized3d) <= 1e-10;
const duplicate2d = distanceSquared2d(previous2d, normalized2d) <= 1e-10;
if (duplicate3d || duplicate2d) {
continue;
}
}
points3d.push(normalized3d);
points2d.push(normalized2d);
}
if (points3d.length >= 3 && distanceSquared3d(points3d[0], points3d[points3d.length - 1]) <= 1e-10) {
points3d.pop();
points2d.pop();
}
if (points3d.length < 3 || points2d.length < 3) {
return null;
}
return { loop3d: points3d, loop2d: points2d };
}
function averagePoint3(points) {
if (!Array.isArray(points) || !points.length) {
return [0, 0, 0];
}
let x = 0;
let y = 0;
let z = 0;
let count = 0;
for (const point of points) {
if (!isFinitePoint3(point)) {
continue;
}
x += Number(point[0]);
y += Number(point[1]);
z += Number(point[2]);
count += 1;
}
if (!count) {
return [0, 0, 0];
}
return [x / count, y / count, z / count];
}
function subtract(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}
function length(vector) {
return Math.hypot(vector[0], vector[1], vector[2]);
}
function normalizeVector3(vector) {
const magnitude = length(vector);
if (magnitude <= 1e-9) {
return null;
}
return [vector[0] / magnitude, vector[1] / magnitude, vector[2] / magnitude];
}
function cross3(a, b) {
return [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0]
];
}
function normalizeAngleAround(angle, center) {
let adjusted = angle;
while (adjusted - center > Math.PI) {
adjusted -= Math.PI * 2;
}
while (adjusted - center < -Math.PI) {
adjusted += Math.PI * 2;
}
return adjusted;
}
function projectLoopsWithFrame(loops, origin, xDir, yDir) {
return loops.map((loop) => loop.map((point) => {
const relative = subtract(point, origin);
return [dot(relative, xDir), dot(relative, yDir)];
}));
}
function projectFaceLoopsTo2d(loops, surface) {
if (!Array.isArray(loops) || !loops.length) {
return null;
}
const sanitizedLoops = loops.map((loop) => (Array.isArray(loop) ? loop.filter(isFinitePoint3) : []));
const hasAnyValidLoop = sanitizedLoops.some((loop) => loop.length >= 3);
if (!hasAnyValidLoop) {
return null;
}
if (surface?.type === "PLANE" && Array.isArray(surface?.origin) && Array.isArray(surface?.xDir) && Array.isArray(surface?.yDir)) {
const origin = surface.origin;
const xDir = surface.xDir;
const yDir = surface.yDir;
return projectLoopsWithFrame(sanitizedLoops, origin, xDir, yDir);
}
if (
surface?.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface?.origin) &&
Array.isArray(surface?.axis) &&
Array.isArray(surface?.xDir) &&
Array.isArray(surface?.yDir) &&
Number(surface?.radius) > 0
) {
const origin = surface.origin;
const axis = surface.axis;
const xDir = surface.xDir;
const yDir = surface.yDir;
const radius = Number(surface.radius);
return sanitizedLoops.map((loop) => {
let previousAngle = null;
return loop.map((point) => {
const relative = subtract(point, origin);
const axial = dot(relative, axis);
const radial = [
relative[0] - axis[0] * axial,
relative[1] - axis[1] * axial,
relative[2] - axis[2] * axial
];
const rawAngle = Math.atan2(dot(radial, yDir), dot(radial, xDir));
const angle = previousAngle === null ? rawAngle : normalizeAngleAround(rawAngle, previousAngle);
previousAngle = angle;
return [angle * radius, axial];
});
});
}
const allPoints = sanitizedLoops.flat();
const origin = isFinitePoint3(surface?.origin) ? surface.origin : averagePoint3(allPoints);
let normal = normalizeVector3(surface?.normal || []);
if (!normal) {
for (let index = 2; index < allPoints.length; index += 1) {
const a = allPoints[index - 2];
const b = allPoints[index - 1];
const c = allPoints[index];
const ab = subtract(b, a);
const ac = subtract(c, a);
normal = normalizeVector3(cross3(ab, ac));
if (normal) {
break;
}
}
}
if (!normal) {
normal = [0, 0, 1];
}
let axis = Math.abs(normal[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0];
let xDir = normalizeVector3(cross3(axis, normal));
if (!xDir) {
axis = [1, 0, 0];
xDir = normalizeVector3(cross3(axis, normal));
}
if (!xDir) {
return null;
}
const yDir = normalizeVector3(cross3(normal, xDir));
if (!yDir) {
return null;
}
return projectLoopsWithFrame(sanitizedLoops, origin, xDir, yDir);
}
export function buildFallbackFaceFanGeometry(THREE, loop3d, centroid = null) {
if (!Array.isArray(loop3d) || loop3d.length < 3) {
return null;
}
const cleanedLoop = loop3d.filter(isFinitePoint3).map((point) => [Number(point[0]), Number(point[1]), Number(point[2])]);
if (cleanedLoop.length < 3) {
return null;
}
const center = isFinitePoint3(centroid) ? centroid.map((value) => Number(value)) : averagePoint3(cleanedLoop);
const fillPositions = [];
for (let index = 0; index < cleanedLoop.length; index += 1) {
const a = cleanedLoop[index];
const b = cleanedLoop[(index + 1) % cleanedLoop.length];
fillPositions.push(
center[0], center[1], center[2],
a[0], a[1], a[2],
b[0], b[1], b[2]
);
}
if (!fillPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(fillPositions), 3));
return geometry;
}
export function createReferenceFaceFillGeometry(THREE, reference) {
const pickData = reference?.pickData;
const loops3d = Array.isArray(pickData?.loops) ? pickData.loops : [];
if (!loops3d.length) {
return null;
}
const projectedLoops = projectFaceLoopsTo2d(loops3d, pickData?.surface || {});
if (!projectedLoops) {
return buildFallbackFaceFanGeometry(
THREE,
selectOuterLoopPoints(pickData) || loops3d[0],
pickData?.centroid || null
);
}
const loopEntries = loops3d
.map((loop3d, index) => {
const cleaned = sanitizeLoopPair(loop3d, projectedLoops[index] || []);
if (!cleaned) {
return null;
}
return {
index,
...cleaned
};
})
.filter((entry) => entry && Array.isArray(entry.loop3d) && entry.loop3d.length >= 3 && Array.isArray(entry.loop2d) && entry.loop2d.length >= 3);
if (!loopEntries.length) {
return buildFallbackFaceFanGeometry(
THREE,
selectOuterLoopPoints(pickData) || loops3d[0],
pickData?.centroid || null
);
}
const preferredOuterIndex = Number.isInteger(pickData?.outerLoopIndex) ? pickData.outerLoopIndex : 0;
const outerEntry = loopEntries.find((entry) => entry.index === preferredOuterIndex) || loopEntries[0];
if (!outerEntry) {
return null;
}
const contourLoop3d = [...outerEntry.loop3d];
let contour2d = outerEntry.loop2d.map((point) => new THREE.Vector2(Number(point[0]), Number(point[1])));
if (contour2d.length < 3) {
return buildFallbackFaceFanGeometry(THREE, contourLoop3d, pickData?.centroid || null);
}
let contourClockwise = THREE.ShapeUtils.isClockWise(contour2d);
if (contourClockwise) {
contour2d = [...contour2d].reverse();
contourLoop3d.reverse();
contourClockwise = false;
}
const holeEntries = loopEntries.filter((entry) => entry.index !== outerEntry.index);
const holeData = [];
for (const holeEntry of holeEntries) {
let holeLoop3d = [...holeEntry.loop3d];
let hole2d = holeEntry.loop2d.map((point) => new THREE.Vector2(Number(point[0]), Number(point[1])));
if (hole2d.length < 3) {
continue;
}
const holeClockwise = THREE.ShapeUtils.isClockWise(hole2d);
if (holeClockwise === contourClockwise) {
hole2d = [...hole2d].reverse();
holeLoop3d.reverse();
}
holeData.push({
loop2d: hole2d,
loop3d: holeLoop3d
});
}
const triangulated = THREE.ShapeUtils.triangulateShape(
contour2d,
holeData.map((entry) => entry.loop2d)
);
if (!Array.isArray(triangulated) || !triangulated.length) {
return buildFallbackFaceFanGeometry(THREE, contourLoop3d, pickData?.centroid || null);
}
const vertexPoints3d = [
...contourLoop3d,
...holeData.flatMap((entry) => entry.loop3d)
];
if (!vertexPoints3d.length) {
return null;
}
const fillPositions = [];
for (const triangle of triangulated) {
if (!Array.isArray(triangle) || triangle.length !== 3) {
continue;
}
for (const vertexIndex of triangle) {
const point = vertexPoints3d[vertexIndex];
if (!Array.isArray(point) || point.length < 3) {
continue;
}
fillPositions.push(
Number(point[0]),
Number(point[1]),
Number(point[2])
);
}
}
if (!fillPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(fillPositions), 3));
return geometry;
}
export function buildEdgePickObjects(THREE, group, references) {
const objects = [];
for (const reference of Array.isArray(references) ? references : []) {
const points = reference?.pickData?.points;
if (!Array.isArray(points) || points.length < 2) {
continue;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(points.flat()), 3));
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0
});
const line = new THREE.Line(geometry, material);
line.userData.referenceId = String(reference?.id || "");
line.userData.partId = String(reference?.partId || "");
line.userData.metric = reference?.pickData?.metric ?? Infinity;
group.add(line);
objects.push(line);
}
group.updateMatrixWorld(true);
return objects;
}
export function buildVertexMarkerMesh(runtime, THREE, reference, {
color,
opacity,
renderOrder = 27
} = {}) {
const center = Array.isArray(reference?.pickData?.center) ? reference.pickData.center : null;
if (!center || center.length < 3) {
return null;
}
const radius = Math.max(Number(runtime?.modelRadius || 1) * 0.0045, 0.2);
const geometry = new THREE.SphereGeometry(radius, 16, 16);
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
depthTest: true,
depthWrite: false,
toneMapped: false
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(center[0], center[1], center[2]);
mesh.renderOrder = renderOrder;
return mesh;
}
@@ -0,0 +1,17 @@
export function resolveInteractionPixelRatioCap({
idlePixelRatioCap = 2,
interactionPixelRatioCap = 1,
preservePixelRatio = false,
screenSpaceLineMaterialCount = null,
screenSpaceLineMaterials = null
} = {}) {
if (preservePixelRatio) {
return idlePixelRatioCap;
}
const materialCount = screenSpaceLineMaterialCount != null && Number.isFinite(Number(screenSpaceLineMaterialCount))
? Number(screenSpaceLineMaterialCount)
: Number(screenSpaceLineMaterials?.size || 0);
return materialCount > 0
? idlePixelRatioCap
: interactionPixelRatioCap;
}
@@ -0,0 +1,90 @@
export const VIEWER_SCENE_SCALE = Object.freeze({
CAD: "cad",
URDF: "urdf"
});
const SCENE_SCALE_SETTINGS = Object.freeze({
[VIEWER_SCENE_SCALE.CAD]: Object.freeze({
minModelRadius: 1,
minGridSize: 280,
lightingScopeRadius: 140,
lightingReferenceModelRadius: 760
}),
[VIEWER_SCENE_SCALE.URDF]: Object.freeze({
minModelRadius: 0.05,
minGridSize: 0.28,
lightingScopeRadius: 0.14,
lightingReferenceModelRadius: 0.76
})
});
const SHADOW_SETTINGS = Object.freeze({
[VIEWER_SCENE_SCALE.CAD]: Object.freeze({
minExtent: 60,
normalBias: 0.024,
radius: 14
}),
[VIEWER_SCENE_SCALE.URDF]: Object.freeze({
minExtent: 0.55,
normalBias: 0.000012,
radius: 14
})
});
export function normalizeSceneScaleMode(value) {
return value === VIEWER_SCENE_SCALE.URDF
? VIEWER_SCENE_SCALE.URDF
: VIEWER_SCENE_SCALE.CAD;
}
export function getSceneScaleSettings(value) {
return SCENE_SCALE_SETTINGS[normalizeSceneScaleMode(value)];
}
export function clampSceneModelRadius(radius, value) {
const numericRadius = Number(radius);
return Math.max(
Number.isFinite(numericRadius) ? numericRadius : 0,
getSceneScaleSettings(value).minModelRadius
);
}
export function defaultSceneGridRadius(value) {
return getSceneScaleSettings(value).minGridSize / 2;
}
export function getLightingScopeRadius(value) {
return getSceneScaleSettings(value).lightingScopeRadius;
}
export function getProportionalLightingScopeRadius(radius, value) {
const sceneScaleMode = normalizeSceneScaleMode(value);
const sceneScaleSettings = getSceneScaleSettings(sceneScaleMode);
const safeModelRadius = clampSceneModelRadius(radius, sceneScaleMode);
const referenceModelRadius = Math.max(
Number(sceneScaleSettings.lightingReferenceModelRadius) || 0,
sceneScaleSettings.minModelRadius
);
return (safeModelRadius / referenceModelRadius) * sceneScaleSettings.lightingScopeRadius;
}
export function getShadowCameraSettings(value, { radius = 0, keyLightDistance = 0 } = {}) {
const sceneScaleMode = normalizeSceneScaleMode(value);
const shadowSettings = SHADOW_SETTINGS[sceneScaleMode];
const shadowScopeRadius = clampSceneModelRadius(radius, sceneScaleMode);
const shadowExtent = Math.max(shadowScopeRadius * 2.8, shadowSettings.minExtent);
const shadowFar = sceneScaleMode === VIEWER_SCENE_SCALE.URDF
? Math.max(
shadowScopeRadius * 8,
Math.max(Number(keyLightDistance) || 0, 0) + (shadowScopeRadius * 6) + 1
)
: Math.max(shadowScopeRadius * 8, 320);
return {
scopeRadius: shadowScopeRadius,
extent: shadowExtent,
far: shadowFar,
normalBias: shadowSettings.normalBias,
radius: shadowSettings.radius
};
}
@@ -0,0 +1,257 @@
export function dataUrlToBlob(dataUrl) {
const parts = String(dataUrl || "").split(",");
if (parts.length !== 2) {
throw new Error("Screenshot capture failed");
}
const mimeMatch = parts[0].match(/data:(.*?);base64/);
const mimeType = mimeMatch?.[1] || "image/png";
const binary = atob(parts[1]);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}
export async function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
if (!canvas) {
reject(new Error("Screenshot capture failed"));
return;
}
if (typeof canvas.toBlob === "function") {
canvas.toBlob((value) => {
if (value) {
resolve(value);
return;
}
reject(new Error("Screenshot capture failed"));
}, "image/png");
return;
}
try {
resolve(dataUrlToBlob(canvas.toDataURL("image/png")));
} catch (captureError) {
reject(captureError);
}
});
}
export function flipPixelsVertically(pixels, width, height) {
const rowSize = width * 4;
const flipped = new Uint8ClampedArray(pixels.length);
for (let row = 0; row < height; row += 1) {
const sourceStart = (height - row - 1) * rowSize;
const targetStart = row * rowSize;
flipped.set(pixels.subarray(sourceStart, sourceStart + rowSize), targetStart);
}
return flipped;
}
export function normalizeScreenshotCrop(crop, width, height) {
const fullWidth = Math.max(1, Math.floor(Number(width) || 1));
const fullHeight = Math.max(1, Math.floor(Number(height) || 1));
const x = Math.min(Math.max(0, Math.floor(Number(crop?.x) || 0)), fullWidth - 1);
const y = Math.min(Math.max(0, Math.floor(Number(crop?.y) || 0)), fullHeight - 1);
const maxWidth = Math.max(1, fullWidth - x);
const maxHeight = Math.max(1, fullHeight - y);
const cropWidth = Math.min(Math.max(1, Math.floor(Number(crop?.width) || maxWidth)), maxWidth);
const cropHeight = Math.min(Math.max(1, Math.floor(Number(crop?.height) || maxHeight)), maxHeight);
return {
x,
y,
width: cropWidth,
height: cropHeight,
isFullFrame: x === 0 && y === 0 && cropWidth === fullWidth && cropHeight === fullHeight
};
}
function isTransparentCssColor(value) {
const normalized = String(value || "").trim().toLowerCase();
if (!normalized || normalized === "transparent") {
return true;
}
if (/^[a-z]+\(\s*[^,]+,\s*[^,]+,\s*[^,]+,\s*0(?:\.0+)?%?\s*\)$/.test(normalized)) {
return true;
}
return /^[a-z]+\([^)]*\/\s*0(?:\.0+)?%?\s*\)$/.test(normalized);
}
export function resolveElementBackgroundColor(element, fallback = "#ffffff") {
const getComputedStyle = globalThis.window?.getComputedStyle || globalThis.getComputedStyle;
let current = element || null;
const visited = new Set();
while (current && !visited.has(current)) {
visited.add(current);
const backgroundColor = typeof getComputedStyle === "function"
? getComputedStyle(current)?.backgroundColor
: "";
if (!isTransparentCssColor(backgroundColor)) {
return backgroundColor;
}
current = current.parentElement || null;
}
const body = globalThis.document?.body || null;
if (body && !visited.has(body) && typeof getComputedStyle === "function") {
const backgroundColor = getComputedStyle(body)?.backgroundColor;
if (!isTransparentCssColor(backgroundColor)) {
return backgroundColor;
}
}
return fallback;
}
export function paintScreenshotImageData(context, imageData, width, height, {
backgroundColor = "",
crop = null,
overlayCanvas = null,
createCanvas = () => globalThis.document?.createElement?.("canvas")
} = {}) {
if (!context) {
throw new Error("Screenshot capture failed");
}
const cropRect = normalizeScreenshotCrop(crop, width, height);
const resolvedBackground = String(backgroundColor || "").trim();
if (resolvedBackground || !cropRect.isFullFrame) {
const imageCanvas = createCanvas();
imageCanvas.width = width;
imageCanvas.height = height;
const imageContext = imageCanvas.getContext("2d");
if (!imageContext) {
throw new Error("Screenshot capture failed");
}
imageContext.putImageData(imageData, 0, 0);
const previousFillStyle = context.fillStyle;
context.fillStyle = "#ffffff";
context.fillStyle = resolvedBackground;
if (resolvedBackground) {
context.fillRect(0, 0, cropRect.width, cropRect.height);
}
context.drawImage(
imageCanvas,
cropRect.x,
cropRect.y,
cropRect.width,
cropRect.height,
0,
0,
cropRect.width,
cropRect.height
);
context.fillStyle = previousFillStyle;
} else {
context.putImageData(imageData, 0, 0);
}
if (overlayCanvas) {
context.drawImage(
overlayCanvas,
cropRect.x,
cropRect.y,
cropRect.width,
cropRect.height,
0,
0,
cropRect.width,
cropRect.height
);
}
}
export function paintScreenshotSourceCanvas(context, sourceCanvas, width, height, {
backgroundColor = "",
crop = null,
overlayCanvas = null
} = {}) {
if (!context || !sourceCanvas) {
throw new Error("Screenshot capture failed");
}
const cropRect = normalizeScreenshotCrop(crop, width, height);
const resolvedBackground = String(backgroundColor || "").trim();
const previousFillStyle = context.fillStyle;
if (resolvedBackground) {
context.fillStyle = "#ffffff";
context.fillStyle = resolvedBackground;
context.fillRect(0, 0, cropRect.width, cropRect.height);
}
context.drawImage(
sourceCanvas,
cropRect.x,
cropRect.y,
cropRect.width,
cropRect.height,
0,
0,
cropRect.width,
cropRect.height
);
context.fillStyle = previousFillStyle;
if (overlayCanvas) {
context.drawImage(
overlayCanvas,
cropRect.x,
cropRect.y,
cropRect.width,
cropRect.height,
0,
0,
cropRect.width,
cropRect.height
);
}
}
// Render the live framebuffer once and immediately copy it into a 2D export
// canvas, keeping normal interaction free to use `preserveDrawingBuffer: false`.
export async function buildCompositeScreenshotBlob(runtime, overlayCanvas, {
backgroundColor = "",
crop = null
} = {}) {
const renderer = runtime?.renderer;
const scene = runtime?.scene;
const camera = runtime?.camera;
const width = renderer?.domElement?.width || 0;
const height = renderer?.domElement?.height || 0;
if (!renderer || !scene || !camera || width <= 0 || height <= 0) {
throw new Error("Screenshot capture failed");
}
const previousRenderTarget = renderer.getRenderTarget();
const previousXrEnabled = renderer.xr?.enabled === true;
try {
if (renderer.xr) {
renderer.xr.enabled = false;
}
renderer.setRenderTarget(null);
renderer.render(scene, camera);
} finally {
renderer.setRenderTarget(previousRenderTarget);
if (renderer.xr) {
renderer.xr.enabled = previousXrEnabled;
}
}
const cropRect = normalizeScreenshotCrop(crop, width, height);
const exportCanvas = document.createElement("canvas");
exportCanvas.width = cropRect.width;
exportCanvas.height = cropRect.height;
const context = exportCanvas.getContext("2d");
if (!context) {
throw new Error("Screenshot capture failed");
}
paintScreenshotSourceCanvas(context, renderer.domElement, width, height, {
backgroundColor,
crop: cropRect,
overlayCanvas
});
return canvasToBlob(exportCanvas);
}
@@ -0,0 +1,269 @@
export const TOPOLOGY_FACE_ID_NONE = 0xffffffff;
export function buildFacePickMesh(THREE, selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
if (!(proxy.facePositions instanceof Float32Array) || !(proxy.faceIndices instanceof Uint32Array) || !proxy.faceIndices.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(proxy.facePositions, 3));
geometry.setIndex(new THREE.BufferAttribute(proxy.faceIndices, 1));
const material = new THREE.MeshBasicMaterial({
color: 0xffffff,
side: THREE.DoubleSide,
transparent: true,
opacity: 0,
depthWrite: false,
toneMapped: false,
colorWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData.faceIds = proxy.faceIds || new Uint32Array(0);
mesh.frustumCulled = false;
return mesh;
}
function faceRunColumnIndexes(selectorRuntime) {
const columns = Array.isArray(selectorRuntime?.proxy?.faceRunColumns) && selectorRuntime.proxy.faceRunColumns.length
? selectorRuntime.proxy.faceRunColumns
: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"];
return {
stride: columns.length,
occurrenceRow: Math.max(0, columns.indexOf("occurrenceRow")),
primitiveIndex: Math.max(0, columns.indexOf("primitiveIndex")),
triangleStart: Math.max(0, columns.indexOf("triangleStart")),
triangleCount: Math.max(0, columns.indexOf("triangleCount")),
faceRow: Math.max(0, columns.indexOf("faceRow"))
};
}
export function buildGlbFaceIdsForPart(part, selectorRuntime) {
const runs = selectorRuntime?.proxy?.faceRuns;
const triangleCount = Math.max(0, Math.floor(Number(part?.triangleCount || 0)));
if (!(runs instanceof Uint32Array) || !runs.length || triangleCount <= 0) {
return null;
}
const occurrenceId = String(part?.occurrenceId || part?.id || "").trim();
if (!occurrenceId) {
return null;
}
const primitiveIndex = Math.max(0, Math.floor(Number(part?.primitiveIndex || 0)));
const columns = faceRunColumnIndexes(selectorRuntime);
const faceIds = new Uint32Array(triangleCount);
faceIds.fill(TOPOLOGY_FACE_ID_NONE);
const sourcePartRanges = Array.isArray(part?.sourcePartRanges)
? part.sourcePartRanges
.map((range) => ({
occurrenceId: String(range?.occurrenceId || "").trim(),
primitiveIndex: Math.max(0, Math.floor(Number(range?.primitiveIndex || 0))),
triangleOffset: Math.max(0, Math.floor(Number(range?.triangleOffset || 0))),
triangleCount: Math.max(0, Math.floor(Number(range?.triangleCount || 0)))
}))
.filter((range) => range.occurrenceId && range.triangleCount > 0)
: [];
let matched = false;
for (let offset = 0; offset + columns.stride <= runs.length; offset += columns.stride) {
const runOccurrenceId = selectorRuntime?.occurrenceIdByRowIndex?.get?.(Number(runs[offset + columns.occurrenceRow]));
const triangleStart = Number(runs[offset + columns.triangleStart]);
const runTriangleCount = Number(runs[offset + columns.triangleCount]);
const faceRow = Number(runs[offset + columns.faceRow]);
if (
!Number.isInteger(triangleStart) ||
!Number.isInteger(runTriangleCount) ||
!Number.isInteger(faceRow) ||
triangleStart < 0 ||
runTriangleCount <= 0 ||
faceRow < 0
) {
continue;
}
if (sourcePartRanges.length) {
for (const range of sourcePartRanges) {
if (runOccurrenceId !== range.occurrenceId || Number(runs[offset + columns.primitiveIndex]) !== range.primitiveIndex) {
continue;
}
if (triangleStart >= range.triangleCount) {
continue;
}
const start = range.triangleOffset + triangleStart;
const end = Math.min(range.triangleOffset + triangleStart + runTriangleCount, range.triangleOffset + range.triangleCount, triangleCount);
if (end <= start || start >= triangleCount) {
continue;
}
faceIds.fill(faceRow, start, end);
matched = true;
}
continue;
}
if (runOccurrenceId !== occurrenceId || Number(runs[offset + columns.primitiveIndex]) !== primitiveIndex || triangleStart >= triangleCount) {
continue;
}
const end = Math.min(triangleStart + runTriangleCount, triangleCount);
faceIds.fill(faceRow, triangleStart, end);
matched = true;
}
return matched ? faceIds : null;
}
export function buildGlbFaceIdsForMesh(meshData, selectorRuntime) {
const triangleCount = Math.floor((meshData?.indices?.length || 0) / 3);
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
if (triangleCount <= 0 || !parts.length) {
return null;
}
const faceIds = new Uint32Array(triangleCount);
faceIds.fill(TOPOLOGY_FACE_ID_NONE);
let matched = false;
for (const part of parts) {
const partFaceIds = buildGlbFaceIdsForPart(part, selectorRuntime);
if (!partFaceIds) {
continue;
}
const triangleOffset = Math.max(0, Math.floor(Number(part?.triangleOffset || 0)));
const end = Math.min(triangleOffset + partFaceIds.length, faceIds.length);
if (end <= triangleOffset) {
continue;
}
faceIds.set(partFaceIds.subarray(0, end - triangleOffset), triangleOffset);
matched = true;
}
return matched ? faceIds : null;
}
function sourcePartForRecord(record, partId) {
const sourcePart = record?.sourcePart || record?.part || null;
const sourcePartId = String(sourcePart?.id || sourcePart?.occurrenceId || "").trim();
return sourcePartId && sourcePartId === partId ? sourcePart : null;
}
export function syncDisplayMeshFaceIds(runtime, meshData, selectorRuntime) {
const records = Array.isArray(runtime?.displayRecords) ? runtime.displayRecords : [];
if (!records.length) {
return;
}
const partsById = new Map(
(Array.isArray(meshData?.parts) ? meshData.parts : [])
.map((part) => [String(part?.id || ""), part])
.filter(([partId]) => partId)
);
for (const record of records) {
const mesh = record?.mesh;
if (!mesh?.userData) {
continue;
}
let faceIds = null;
const partId = String(record?.partId || "").trim();
if (partId && partId !== "__model__") {
const part = partsById.get(partId) || sourcePartForRecord(record, partId);
faceIds = part ? buildGlbFaceIdsForPart(part, selectorRuntime) : null;
} else {
faceIds = buildGlbFaceIdsForMesh(meshData, selectorRuntime);
}
if (faceIds) {
mesh.userData.faceIds = faceIds;
} else {
delete mesh.userData.faceIds;
}
}
}
export function buildEdgePickLines(THREE, selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
if (!(proxy.edgePositions instanceof Float32Array) || !(proxy.edgeIndices instanceof Uint32Array) || !proxy.edgeIndices.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(proxy.edgePositions, 3));
geometry.setIndex(new THREE.BufferAttribute(proxy.edgeIndices, 1));
const material = new THREE.LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0,
depthWrite: false,
toneMapped: false,
});
const lines = new THREE.LineSegments(geometry, material);
lines.userData.edgeIds = proxy.edgeIds || new Uint32Array(0);
lines.frustumCulled = false;
return lines;
}
export function buildVertexPickPoints(THREE, selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
if (!(proxy.vertexPositions instanceof Float32Array) || !proxy.vertexPositions.length) {
return null;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(proxy.vertexPositions, 3));
const material = new THREE.PointsMaterial({
color: 0xffffff,
transparent: true,
opacity: 0,
size: 1.5,
sizeAttenuation: false,
depthWrite: false,
toneMapped: false,
});
const points = new THREE.Points(geometry, material);
points.userData.vertexIds = proxy.vertexIds || new Uint32Array(0);
points.frustumCulled = false;
return points;
}
function clearPickGroup(group, clearSceneGroup) {
if (typeof clearSceneGroup === "function") {
clearSceneGroup(group);
return;
}
while (group?.children?.length) {
group.remove(group.children[0]);
}
}
export function syncSelectorPickGroups(runtime, selectorRuntime, modelOffset = null, {
clearSceneGroup = null
} = {}) {
if (!runtime?.THREE || !runtime?.facePickGroup || !runtime?.edgePickGroup || !runtime?.vertexPickGroup) {
return;
}
clearPickGroup(runtime.facePickGroup, clearSceneGroup);
clearPickGroup(runtime.edgePickGroup, clearSceneGroup);
clearPickGroup(runtime.vertexPickGroup, clearSceneGroup);
runtime.facePickMesh = null;
runtime.edgePickLines = null;
runtime.vertexPickPoints = null;
runtime.edgePickObjects = [];
const facePickMesh = buildFacePickMesh(runtime.THREE, selectorRuntime);
if (facePickMesh) {
runtime.facePickMesh = facePickMesh;
runtime.facePickGroup.add(facePickMesh);
}
const edgePickLines = buildEdgePickLines(runtime.THREE, selectorRuntime);
if (edgePickLines) {
runtime.edgePickLines = edgePickLines;
runtime.edgePickGroup.add(edgePickLines);
runtime.edgePickObjects = [edgePickLines];
}
const vertexPickPoints = buildVertexPickPoints(runtime.THREE, selectorRuntime);
if (vertexPickPoints) {
runtime.vertexPickPoints = vertexPickPoints;
runtime.vertexPickGroup.add(vertexPickPoints);
}
if (modelOffset) {
runtime.facePickGroup.position.copy(modelOffset);
runtime.edgePickGroup.position.copy(modelOffset);
runtime.vertexPickGroup.position.copy(modelOffset);
} else {
runtime.facePickGroup.position.set(0, 0, 0);
runtime.edgePickGroup.position.set(0, 0, 0);
runtime.vertexPickGroup.position.set(0, 0, 0);
}
runtime.facePickGroup.updateMatrixWorld(true);
runtime.edgePickGroup.updateMatrixWorld(true);
runtime.vertexPickGroup.updateMatrixWorld(true);
}
@@ -0,0 +1,144 @@
import {
clampSceneModelRadius,
VIEWER_SCENE_SCALE,
getSceneScaleSettings
} from "./sceneScale.js";
import { BASE_VIEWER_THEME } from "./stageTheme.js";
import {
DEFAULT_FLOOR_GRID_SETTINGS,
MAX_FLOOR_GRID_DENSITY,
MIN_FLOOR_GRID_DENSITY,
THEME_FLOOR_MODES
} from "../themeSettings.js";
import { DEFAULT_AUTO_ZOOM_PADDING } from "./autoZoom.js";
export const DEFAULT_GRID_DIVISIONS = 28;
export const GRID_TARGET_VISIBLE_CELLS = 1.25;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeGridDensity(value) {
const density = Number(value);
return Number.isFinite(density)
? clamp(density, MIN_FLOOR_GRID_DENSITY, MAX_FLOOR_GRID_DENSITY)
: DEFAULT_FLOOR_GRID_SETTINGS.density;
}
function resolveGridStyle(viewerTheme = {}, floorSettings = {}) {
const gridSettings = floorSettings?.grid || {};
return {
centerColor: gridSettings?.centerColor
|| floorSettings?.gridCenterColor
|| floorSettings?.gridCenter
|| viewerTheme?.gridCenter
|| BASE_VIEWER_THEME.gridCenter,
cellColor: gridSettings?.cellColor
|| floorSettings?.gridCellColor
|| floorSettings?.gridCell
|| viewerTheme?.gridCell
|| BASE_VIEWER_THEME.gridCell,
opacity: Number.isFinite(Number(gridSettings?.opacity))
? clamp(Number(gridSettings.opacity), 0, 1)
: Number.isFinite(Number(floorSettings?.gridOpacity))
? clamp(Number(floorSettings.gridOpacity), 0, 1)
: (viewerTheme?.gridOpacity ?? BASE_VIEWER_THEME.gridOpacity)
};
}
export function niceGridStep(minimumStep) {
if (!Number.isFinite(minimumStep) || minimumStep <= 0) {
return getSceneScaleSettings(VIEWER_SCENE_SCALE.CAD).minGridSize / DEFAULT_GRID_DIVISIONS;
}
const exponent = Math.floor(Math.log10(minimumStep));
const base = 10 ** exponent;
for (const multiplier of [1, 2, 5, 10]) {
const step = base * multiplier;
if (step >= minimumStep) {
return step;
}
}
return base * 10;
}
export function buildGridConfig(radius, sceneScaleMode, floorSettings = {}) {
const gridDensity = normalizeGridDensity(floorSettings?.grid?.density ?? floorSettings?.gridDensity);
const safeRadius = clampSceneModelRadius(radius, sceneScaleMode);
const targetVisibleCells = GRID_TARGET_VISIBLE_CELLS * gridDensity;
const cellSize = (safeRadius * 2 * DEFAULT_AUTO_ZOOM_PADDING) / targetVisibleCells;
let divisions = Math.max(2, Math.round(DEFAULT_GRID_DIVISIONS * gridDensity));
if (divisions % 2 !== 0) {
divisions += 1;
}
return {
size: cellSize * divisions,
cellSize,
divisions
};
}
export function updateGridHelper(
runtime,
viewerTheme,
radius,
floorZ = 0,
sceneScaleMode = VIEWER_SCENE_SCALE.CAD,
floorMode = THEME_FLOOR_MODES.STAGE,
{ disposeSceneObject = () => {}, floorSettings = {} } = {}
) {
if (!runtime?.THREE || !runtime?.scene) {
return;
}
runtime.gridRadius = radius;
runtime.gridFloorZ = floorZ;
const gridEnabled = floorSettings?.grid?.enabled === true || floorMode === THEME_FLOOR_MODES.GRID;
runtime.floorMode = gridEnabled ? THEME_FLOOR_MODES.GRID : floorMode;
if (!gridEnabled) {
disposeSceneObject(runtime.gridHelper);
runtime.gridHelper = null;
runtime.gridConfig = null;
return;
}
const nextConfig = {
...buildGridConfig(radius, sceneScaleMode, floorSettings),
...resolveGridStyle(viewerTheme, floorSettings)
};
const currentConfig = runtime.gridConfig;
if (
currentConfig &&
currentConfig.size === nextConfig.size &&
currentConfig.cellSize === nextConfig.cellSize &&
currentConfig.divisions === nextConfig.divisions &&
currentConfig.centerColor === nextConfig.centerColor &&
currentConfig.cellColor === nextConfig.cellColor &&
currentConfig.opacity === nextConfig.opacity
) {
if (runtime.gridHelper) {
runtime.gridHelper.rotation.x = Math.PI / 2;
}
runtime.gridHelper?.position.set(0, 0, floorZ);
return;
}
disposeSceneObject(runtime.gridHelper);
runtime.gridHelper = new runtime.THREE.GridHelper(
nextConfig.size,
nextConfig.divisions,
nextConfig.centerColor,
nextConfig.cellColor
);
const materials = Array.isArray(runtime.gridHelper.material)
? runtime.gridHelper.material
: [runtime.gridHelper.material];
for (const material of materials) {
material.transparent = true;
material.opacity = nextConfig.opacity;
material.depthWrite = false;
material.toneMapped = false;
}
runtime.gridHelper.rotation.x = Math.PI / 2;
runtime.gridHelper.position.set(0, 0, floorZ);
runtime.scene.add(runtime.gridHelper);
runtime.gridConfig = nextConfig;
}
@@ -0,0 +1,651 @@
import { THEME_FLOOR_MODES } from "../themeSettings.js";
import {
clampSceneModelRadius,
VIEWER_SCENE_SCALE,
getSceneScaleSettings
} from "./sceneScale.js";
export const BASE_VIEWER_THEME = {
sceneBackground: "#09090b",
surface: "#f4f4f5",
surfaceRoughness: 0.92,
surfaceMetalness: 0.03,
surfaceClearcoat: 0,
surfaceClearcoatRoughness: 0.6,
edge: "#18181b",
edgeThickness: 1,
edgeOpacity: 0.84,
selected: "#2563eb",
hover: "#0ea5e9",
gridCenter: "#3f3f46",
gridCell: "#27272a",
gridOpacity: 0.16,
stageFloorColor: "#141416",
stageFloorOpacity: 0.78,
stageFloorRoughness: 0.92,
stageFloorMetalness: 0,
stageFloorTransmission: 0,
stageFloorIor: 1.35,
stageFloorThickness: 0.035,
stageFloorAttenuationDistance: 4,
viewPlanePalette: {
axis: {
x: {
front: [250, 88, 79],
back: [122, 32, 28]
},
y: {
front: [92, 233, 123],
back: [30, 99, 46]
},
z: {
front: [84, 131, 255],
back: [30, 53, 126]
}
},
center: {
fill: [252, 215, 74],
stroke: [255, 235, 153]
},
shell: {
inner: [24, 31, 48],
outer: [8, 12, 20],
stroke: [148, 163, 184]
}
}
};
export const THEME_BACKGROUND_TYPES = {
SOLID: "solid",
LINEAR: "linear",
RADIAL: "radial",
TRANSPARENT: "transparent"
};
export const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const BACKGROUND_TEXTURE_SIZE = 1024;
const FLOOR_GLOW_TEXTURE_SIZE = 512;
const MIN_WIREFRAME_EDGE_CONTRAST = 3;
const WIREFRAME_LIGHT_EDGE_COLOR = "#dbeafe";
const WIREFRAME_DARK_EDGE_COLOR = "#111827";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeHexColor(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 hexChannelToLinear(value) {
const srgb = value / 255;
return srgb <= 0.03928
? srgb / 12.92
: ((srgb + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance(value) {
const color = normalizeHexColor(value, "#000000");
const red = parseInt(color.slice(1, 3), 16);
const green = parseInt(color.slice(3, 5), 16);
const blue = parseInt(color.slice(5, 7), 16);
return (
0.2126 * hexChannelToLinear(red) +
0.7152 * hexChannelToLinear(green) +
0.0722 * hexChannelToLinear(blue)
);
}
function contrastRatio(colorA, colorB) {
const luminanceA = relativeLuminance(colorA);
const luminanceB = relativeLuminance(colorB);
const lighter = Math.max(luminanceA, luminanceB);
const darker = Math.min(luminanceA, luminanceB);
return (lighter + 0.05) / (darker + 0.05);
}
function minimumContrast(color, backgroundColors) {
const normalizedBackgrounds = backgroundColors
.map((backgroundColor) => normalizeHexColor(backgroundColor, ""))
.filter(Boolean);
if (!normalizedBackgrounds.length) {
return Infinity;
}
return Math.min(...normalizedBackgrounds.map((backgroundColor) => contrastRatio(color, backgroundColor)));
}
function wireframeBackgroundColors(themeSettings = {}, viewerTheme = BASE_VIEWER_THEME) {
const background = themeSettings?.background || {};
const backgroundType = String(background.type || "").trim().toLowerCase();
const colors = [];
if (backgroundType === THEME_BACKGROUND_TYPES.LINEAR) {
colors.push(background.linearStart, background.linearEnd);
} else if (backgroundType === THEME_BACKGROUND_TYPES.RADIAL) {
colors.push(background.radialInner, background.radialOuter);
} else if (backgroundType === THEME_BACKGROUND_TYPES.SOLID) {
colors.push(background.solidColor);
}
const normalizedColors = colors
.map((backgroundColor) => normalizeHexColor(backgroundColor, ""))
.filter(Boolean);
return normalizedColors.length
? normalizedColors
: [viewerTheme?.sceneBackground, BASE_VIEWER_THEME.sceneBackground];
}
export function resolveWireframeEdgeColor({
edgeColor = "",
themeSettings = {},
viewerTheme = BASE_VIEWER_THEME,
minimumContrastRatio = MIN_WIREFRAME_EDGE_CONTRAST
} = {}) {
const normalizedEdgeColor = normalizeHexColor(edgeColor, "");
const backgroundColors = wireframeBackgroundColors(themeSettings, viewerTheme);
if (
normalizedEdgeColor &&
minimumContrast(normalizedEdgeColor, backgroundColors) >= minimumContrastRatio
) {
return normalizedEdgeColor;
}
return minimumContrast(WIREFRAME_LIGHT_EDGE_COLOR, backgroundColors) >=
minimumContrast(WIREFRAME_DARK_EDGE_COLOR, backgroundColors)
? WIREFRAME_LIGHT_EDGE_COLOR
: WIREFRAME_DARK_EDGE_COLOR;
}
export function getViewerThemeValue(viewerTheme, key, fallback) {
const value = viewerTheme?.[key];
return value ?? BASE_VIEWER_THEME[key] ?? fallback;
}
export function getViewerThemeNumber(viewerTheme, key, fallback) {
const value = Number(getViewerThemeValue(viewerTheme, key, fallback));
return Number.isFinite(value) ? value : fallback;
}
export function normalizeFloorMode(value, fallback = THEME_FLOOR_MODES.STAGE) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "glass") {
return THEME_FLOOR_MODES.STAGE;
}
return Object.values(THEME_FLOOR_MODES).includes(normalized)
? normalized
: fallback;
}
export function resolveFloorMode(floorSettings = {}) {
const gridEnabled = floorSettings?.grid?.enabled === true;
const floorEnabled = floorSettings?.enabled !== false;
const gridExplicitlyDisabled = floorSettings?.grid?.enabled === false;
if (gridEnabled) {
return THEME_FLOOR_MODES.GRID;
}
if (!floorEnabled) {
return THEME_FLOOR_MODES.NONE;
}
if (gridExplicitlyDisabled) {
return THEME_FLOOR_MODES.STAGE;
}
return normalizeFloorMode(floorSettings?.mode);
}
function toThemeArray(value) {
if (Array.isArray(value)) {
return value.filter(Boolean);
}
return value ? [value] : [];
}
function colorToRgba(THREE, value, alpha = 1) {
const color = new THREE.Color(value || "#000000");
return `rgba(${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}, ${clamp(alpha, 0, 1)})`;
}
export function normalizeGradientStops(stops) {
const filteredStops = toThemeArray(stops);
if (!filteredStops.length) {
return [];
}
return filteredStops
.map((stop, index) => {
if (typeof stop === "string") {
return {
offset: filteredStops.length === 1 ? 0 : index / (filteredStops.length - 1),
color: stop
};
}
const fallbackOffset = filteredStops.length === 1 ? 0 : index / (filteredStops.length - 1);
const offset = Number(stop?.offset);
return {
offset: Number.isFinite(offset) ? clamp(offset, 0, 1) : fallbackOffset,
color: stop?.color || stop?.value || "#000000"
};
})
.sort((left, right) => left.offset - right.offset);
}
export function createSceneBackgroundTexture(THREE, viewerTheme, themeBackground = null) {
const backgroundType = String(themeBackground?.type || "").trim().toLowerCase();
const useThemeBackground = !!backgroundType;
const gradientStops = useThemeBackground
? (
backgroundType === THEME_BACKGROUND_TYPES.LINEAR
? [
{ offset: 0, color: themeBackground.linearStart || "#000000" },
{ offset: 1, color: themeBackground.linearEnd || "#ffffff" }
]
: backgroundType === THEME_BACKGROUND_TYPES.RADIAL
? [
{ offset: 0, color: themeBackground.radialInner || "#000000" },
{ offset: 1, color: themeBackground.radialOuter || "#ffffff" }
]
: []
)
: normalizeGradientStops(viewerTheme?.sceneBackgroundGradient);
const glowLayers = useThemeBackground ? [] : toThemeArray(viewerTheme?.sceneBackgroundGlow);
if (!gradientStops.length && !glowLayers.length) {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = BACKGROUND_TEXTURE_SIZE;
canvas.height = BACKGROUND_TEXTURE_SIZE;
const context = canvas.getContext("2d");
if (!context) {
return null;
}
context.fillStyle = useThemeBackground
? themeBackground.solidColor || BASE_VIEWER_THEME.sceneBackground
: viewerTheme?.sceneBackground || BASE_VIEWER_THEME.sceneBackground;
context.fillRect(0, 0, canvas.width, canvas.height);
if (gradientStops.length) {
if (backgroundType === THEME_BACKGROUND_TYPES.RADIAL) {
const radialGradient = context.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
canvas.width * 0.1,
canvas.width / 2,
canvas.height / 2,
canvas.width * 0.75
);
for (const stop of gradientStops) {
radialGradient.addColorStop(stop.offset, stop.color);
}
context.fillStyle = radialGradient;
context.fillRect(0, 0, canvas.width, canvas.height);
} else {
const angleDeg = Number.isFinite(Number(themeBackground?.linearAngle)) ? Number(themeBackground.linearAngle) : 180;
const angleRad = (angleDeg * Math.PI) / 180;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.max(canvas.width, canvas.height);
const x1 = cx - Math.cos(angleRad) * radius;
const y1 = cy - Math.sin(angleRad) * radius;
const x2 = cx + Math.cos(angleRad) * radius;
const y2 = cy + Math.sin(angleRad) * radius;
const linearGradient = context.createLinearGradient(x1, y1, x2, y2);
for (const stop of gradientStops) {
linearGradient.addColorStop(stop.offset, stop.color);
}
context.fillStyle = linearGradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
}
for (const glowLayer of glowLayers) {
const resolvedX = Number(glowLayer?.x);
const resolvedY = Number(glowLayer?.y);
const x = Number.isFinite(resolvedX) ? clamp(resolvedX, 0, 1) : 0.5;
const y = Number.isFinite(resolvedY) ? clamp(resolvedY, 0, 1) : 0.5;
const radius = Math.max(Number(glowLayer?.radius) || 0, 0.08);
const opacity = clamp(Number(glowLayer?.opacity) || 0, 0, 1);
if (opacity <= 0) {
continue;
}
const centerX = canvas.width * x;
const centerY = canvas.height * y;
const outerRadius = canvas.width * radius;
const glow = context.createRadialGradient(centerX, centerY, 0, centerX, centerY, outerRadius);
glow.addColorStop(0, colorToRgba(THREE, glowLayer?.color || "#ffffff", opacity));
glow.addColorStop(0.5, colorToRgba(THREE, glowLayer?.color || "#ffffff", opacity * 0.3));
glow.addColorStop(1, colorToRgba(THREE, glowLayer?.color || "#ffffff", 0));
context.fillStyle = glow;
context.fillRect(0, 0, canvas.width, canvas.height);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
export function disposeTexture(texture) {
texture?.dispose?.();
}
export function applySceneBackground(runtime, viewerTheme, themeBackground = null) {
if (!runtime?.THREE || !runtime?.scene) {
return;
}
if (String(themeBackground?.type || "").toLowerCase() === THEME_BACKGROUND_TYPES.TRANSPARENT) {
disposeTexture(runtime.sceneBackgroundTexture);
runtime.sceneBackgroundTexture = null;
runtime.scene.background = null;
runtime.renderer?.setClearAlpha?.(0);
return;
}
runtime.renderer?.setClearAlpha?.(1);
disposeTexture(runtime.sceneBackgroundTexture);
runtime.sceneBackgroundTexture = createSceneBackgroundTexture(runtime.THREE, viewerTheme, themeBackground);
if (runtime.sceneBackgroundTexture) {
runtime.scene.background = runtime.sceneBackgroundTexture;
return;
}
runtime.scene.background = new runtime.THREE.Color(
themeBackground?.solidColor || viewerTheme.sceneBackground || BASE_VIEWER_THEME.sceneBackground
);
}
export function createSafeColor(THREE, value, fallback = "#000000") {
const normalizedValue = String(value || "").trim();
const normalizedFallback = String(fallback || "#000000").trim();
const colorValue = HEX_COLOR_PATTERN.test(normalizedValue) ? normalizedValue : normalizedFallback;
try {
return new THREE.Color(colorValue);
} catch {
return new THREE.Color(HEX_COLOR_PATTERN.test(normalizedFallback) ? normalizedFallback : "#000000");
}
}
export function resolveBackgroundFloorColor(THREE, themeBackground = {}, viewerTheme = BASE_VIEWER_THEME) {
const fallbackColor = viewerTheme?.stageFloorColor || viewerTheme?.sceneBackground || BASE_VIEWER_THEME.stageFloorColor;
const backgroundType = String(themeBackground?.type || "").trim().toLowerCase();
if (backgroundType === THEME_BACKGROUND_TYPES.LINEAR) {
return createSafeColor(THREE, themeBackground.linearStart, fallbackColor)
.lerp(createSafeColor(THREE, themeBackground.linearEnd, fallbackColor), 0.7);
}
if (backgroundType === THEME_BACKGROUND_TYPES.RADIAL) {
return createSafeColor(THREE, themeBackground.radialInner, fallbackColor)
.lerp(createSafeColor(THREE, themeBackground.radialOuter, fallbackColor), 0.72);
}
if (backgroundType === THEME_BACKGROUND_TYPES.SOLID) {
return createSafeColor(THREE, themeBackground.solidColor, fallbackColor);
}
return createSafeColor(THREE, fallbackColor, BASE_VIEWER_THEME.stageFloorColor);
}
export function resolveStageFloorGlassFactor(themeSettings = {}) {
const materials = themeSettings?.materials || {};
const environment = themeSettings?.environment || {};
const roughness = clamp(Number(materials.roughness) || 0, 0, 1);
const clearcoat = clamp(Number(materials.clearcoat) || 0, 0, 1);
const envSignal = environment?.enabled
? clamp(((Number(materials.envMapIntensity) || 0) * (Number(environment.intensity) || 0)) / 3, 0, 1)
: 0;
return clamp((clearcoat * 0.5) + ((1 - roughness) * 0.25) + (envSignal * 0.35), 0, 1);
}
export function resolveStageFloorColor(THREE, viewerTheme, themeSettings = {}) {
const explicitFloorColor = String(themeSettings?.floor?.color || "").trim();
if (HEX_COLOR_PATTERN.test(explicitFloorColor)) {
return createSafeColor(THREE, explicitFloorColor, viewerTheme?.stageFloorColor || BASE_VIEWER_THEME.stageFloorColor);
}
const backgroundColor = resolveBackgroundFloorColor(THREE, themeSettings?.background, viewerTheme);
const glassFactor = resolveStageFloorGlassFactor(themeSettings);
if (glassFactor >= 0.35) {
const backgroundHsl = {};
backgroundColor.getHSL(backgroundHsl);
const floorColor = backgroundColor.clone();
const lightness = backgroundHsl.l < 0.42
? clamp(backgroundHsl.l + 0.012, 0.06, 0.14)
: clamp(backgroundHsl.l - 0.045, 0.36, 0.78);
floorColor.setHSL(backgroundHsl.h, clamp(backgroundHsl.s * 0.14, 0, 0.04), lightness);
return floorColor;
}
const groundColor = createSafeColor(
THREE,
themeSettings?.lighting?.hemisphere?.groundColor,
viewerTheme?.stageFloorColor || BASE_VIEWER_THEME.stageFloorColor
);
const defaultColor = createSafeColor(THREE, themeSettings?.materials?.defaultColor, "#ffffff");
const floorColor = backgroundColor.clone()
.lerp(groundColor, 0.42 - (glassFactor * 0.24))
.lerp(defaultColor, 0.07 - (glassFactor * 0.04))
.lerp(backgroundColor, glassFactor * 0.5);
const floorHsl = {};
const backgroundHsl = {};
floorColor.getHSL(floorHsl);
backgroundColor.getHSL(backgroundHsl);
const lightness = backgroundHsl.l < 0.42
? clamp(
Math.max(floorHsl.l, backgroundHsl.l + (0.055 - (glassFactor * 0.035))),
0.1 - (glassFactor * 0.04),
0.34 - (glassFactor * 0.13)
)
: clamp(Math.min(floorHsl.l, backgroundHsl.l - 0.075), 0.38, 0.78);
floorColor.setHSL(
floorHsl.h,
clamp(floorHsl.s * (0.58 - (glassFactor * 0.2)), 0.025, 0.38),
lightness
);
return floorColor;
}
export function getStageFloorSetting(themeSettings, key, fallback, min = 0, max = 1) {
const value = Number(themeSettings?.floor?.[key]);
return Number.isFinite(value) ? clamp(value, min, max) : fallback;
}
export function createStageFloorGlowTexture(THREE, color, opacity) {
const resolvedOpacity = clamp(Number(opacity) || 0, 0, 1);
if (resolvedOpacity <= 0.001 || typeof document === "undefined") {
return null;
}
const canvas = document.createElement("canvas");
canvas.width = FLOOR_GLOW_TEXTURE_SIZE;
canvas.height = FLOOR_GLOW_TEXTURE_SIZE;
const context = canvas.getContext("2d");
if (!context) {
return null;
}
const center = FLOOR_GLOW_TEXTURE_SIZE / 2;
const gradient = context.createRadialGradient(center, center, 0, center, center, center);
gradient.addColorStop(0, colorToRgba(THREE, color, resolvedOpacity));
gradient.addColorStop(0.28, colorToRgba(THREE, color, resolvedOpacity * 0.56));
gradient.addColorStop(0.62, colorToRgba(THREE, color, resolvedOpacity * 0.16));
gradient.addColorStop(1, colorToRgba(THREE, color, 0));
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
export function getStageFloorGlowSize(lightingScopeRadius, size, sceneScaleMode = VIEWER_SCENE_SCALE.CAD) {
const sceneScaleSettings = getSceneScaleSettings(sceneScaleMode);
const safeLightingRadius = Math.max(
Number(lightingScopeRadius) || 0,
sceneScaleSettings.minModelRadius
);
return Math.min(
Math.max(Number(size) || 0, 0) * 0.24,
safeLightingRadius * 8
);
}
export function createStageFloorPlane(THREE, viewerTheme, themeSettings, size, floorZ, lift = 0) {
const glassFactor = resolveStageFloorGlassFactor(themeSettings);
const horizonBlend = getStageFloorSetting(themeSettings, "horizonBlend", 0, 0, 1);
const reflectivity = getStageFloorSetting(themeSettings, "reflectivity", 0.12, 0, 1);
const floorColor = resolveStageFloorColor(THREE, viewerTheme, themeSettings);
const roughness = getStageFloorSetting(
themeSettings,
"roughness",
clamp(getViewerThemeNumber(viewerTheme, "stageFloorRoughness", 0.92) - (glassFactor * 0.48), 0.16, 1),
0,
1
);
const opacity = horizonBlend <= 0.001
? 1
: clamp(
(getViewerThemeNumber(viewerTheme, "stageFloorOpacity", 0.78) - (glassFactor * 0.02)) * (1 - (horizonBlend * 0.3)),
0.62,
1
);
const floorHsl = {};
floorColor.getHSL(floorHsl);
const isDarkFloor = floorHsl.l < 0.18;
const specularColor = isDarkFloor
? floorColor.clone().lerp(new THREE.Color("#1c5f8f"), 0.5)
: new THREE.Color("#ffffff");
const specularIntensity = isDarkFloor
? clamp(reflectivity * 0.38, 0, 0.06)
: clamp(reflectivity * 1.4, 0.04, 0.36);
const clearcoat = isDarkFloor
? clamp(reflectivity * 0.22, 0, 0.05)
: clamp((reflectivity * 0.58) + (glassFactor * 0.12), 0, 0.9);
const clearcoatRoughness = isDarkFloor
? clamp(Math.max(roughness * 0.95, 0.35), 0.35, 0.85)
: clamp(
roughness * 0.62,
0.04,
0.8
);
const envMapIntensity = clamp(
(
Number(themeSettings?.materials?.envMapIntensity || 0) *
(themeSettings?.environment?.enabled ? Number(themeSettings?.environment?.intensity || 0) : 0) *
(0.08 + (glassFactor * 0.1))
) + (reflectivity * 0.48),
0,
1.15
);
const material = new THREE.MeshPhysicalMaterial({
color: floorColor,
roughness,
metalness: clamp(getViewerThemeNumber(viewerTheme, "stageFloorMetalness", 0) + (reflectivity * 0.06), 0, 0.18),
clearcoat,
clearcoatRoughness,
reflectivity,
specularColor,
specularIntensity,
transmission: clamp(
getViewerThemeNumber(viewerTheme, "stageFloorTransmission", BASE_VIEWER_THEME.stageFloorTransmission) +
(glassFactor * 0.005),
0,
0.02
),
ior: getViewerThemeNumber(viewerTheme, "stageFloorIor", BASE_VIEWER_THEME.stageFloorIor),
thickness: getViewerThemeNumber(
viewerTheme,
"stageFloorThickness",
BASE_VIEWER_THEME.stageFloorThickness
),
attenuationDistance: getViewerThemeNumber(
viewerTheme,
"stageFloorAttenuationDistance",
BASE_VIEWER_THEME.stageFloorAttenuationDistance
),
transparent: opacity < 0.999,
opacity,
side: THREE.FrontSide,
depthWrite: opacity >= 0.999,
envMapIntensity
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 1, 1), material);
mesh.position.set(0, 0, floorZ + lift);
mesh.scale.set(size, size, 1);
mesh.receiveShadow = false;
mesh.castShadow = false;
mesh.renderOrder = -3;
return mesh;
}
export function createStageFloorGlowPlane(THREE, themeSettings, lightingScopeRadius, size, floorZ, sceneScaleMode, lift = 0.008) {
const spotLight = themeSettings?.lighting?.spot || {};
if (spotLight.enabled === false) {
return null;
}
const reflectivity = getStageFloorSetting(themeSettings, "reflectivity", 0.12, 0, 1);
const shadowOpacity = getStageFloorSetting(themeSettings, "shadowOpacity", 0.45, 0, 1);
const spotIntensity = Math.max(Number(spotLight.intensity) || 0, 0);
const glowOpacity = clamp(0.025 + (spotIntensity * 0.11) + (reflectivity * 0.32) - (shadowOpacity * 0.06), 0, 0.36);
const texture = createStageFloorGlowTexture(THREE, spotLight.color || "#ffffff", glowOpacity);
if (!texture) {
return null;
}
const glowSize = getStageFloorGlowSize(lightingScopeRadius, size, sceneScaleMode);
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 1,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.FrontSide,
toneMapped: true
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 1, 1), material);
mesh.position.set(0, 0, floorZ + lift);
mesh.scale.set(glowSize * 1.45, glowSize, 1);
mesh.receiveShadow = false;
mesh.castShadow = false;
mesh.renderOrder = -2.8;
return mesh;
}
export function createStageShadowPlane(THREE, themeSettings, size, floorZ, lift = 0.01) {
const opacity = getStageFloorSetting(themeSettings, "shadowOpacity", 0.45, 0, 1);
if (opacity <= 0.001) {
return null;
}
const material = new THREE.ShadowMaterial({
color: 0x000000,
opacity,
transparent: true,
depthWrite: false
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 1, 1), material);
mesh.position.set(0, 0, floorZ + lift);
mesh.scale.set(size, size, 1);
mesh.receiveShadow = true;
mesh.castShadow = false;
mesh.renderOrder = -2;
return mesh;
}
export function updateSpotLightTarget(runtime) {
if (!runtime?.spotLight?.target?.position) {
return;
}
const floorZ = Number(runtime.gridFloorZ);
const targetZ = runtime.floorMode !== THEME_FLOOR_MODES.NONE && Number.isFinite(floorZ) ? floorZ : 0;
runtime.spotLight.target.position.set(0, 0, targetZ);
runtime.spotLight.target.updateMatrixWorld?.();
}
export function getStageFloorSize(radius, sceneScaleMode = VIEWER_SCENE_SCALE.CAD) {
const sceneScaleSettings = getSceneScaleSettings(sceneScaleMode);
const safeRadius = clampSceneModelRadius(radius, sceneScaleMode);
return Math.max(sceneScaleSettings.minGridSize * 80, safeRadius * 160);
}
@@ -0,0 +1,225 @@
export const SURFACE_LINE_UNSUPPORTED_TYPES = new Set(["", "SPHERICAL_SURFACE", "TOROIDAL_SURFACE", "BSPLINE_SURFACE"]);
function length(vector) {
return Math.hypot(vector[0], vector[1], vector[2]);
}
function normalizeVector3(vector) {
const magnitude = length(vector);
if (magnitude <= 1e-9) {
return null;
}
return [vector[0] / magnitude, vector[1] / magnitude, vector[2] / magnitude];
}
function subtract(a, b) {
return [Number(a[0]) - Number(b[0]), Number(a[1]) - Number(b[1]), Number(a[2]) - Number(b[2])];
}
function dot(a, b) {
return (Number(a[0]) * Number(b[0])) + (Number(a[1]) * Number(b[1])) + (Number(a[2]) * Number(b[2]));
}
function normalizeAngleAround(angle, center) {
let adjusted = angle;
while (adjusted - center > Math.PI) {
adjusted -= Math.PI * 2;
}
while (adjusted - center < -Math.PI) {
adjusted += Math.PI * 2;
}
return adjusted;
}
function clonePoint3(point) {
return Array.isArray(point) && point.length >= 3
? [Number(point[0]), Number(point[1]), Number(point[2])]
: null;
}
function clonePoint2(point) {
return Array.isArray(point) && point.length >= 2
? [Number(point[0]), Number(point[1])]
: null;
}
export function projectPointToPlane(point, surface) {
if (!Array.isArray(point) || !Array.isArray(surface?.origin) || !Array.isArray(surface?.xDir) || !Array.isArray(surface?.yDir)) {
return null;
}
const relative = subtract(point, surface.origin);
return [dot(relative, surface.xDir), dot(relative, surface.yDir)];
}
export function projectPointToCylinder(point, surface, angleCenter = null) {
if (
!Array.isArray(point) ||
!Array.isArray(surface?.origin) ||
!Array.isArray(surface?.axis) ||
!Array.isArray(surface?.xDir) ||
!Array.isArray(surface?.yDir) ||
Number(surface?.radius) <= 0
) {
return null;
}
const radius = Number(surface.radius);
const relative = subtract(point, surface.origin);
const axial = dot(relative, surface.axis);
const radial = [
relative[0] - (Number(surface.axis[0]) * axial),
relative[1] - (Number(surface.axis[1]) * axial),
relative[2] - (Number(surface.axis[2]) * axial)
];
const rawAngle = Math.atan2(dot(radial, surface.yDir), dot(radial, surface.xDir));
const center = Number(angleCenter);
const angle = Number.isFinite(center) ? normalizeAngleAround(rawAngle, center) : rawAngle;
return [angle * radius, axial];
}
export function projectPointToSurfaceUv(surface, point, angleCenter = null) {
if (!surface || !Array.isArray(point) || point.length < 3) {
return null;
}
if (
surface.type === "PLANE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir)
) {
return projectPointToPlane(point, surface);
}
if (
surface.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.axis) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir) &&
Number(surface.radius) > 0
) {
return projectPointToCylinder(point, surface, angleCenter);
}
return null;
}
export function pointOnSurfaceFromUv(surface, uv) {
if (!surface || !Array.isArray(uv) || uv.length < 2) {
return null;
}
if (
surface.type === "PLANE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir)
) {
return [
surface.origin[0] + surface.xDir[0] * uv[0] + surface.yDir[0] * uv[1],
surface.origin[1] + surface.xDir[1] * uv[0] + surface.yDir[1] * uv[1],
surface.origin[2] + surface.xDir[2] * uv[0] + surface.yDir[2] * uv[1]
];
}
if (
surface.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface.origin) &&
Array.isArray(surface.axis) &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir) &&
Number(surface.radius) > 0
) {
const radius = Number(surface.radius);
const theta = uv[0] / radius;
const cosTheta = Math.cos(theta);
const sinTheta = Math.sin(theta);
return [
surface.origin[0] + surface.axis[0] * uv[1] + radius * (surface.xDir[0] * cosTheta + surface.yDir[0] * sinTheta),
surface.origin[1] + surface.axis[1] * uv[1] + radius * (surface.xDir[1] * cosTheta + surface.yDir[1] * sinTheta),
surface.origin[2] + surface.axis[2] * uv[1] + radius * (surface.xDir[2] * cosTheta + surface.yDir[2] * sinTheta)
];
}
return null;
}
export function surfaceNormalAtUv(surface, uv) {
if (!surface || !Array.isArray(uv) || uv.length < 2) {
return null;
}
if (surface.type === "PLANE") {
return normalizeVector3(surface.normal || []);
}
if (
surface.type === "CYLINDRICAL_SURFACE" &&
Array.isArray(surface.xDir) &&
Array.isArray(surface.yDir) &&
Number(surface.radius) > 0
) {
const theta = uv[0] / Number(surface.radius);
return normalizeVector3([
surface.xDir[0] * Math.cos(theta) + surface.yDir[0] * Math.sin(theta),
surface.xDir[1] * Math.cos(theta) + surface.yDir[1] * Math.sin(theta),
surface.xDir[2] * Math.cos(theta) + surface.yDir[2] * Math.sin(theta)
]);
}
return null;
}
export function buildSurfaceLinePositions(reference, surfaceLine, { segments = 48, offset = 0.04 } = {}) {
const surface = reference?.pickData?.surface || {};
const startPoint = clonePoint3(surfaceLine?.startPoint);
const endPoint = clonePoint3(surfaceLine?.endPoint);
const startUv = clonePoint2(surfaceLine?.startUv);
const endUv = clonePoint2(surfaceLine?.endUv);
if (surface.type === "PLANE" && startPoint && endPoint) {
const normal = normalizeVector3(reference?.pickData?.normal || surface.normal || []);
const project = (point) => normal ? [
point[0] + normal[0] * offset,
point[1] + normal[1] * offset,
point[2] + normal[2] * offset
] : point;
const start = project(startPoint);
const end = project(endPoint);
return [
start[0], start[1], start[2],
end[0], end[1], end[2]
];
}
if (surface.type === "CYLINDRICAL_SURFACE" && startUv && endUv) {
const linePositions = [];
const count = Math.max(4, Math.round(Number(segments) || 48));
let previousPoint = null;
for (let index = 0; index <= count; index += 1) {
const t = index / count;
const uv = [
startUv[0] + (endUv[0] - startUv[0]) * t,
startUv[1] + (endUv[1] - startUv[1]) * t
];
const point = pointOnSurfaceFromUv(surface, uv);
if (!point) {
continue;
}
const normal = surfaceNormalAtUv(surface, uv);
const offsetPoint = normal ? [
point[0] + normal[0] * offset,
point[1] + normal[1] * offset,
point[2] + normal[2] * offset
] : point;
if (previousPoint) {
linePositions.push(
previousPoint[0], previousPoint[1], previousPoint[2],
offsetPoint[0], offsetPoint[1], offsetPoint[2]
);
}
previousPoint = offsetPoint;
}
return linePositions;
}
if (startPoint && endPoint) {
return [
startPoint[0], startPoint[1], startPoint[2],
endPoint[0], endPoint[1], endPoint[2]
];
}
return [];
}
@@ -0,0 +1,209 @@
import { resolveThemeFillColor } from "../themeSettings.js";
import {
CAD_DISPLAY_MODE,
displayModeIsWireframe,
displayModeSurfaceOpacity
} from "../../common/displaySettings.js";
import {
BASE_VIEWER_THEME,
createSafeColor,
getViewerThemeNumber,
HEX_COLOR_PATTERN
} from "./stageTheme.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function isNumericArray(value, stride = 1) {
return (
(Array.isArray(value) || ArrayBuffer.isView(value)) &&
value.length >= stride &&
value.length % stride === 0
);
}
export function shouldUseDisplayVertexColors(meshData) {
return !!meshData?.has_source_colors && isNumericArray(meshData?.colors, 3);
}
export function partUsesDisplayVertexColors(meshData, part) {
if (!shouldUseDisplayVertexColors(meshData)) {
return false;
}
if (part && Object.hasOwn(part, "hasSourceColors")) {
return !!part.hasSourceColors;
}
return true;
}
export function createSurfaceMaterial(THREE, viewerTheme, { color, useVertexColors = false } = {}) {
const opacity = Number.isFinite(Number(viewerTheme?.surfaceOpacity))
? Number(viewerTheme.surfaceOpacity)
: 1;
return new THREE.MeshPhysicalMaterial({
color: color || viewerTheme?.surface || BASE_VIEWER_THEME.surface,
roughness: getViewerThemeNumber(viewerTheme, "surfaceRoughness", BASE_VIEWER_THEME.surfaceRoughness),
metalness: getViewerThemeNumber(viewerTheme, "surfaceMetalness", BASE_VIEWER_THEME.surfaceMetalness),
clearcoat: getViewerThemeNumber(viewerTheme, "surfaceClearcoat", BASE_VIEWER_THEME.surfaceClearcoat),
clearcoatRoughness: getViewerThemeNumber(
viewerTheme,
"surfaceClearcoatRoughness",
BASE_VIEWER_THEME.surfaceClearcoatRoughness
),
side: THREE.DoubleSide,
vertexColors: useVertexColors,
transparent: opacity < 0.999,
opacity,
emissive: 0x000000,
emissiveIntensity: 0,
polygonOffset: false,
polygonOffsetFactor: 0,
polygonOffsetUnits: 0
});
}
export function readSourceColor(THREE, 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 new THREE.Color(expanded);
}
export function shapeSourceColor(THREE, sourceColor, materialSettings = {}, { applyTint = true } = {}) {
const shaped = (sourceColor || new THREE.Color("#ffffff")).clone();
const tintStrength = clamp(Number(materialSettings.tintStrength) || 0, 0, 1);
if (applyTint && tintStrength > 0) {
const tintColor = createSafeColor(THREE, materialSettings.defaultColor || materialSettings.tintColor, "#ffffff");
if (materialSettings.tintMode === "blend") {
shaped.lerp(tintColor, tintStrength);
} else {
shaped.lerp(shaped.clone().multiply(tintColor), tintStrength);
}
}
const saturation = clamp(Number(materialSettings.saturation) || 1, 0, 2.5);
if (Math.abs(saturation - 1) > 1e-4) {
const hsl = {};
shaped.getHSL(hsl);
shaped.setHSL(hsl.h, clamp(hsl.s * saturation, 0, 1), hsl.l);
}
const contrast = clamp(Number(materialSettings.contrast) || 1, 0, 2.5);
const brightness = clamp(Number(materialSettings.brightness) || 1, 0, 2);
shaped.r = clamp(((shaped.r - 0.5) * contrast + 0.5) * brightness, 0, 1);
shaped.g = clamp(((shaped.g - 0.5) * contrast + 0.5) * brightness, 0, 1);
shaped.b = clamp(((shaped.b - 0.5) * contrast + 0.5) * brightness, 0, 1);
return shaped;
}
export function shapeSourceColorBuffer(THREE, colors, materialSettings = {}) {
if (!isNumericArray(colors, 3)) {
return colors;
}
const shapedColors = new Float32Array(colors.length);
const color = new THREE.Color();
for (let index = 0; index + 2 < colors.length; index += 3) {
color.setRGB(
clamp(Number(colors[index]) || 0, 0, 1),
clamp(Number(colors[index + 1]) || 0, 0, 1),
clamp(Number(colors[index + 2]) || 0, 0, 1)
);
const shaped = shapeSourceColor(THREE, color, materialSettings);
shapedColors[index] = shaped.r;
shapedColors[index + 1] = shaped.g;
shapedColors[index + 2] = shaped.b;
}
return shapedColors;
}
export function createMaterialFillColor(THREE, materialSettings = {}, fillIndex = 0) {
return createSafeColor(
THREE,
resolveThemeFillColor(materialSettings, fillIndex),
materialSettings?.defaultColor || BASE_VIEWER_THEME.surface
);
}
export function resolveMaterialFillBaseColor(THREE, materialSettings = {}, fillIndex = 0) {
return shapeSourceColor(
THREE,
createMaterialFillColor(THREE, materialSettings, fillIndex),
materialSettings,
{ applyTint: false }
);
}
export function resolveSourceBaseColor(THREE, {
hasVertexColors = false,
sourceColor = null,
materialSettings,
fallbackColor = "#ffffff",
fillIndex = 0,
forceFill = false
}) {
if (forceFill) {
return resolveMaterialFillBaseColor(THREE, materialSettings, fillIndex);
}
if (hasVertexColors) {
return new THREE.Color("#ffffff");
}
if (!sourceColor) {
return resolveMaterialFillBaseColor(THREE, {
...materialSettings,
defaultColor: fallbackColor || materialSettings?.defaultColor
}, fillIndex);
}
return shapeSourceColor(THREE, sourceColor, materialSettings);
}
export function applyMaterialSettingsToRecord(THREE, record, materialSettings, {
displayMode = CAD_DISPLAY_MODE.SOLID
} = {}) {
if (!record?.material || !materialSettings) {
return;
}
const wireframeMode = displayModeIsWireframe(displayMode);
const forceFill = materialSettings.overrideSourceColors === true || wireframeMode;
const hasVertexColors = !forceFill && !!record.hasVertexColors;
const nextUseVertexColors = hasVertexColors;
record.useVertexColors = nextUseVertexColors;
record.baseColor = resolveSourceBaseColor(THREE, {
hasVertexColors,
sourceColor: forceFill ? null : record.sourceColor || null,
materialSettings,
fallbackColor: materialSettings?.defaultColor || BASE_VIEWER_THEME.surface,
fillIndex: record.fillIndex || 0,
forceFill
});
record.material.vertexColors = nextUseVertexColors;
record.material.roughness = clamp(Number(materialSettings.roughness) || 0, 0, 1);
record.material.metalness = clamp(Number(materialSettings.metalness) || 0, 0, 1);
record.material.clearcoat = clamp(Number(materialSettings.clearcoat) || 0, 0, 1);
record.material.clearcoatRoughness = clamp(Number(materialSettings.clearcoatRoughness) || 0, 0, 1);
record.baseOpacity = clamp(displayModeSurfaceOpacity(displayMode, Number(materialSettings.opacity) || 0), 0, 1);
record.material.opacity = record.baseOpacity;
record.material.transparent = wireframeMode || record.baseOpacity < 0.999;
record.material.depthWrite = displayMode === CAD_DISPLAY_MODE.TRANSPARENT || wireframeMode
? false
: record.baseOpacity >= 0.999;
record.material.envMapIntensity = Math.max(Number(materialSettings.envMapIntensity) || 0, 0);
if (record.material.color && record.baseColor) {
record.material.color.copy(record.baseColor);
}
record.baseEmissiveIntensity = clamp(Number(materialSettings.emissiveIntensity) || 0, 0, 2);
record.baseEmissiveColor = record.baseColor ? record.baseColor.clone() : null;
if ("emissive" in record.material && record.material.emissive) {
if (record.baseEmissiveColor && record.baseEmissiveIntensity > 0) {
record.material.emissive.copy(record.baseEmissiveColor);
} else {
record.material.emissive.set(0x000000);
}
record.material.emissiveIntensity = record.baseEmissiveIntensity;
}
record.material.needsUpdate = true;
}
@@ -0,0 +1,295 @@
import {
applyTopologyDisplayEdgeSurfaceOffset,
disposeTopologyDisplayEdgeObject
} from "../../common/topologyDisplayEdgeRuntime.js";
import {
composeDisplayRecordEffectMatrix
} from "../../common/displayRecordTransform.js";
import {
createTopologyDisplayEdgeObject
} from "../../common/renderEdges.js";
function normalizePartIdList(value) {
return (Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean);
}
function partIdMatchesAny(partId, partIds = []) {
const normalizedPartId = String(partId || "").trim();
if (!normalizedPartId) {
return false;
}
for (const candidate of partIds) {
if (normalizedPartId === candidate || normalizedPartId.startsWith(`${candidate}.`)) {
return true;
}
}
return false;
}
function uniqueDisplayRecordsByPartId(displayRecords = []) {
const records = [];
const seen = new Set();
for (const record of Array.isArray(displayRecords) ? displayRecords : []) {
const partId = String(record?.partId || "").trim();
if (!partId || seen.has(partId)) {
continue;
}
seen.add(partId);
records.push(record);
}
return records;
}
function displayRecordPartIds(displayRecords = []) {
return uniqueDisplayRecordsByPartId(displayRecords).map((record) => String(record.partId || "").trim());
}
function topologyDisplayEdgeLineSettingsKey(
edgeSettings = {},
focusedPartIds = [],
baseTheme = {},
dimmedOpacity = 0.035,
{
transformByRecord = false,
displayRecords = []
} = {}
) {
const numberOrNull = (value) => Number.isFinite(Number(value)) ? Number(value) : null;
return JSON.stringify({
color: String(edgeSettings?.color || ""),
opacity: numberOrNull(edgeSettings?.opacity),
thickness: numberOrNull(edgeSettings?.thickness),
includePartIds: normalizePartIdList(edgeSettings?.includePartIds),
excludePartIds: normalizePartIdList(edgeSettings?.excludePartIds),
visibilityClasses: normalizePartIdList(edgeSettings?.visibilityClasses),
classes: edgeSettings?.classes && typeof edgeSettings.classes === "object"
? Object.fromEntries(Object.entries(edgeSettings.classes).map(([classId, settings]) => [
classId,
{
color: String(settings?.color || ""),
opacity: numberOrNull(settings?.opacity),
thickness: numberOrNull(settings?.thickness)
}
]))
: null,
highlightColor: String(edgeSettings?.highlightColor || ""),
highlightOpacity: numberOrNull(edgeSettings?.highlightOpacity),
highlightRenderOrder: numberOrNull(edgeSettings?.highlightRenderOrder),
focusedPartIds: normalizePartIdList(focusedPartIds),
dimmedOpacity,
baseEdge: String(baseTheme?.edge || ""),
baseEdgeOpacity: numberOrNull(baseTheme?.edgeOpacity),
baseEdgeThickness: numberOrNull(baseTheme?.edgeThickness),
transformByRecord: transformByRecord === true,
recordPartIds: transformByRecord === true ? displayRecordPartIds(displayRecords) : []
});
}
function applyRecordEffectMatrix(runtime, object, record) {
const THREE = runtime?.THREE;
if (!THREE || !object) {
return false;
}
const nextMatrix = composeDisplayRecordEffectMatrix(THREE, record) || new THREE.Matrix4();
object.matrixAutoUpdate = false;
const targetMatrix = object.matrix instanceof THREE.Matrix4 ? object.matrix : new THREE.Matrix4();
const changed = !targetMatrix.equals(nextMatrix);
if (changed) {
targetMatrix.copy(nextMatrix);
object.matrix = targetMatrix;
object.matrixWorldNeedsUpdate = true;
}
const nextVisible = record?.mesh?.visible !== false && record?.effectVisible !== false;
if (object.visible !== nextVisible) {
object.visible = nextVisible;
return true;
}
return changed;
}
function syncRecordEdgeLineTransforms(runtime, group, displayRecords = []) {
if (!group?.children?.length) {
return false;
}
const recordByPartId = new Map();
for (const record of uniqueDisplayRecordsByPartId(displayRecords)) {
recordByPartId.set(String(record.partId || "").trim(), record);
}
let changed = false;
for (const child of group.children) {
const partId = String(child?.userData?.cadTopologyDisplayRecordPartId || "").trim();
const record = recordByPartId.get(partId) || null;
if (!record) {
if (child.visible !== false) {
child.visible = false;
changed = true;
}
continue;
}
changed = applyRecordEffectMatrix(runtime, child, record) || changed;
}
if (changed) {
runtime.edgesGroup?.updateMatrixWorld?.(true);
}
return changed;
}
export function syncRecordTopologyDisplayEdgeTransforms(runtime, displayRecords = []) {
return syncRecordEdgeLineTransforms(runtime, runtime?.topologyDisplayEdgeLine, displayRecords);
}
export function createRecordTopologyDisplayEdgeGroup(runtime, sourceRuntime, {
edgeSettings = {},
focusedPartIds = [],
viewerTheme = {},
dimmedOpacity = 0.035,
displayRecords = []
} = {}) {
if (!runtime?.THREE || !sourceRuntime) {
return null;
}
const records = uniqueDisplayRecordsByPartId(displayRecords);
if (!records.length) {
return null;
}
const group = new runtime.THREE.Group();
const focusedIds = normalizePartIdList(focusedPartIds);
const highlightIds = normalizePartIdList(edgeSettings?.highlightPartIds);
const baseOpacity = Number.isFinite(Number(edgeSettings?.opacity)) ? Number(edgeSettings.opacity) : null;
for (const record of records) {
const partId = String(record?.partId || "").trim();
if (!partId) {
continue;
}
if (highlightIds.length && !partIdMatchesAny(partId, highlightIds)) {
continue;
}
const opacity = focusedIds.length && !partIdMatchesAny(partId, focusedIds)
? dimmedOpacity
: baseOpacity;
const recordEdgeSettings = highlightIds.length
? {
...edgeSettings,
highlightPartIds: [partId]
}
: {
...edgeSettings,
includePartIds: [partId],
...(opacity !== null ? { opacity } : {})
};
const line = createTopologyDisplayEdgeObject(
runtime,
sourceRuntime,
recordEdgeSettings,
viewerTheme
);
if (!line) {
continue;
}
line.userData = {
...(line.userData || {}),
cadTopologyDisplayRecordPartId: partId
};
applyRecordEffectMatrix(runtime, line, record);
group.add(line);
}
if (!group.children.length) {
return null;
}
group.name = "TopologyDisplayEdges";
group.userData.partId = "__topology__";
group.userData.disposeGeometry = false;
group.userData.disposeMaterial = false;
return group;
}
export function syncTopologyDisplayEdgeLine(runtime, topologyRuntime, {
visible = false,
edgeSettings = {},
focusedPartIds = [],
viewerTheme = {},
dimmedOpacity = 0.035,
transformByRecord = false,
displayRecords = [],
syncClip = null
} = {}) {
if (!runtime?.THREE || !runtime?.edgesGroup) {
return false;
}
const sourceRuntime = visible ? topologyRuntime : null;
const settingsKey = sourceRuntime
? topologyDisplayEdgeLineSettingsKey(edgeSettings, focusedPartIds, viewerTheme, dimmedOpacity, {
transformByRecord,
displayRecords
})
: "";
const current = runtime.topologyDisplayEdgeLine || null;
if (
current &&
current.userData?.cadTopologyDisplayEdgeRuntime === sourceRuntime &&
current.userData?.cadTopologyDisplayEdgeSettingsKey === settingsKey
) {
const changed = transformByRecord
? syncRecordEdgeLineTransforms(runtime, current, displayRecords)
: false;
if (changed) {
applyTopologyDisplayEdgeSurfaceOffset(runtime.displayRecords, edgeSettings);
syncClip?.(runtime);
runtime.requestRender?.();
}
return changed;
}
if (current) {
runtime.edgesGroup.remove(current);
disposeTopologyDisplayEdgeObject(current);
runtime.topologyDisplayEdgeLine = null;
}
if (!sourceRuntime) {
syncClip?.(runtime);
runtime.requestRender?.();
return true;
}
const topologyEdgeLine = transformByRecord
? createRecordTopologyDisplayEdgeGroup(runtime, sourceRuntime, {
edgeSettings,
focusedPartIds,
viewerTheme,
dimmedOpacity,
displayRecords
})
: createTopologyDisplayEdgeObject(
runtime,
sourceRuntime,
{
...edgeSettings,
focusedPartIds,
dimmedOpacity
},
viewerTheme
);
if (!topologyEdgeLine) {
syncClip?.(runtime);
runtime.requestRender?.();
return true;
}
topologyEdgeLine.userData = {
...(topologyEdgeLine.userData || {}),
cadTopologyDisplayEdgeRuntime: sourceRuntime,
cadTopologyDisplayEdgeSettingsKey: settingsKey
};
runtime.topologyDisplayEdgeLine = topologyEdgeLine;
runtime.edgesGroup.add(topologyEdgeLine);
runtime.edgesGroup.updateMatrixWorld?.(true);
applyTopologyDisplayEdgeSurfaceOffset(runtime.displayRecords, edgeSettings);
syncClip?.(runtime);
runtime.requestRender?.();
return true;
}
@@ -0,0 +1,458 @@
import { createScreenSpaceLineSegments } from "../../common/renderEdges.js";
export const URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS = 100;
export const URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS = 50;
const URDF_POSE_PICKER_MARKER_COLOR = "#38bdf8";
const URDF_POSE_PICKER_HOVER_FILL_COLOR = "#1e3a8a";
const URDF_POSE_PICKER_HOVER_OUTLINE_COLOR = "#bae6fd";
const URDF_POSE_PICKER_HOVER_OUTLINE_WIDTH = 3.25;
const URDF_POSE_PICKER_GRID_OPACITY = 0.155;
const URDF_POSE_PICKER_SHELL_RADIUS_SCALE = 0.238;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
export function normalizeUrdfPosePickerCenter(picker) {
const center = Array.isArray(picker?.center) ? picker.center : [0, 0, 0];
const point = [Number(center[0]), Number(center[1]), Number(center[2])];
return point.every(Number.isFinite) ? point : [0, 0, 0];
}
export function resolveUrdfPosePickerCenterWorld(runtime, picker) {
if (!runtime?.THREE || !runtime?.modelGroup) {
return null;
}
const { THREE } = runtime;
runtime.modelGroup.updateMatrixWorld(true);
const target = runtime.controls?.target;
if (
target &&
Number.isFinite(target.x) &&
Number.isFinite(target.y) &&
Number.isFinite(target.z)
) {
return target.clone();
}
const centerLocal = normalizeUrdfPosePickerCenter(picker);
const centerWorld = new THREE.Vector3(...centerLocal);
runtime.modelGroup.localToWorld(centerWorld);
return centerWorld;
}
export function resolveUrdfPosePickerShell(runtime, picker) {
if (!runtime?.THREE || !runtime?.camera || !runtime?.modelGroup) {
return null;
}
const centerWorld = resolveUrdfPosePickerCenterWorld(runtime, picker);
if (!centerWorld) {
return null;
}
const centerLocalVector = runtime.modelGroup.worldToLocal(centerWorld.clone());
const centerLocal = [centerLocalVector.x, centerLocalVector.y, centerLocalVector.z];
const cameraDistance = runtime.camera.position.distanceTo(centerWorld);
const modelRadius = Math.max(Number(runtime.modelRadius || 0), 0.08);
const minRadius = Math.max(modelRadius * 0.12, 0.025);
const maxRadius = Math.max(modelRadius * 3.2, minRadius * 3);
const clampedRadius = clamp(cameraDistance * URDF_POSE_PICKER_SHELL_RADIUS_SCALE, minRadius, maxRadius);
const radius = cameraDistance > 1e-4 ? Math.min(clampedRadius, cameraDistance * 0.92) : clampedRadius;
return {
centerLocal,
centerWorld,
radius
};
}
export function intersectUrdfPosePickerShell(runtime, picker, ray = runtime?.raycaster?.ray) {
if (!runtime?.THREE || !runtime?.modelGroup || !ray) {
return null;
}
const shell = resolveUrdfPosePickerShell(runtime, picker);
if (!shell) {
return null;
}
const { THREE } = runtime;
const hitPoint = new THREE.Vector3();
const sphere = new THREE.Sphere(shell.centerWorld, shell.radius);
if (!ray.intersectSphere(sphere, hitPoint)) {
return null;
}
const modelPoint = runtime.modelGroup.worldToLocal(hitPoint.clone());
return {
point: [modelPoint.x, modelPoint.y, modelPoint.z],
source: "sphere"
};
}
export function intersectUrdfPosePickerShellAtNdc(runtime, picker, ndc) {
if (!runtime?.THREE || !runtime?.camera || !ndc) {
return null;
}
const { THREE } = runtime;
const cameraPosition = new THREE.Vector3();
runtime.camera.getWorldPosition(cameraPosition);
const worldPoint = new THREE.Vector3(Number(ndc.x) || 0, Number(ndc.y) || 0, 0.5).unproject(runtime.camera);
const direction = worldPoint.sub(cameraPosition).normalize();
return intersectUrdfPosePickerShell(runtime, picker, new THREE.Ray(cameraPosition, direction));
}
export function urdfPosePickerCellKey(cell) {
return cell ? `${cell.x}:${cell.y}` : "";
}
export function isValidUrdfPosePickerCell(cell) {
return (
cell &&
Number.isInteger(cell.x) &&
Number.isInteger(cell.y) &&
cell.x >= 0 &&
cell.x < URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS &&
cell.y >= 0 &&
cell.y < URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS
);
}
export function urdfPosePickerCellForModelPoint(runtime, picker, point) {
if (!runtime?.THREE || !runtime?.camera || !runtime?.modelGroup || !Array.isArray(point) || point.length < 3) {
return null;
}
const shell = resolveUrdfPosePickerShell(runtime, picker);
if (!shell) {
return null;
}
const { THREE } = runtime;
const worldPoint = new THREE.Vector3(Number(point[0]), Number(point[1]), Number(point[2]));
if (![worldPoint.x, worldPoint.y, worldPoint.z].every(Number.isFinite)) {
return null;
}
runtime.modelGroup.updateMatrixWorld(true);
runtime.modelGroup.localToWorld(worldPoint);
const direction = worldPoint.sub(shell.centerWorld);
if (direction.lengthSq() <= 1e-12) {
return null;
}
direction.normalize();
let longitude = Math.atan2(direction.z, -direction.x);
if (longitude < 0) {
longitude += Math.PI * 2;
}
const latitude = Math.acos(clamp(direction.y, -1, 1));
const u = clamp(longitude / (Math.PI * 2), 0, 0.999999);
const v = clamp(latitude / Math.PI, 0, 0.999999);
return {
x: Math.floor(u * URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS),
y: Math.floor(v * URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS)
};
}
export function resolveUrdfPosePickerHoverCell(runtime, picker) {
const pick = intersectUrdfPosePickerShellAtNdc(runtime, picker, runtime?.urdfPosePickerPointerNdc);
if (pick?.point) {
return urdfPosePickerCellForModelPoint(runtime, picker, pick.point);
}
return null;
}
export function urdfPosePickerSphereDirection(THREE, u, v) {
const longitude = u * Math.PI * 2;
const latitude = v * Math.PI;
const horizontal = Math.sin(latitude);
return new THREE.Vector3(
-Math.cos(longitude) * horizontal,
Math.cos(latitude),
Math.sin(longitude) * horizontal
);
}
export function urdfPosePickerCellCornerDirection(THREE, cell, xOffset, yOffset) {
return urdfPosePickerSphereDirection(
THREE,
(cell.x + xOffset) / URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS,
(cell.y + yOffset) / URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS
);
}
export function createUrdfPosePickerShellGridGeometry(THREE) {
const positions = [];
const pushPoint = (point) => {
positions.push(point.x, point.y, point.z);
};
for (let y = 1; y < URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS; y += 1) {
const v = y / URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS;
for (let x = 0; x < URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS; x += 1) {
pushPoint(urdfPosePickerSphereDirection(THREE, x / URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS, v));
pushPoint(urdfPosePickerSphereDirection(THREE, (x + 1) / URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS, v));
}
}
for (let x = 0; x < URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS; x += 1) {
const u = x / URDF_POSE_PICKER_SHELL_WIDTH_SEGMENTS;
for (let y = 0; y < URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS; y += 1) {
pushPoint(urdfPosePickerSphereDirection(THREE, u, y / URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS));
pushPoint(urdfPosePickerSphereDirection(THREE, u, (y + 1) / URDF_POSE_PICKER_SHELL_HEIGHT_SEGMENTS));
}
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(positions), 3));
return geometry;
}
export function createUrdfPosePickerCellGeometry(runtime, cell) {
if (!runtime?.THREE || !isValidUrdfPosePickerCell(cell)) {
return null;
}
const { THREE } = runtime;
const surfaceOffset = 1.0015;
const corners = [
urdfPosePickerCellCornerDirection(THREE, cell, 0, 0),
urdfPosePickerCellCornerDirection(THREE, cell, 1, 0),
urdfPosePickerCellCornerDirection(THREE, cell, 1, 1),
urdfPosePickerCellCornerDirection(THREE, cell, 0, 1)
].map((direction) => direction.multiplyScalar(surfaceOffset));
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.BufferAttribute(
new Float32Array([
corners[0].x, corners[0].y, corners[0].z,
corners[1].x, corners[1].y, corners[1].z,
corners[2].x, corners[2].y, corners[2].z,
corners[0].x, corners[0].y, corners[0].z,
corners[2].x, corners[2].y, corners[2].z,
corners[3].x, corners[3].y, corners[3].z
]),
3
)
);
geometry.computeVertexNormals();
return geometry;
}
export function createUrdfPosePickerCellOutlinePositions(runtime, cell) {
if (!runtime?.THREE || !isValidUrdfPosePickerCell(cell)) {
return null;
}
const { THREE } = runtime;
const surfaceOffset = 1.0045;
const corners = [
urdfPosePickerCellCornerDirection(THREE, cell, 0, 0),
urdfPosePickerCellCornerDirection(THREE, cell, 1, 0),
urdfPosePickerCellCornerDirection(THREE, cell, 1, 1),
urdfPosePickerCellCornerDirection(THREE, cell, 0, 1)
].map((direction) => direction.multiplyScalar(surfaceOffset));
return new Float32Array([
corners[0].x, corners[0].y, corners[0].z,
corners[1].x, corners[1].y, corners[1].z,
corners[1].x, corners[1].y, corners[1].z,
corners[2].x, corners[2].y, corners[2].z,
corners[2].x, corners[2].y, corners[2].z,
corners[3].x, corners[3].y, corners[3].z,
corners[3].x, corners[3].y, corners[3].z,
corners[0].x, corners[0].y, corners[0].z
]);
}
export function createUrdfPosePickerShell(runtime, picker) {
const shell = resolveUrdfPosePickerShell(runtime, picker);
if (!runtime?.THREE || !shell) {
return null;
}
const { THREE } = runtime;
const geometry = createUrdfPosePickerShellGridGeometry(THREE);
const material = new THREE.LineBasicMaterial({
color: URDF_POSE_PICKER_MARKER_COLOR,
transparent: true,
opacity: URDF_POSE_PICKER_GRID_OPACITY,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const lineSegments = new THREE.LineSegments(geometry, material);
lineSegments.position.set(...shell.centerLocal);
lineSegments.scale.setScalar(shell.radius);
lineSegments.renderOrder = 28;
lineSegments.frustumCulled = false;
lineSegments.onBeforeRender = () => {
const nextShell = resolveUrdfPosePickerShell(runtime, picker);
if (!nextShell) {
return;
}
lineSegments.position.set(...nextShell.centerLocal);
lineSegments.scale.setScalar(nextShell.radius);
};
lineSegments.userData.disposeGeometry = true;
lineSegments.userData.disposeMaterial = true;
return lineSegments;
}
export function createUrdfPosePickerHoverCellMesh(runtime, picker) {
const shell = resolveUrdfPosePickerShell(runtime, picker);
const cell = resolveUrdfPosePickerHoverCell(runtime, picker);
const geometry = createUrdfPosePickerCellGeometry(runtime, cell) || createUrdfPosePickerCellGeometry(runtime, { x: 0, y: 0 });
if (!runtime?.THREE || !shell || !geometry) {
return null;
}
const { THREE } = runtime;
const material = new THREE.MeshBasicMaterial({
color: URDF_POSE_PICKER_HOVER_FILL_COLOR,
transparent: false,
opacity: 1,
side: THREE.DoubleSide,
blending: THREE.NoBlending,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...shell.centerLocal);
mesh.scale.setScalar(shell.radius);
mesh.renderOrder = 80;
mesh.frustumCulled = false;
mesh.visible = isValidUrdfPosePickerCell(cell);
mesh.userData.lastHoverCellKey = urdfPosePickerCellKey(cell);
runtime.urdfPosePickerHoverCellMesh = mesh;
mesh.onBeforeRender = () => {
const nextShell = resolveUrdfPosePickerShell(runtime, picker);
const nextCell = resolveUrdfPosePickerHoverCell(runtime, picker);
const nextCellKey = urdfPosePickerCellKey(nextCell);
mesh.visible = isValidUrdfPosePickerCell(nextCell);
if (!nextShell || !mesh.visible) {
return;
}
mesh.position.set(...nextShell.centerLocal);
mesh.scale.setScalar(nextShell.radius);
if (mesh.userData.lastHoverCellKey !== nextCellKey) {
const nextGeometry = createUrdfPosePickerCellGeometry(runtime, nextCell);
if (!nextGeometry) {
return;
}
mesh.geometry?.dispose?.();
mesh.geometry = nextGeometry;
}
mesh.userData.lastHoverCellKey = nextCellKey;
};
mesh.userData.disposeGeometry = true;
mesh.userData.disposeMaterial = true;
return mesh;
}
export function createUrdfPosePickerHoverCellOutline(runtime, picker) {
const shell = resolveUrdfPosePickerShell(runtime, picker);
const cell = resolveUrdfPosePickerHoverCell(runtime, picker);
const positions =
createUrdfPosePickerCellOutlinePositions(runtime, cell) ||
createUrdfPosePickerCellOutlinePositions(runtime, { x: 0, y: 0 });
if (!runtime?.THREE || !shell || !positions) {
return null;
}
const line = createScreenSpaceLineSegments(runtime, positions, {
color: URDF_POSE_PICKER_HOVER_OUTLINE_COLOR,
opacity: 0.98,
lineWidth: URDF_POSE_PICKER_HOVER_OUTLINE_WIDTH,
renderOrder: 82,
depthTest: false,
depthWrite: false
});
if (!line) {
return null;
}
line.position.set(...shell.centerLocal);
line.scale.setScalar(shell.radius);
line.visible = isValidUrdfPosePickerCell(cell);
line.userData.lastHoverCellKey = urdfPosePickerCellKey(cell);
runtime.urdfPosePickerHoverCellOutline = line;
line.onBeforeRender = () => {
const nextShell = resolveUrdfPosePickerShell(runtime, picker);
const nextCell = resolveUrdfPosePickerHoverCell(runtime, picker);
const nextCellKey = urdfPosePickerCellKey(nextCell);
line.visible = isValidUrdfPosePickerCell(nextCell);
if (!nextShell || !line.visible) {
return;
}
line.position.set(...nextShell.centerLocal);
line.scale.setScalar(nextShell.radius);
if (line.userData.lastHoverCellKey !== nextCellKey) {
const nextPositions = createUrdfPosePickerCellOutlinePositions(runtime, nextCell);
if (!nextPositions) {
return;
}
line.geometry?.setPositions?.(nextPositions);
line.geometry?.computeBoundingSphere?.();
}
line.userData.lastHoverCellKey = nextCellKey;
};
return line;
}
export function syncUrdfPosePickerHoverMesh(runtime, object, picker) {
if (!runtime?.THREE || !object) {
return false;
}
const shell = resolveUrdfPosePickerShell(runtime, picker);
const cell = resolveUrdfPosePickerHoverCell(runtime, picker);
const cellKey = urdfPosePickerCellKey(cell);
object.visible = isValidUrdfPosePickerCell(cell);
if (!shell || !object.visible) {
object.userData.lastHoverCellKey = "";
return false;
}
object.position.set(...shell.centerLocal);
object.scale.setScalar(shell.radius);
if (object.userData.lastHoverCellKey === cellKey) {
return true;
}
const nextGeometry = createUrdfPosePickerCellGeometry(runtime, cell);
if (!nextGeometry) {
object.visible = false;
object.userData.lastHoverCellKey = "";
return false;
}
object.geometry?.dispose?.();
object.geometry = nextGeometry;
object.userData.lastHoverCellKey = cellKey;
return true;
}
export function syncUrdfPosePickerHoverOutline(runtime, object, picker) {
if (!runtime?.THREE || !object) {
return false;
}
const shell = resolveUrdfPosePickerShell(runtime, picker);
const cell = resolveUrdfPosePickerHoverCell(runtime, picker);
const cellKey = urdfPosePickerCellKey(cell);
object.visible = isValidUrdfPosePickerCell(cell);
if (!shell || !object.visible) {
object.userData.lastHoverCellKey = "";
return false;
}
object.position.set(...shell.centerLocal);
object.scale.setScalar(shell.radius);
if (object.userData.lastHoverCellKey === cellKey) {
return true;
}
const nextPositions = createUrdfPosePickerCellOutlinePositions(runtime, cell);
if (!nextPositions) {
object.visible = false;
object.userData.lastHoverCellKey = "";
return false;
}
object.geometry?.setPositions?.(nextPositions);
object.geometry?.computeBoundingSphere?.();
object.userData.lastHoverCellKey = cellKey;
return true;
}
export function syncUrdfPosePickerHoverObjects(runtime, picker) {
const meshVisible = syncUrdfPosePickerHoverMesh(
runtime,
runtime?.urdfPosePickerHoverCellMesh,
picker
);
const outlineVisible = syncUrdfPosePickerHoverOutline(
runtime,
runtime?.urdfPosePickerHoverCellOutline,
picker
);
return meshVisible || outlineVisible;
}
@@ -0,0 +1,33 @@
const WEBGL_CONTEXT_ERROR_PATTERN = /(?:error creating webgl context|webgl context could not be created|failed to create webgl|webgl is not supported)/i;
export function runtimeErrorMessage(error) {
return error instanceof Error ? error.message : String(error || "");
}
export function isWebGlContextCreationError(error) {
return WEBGL_CONTEXT_ERROR_PATTERN.test(runtimeErrorMessage(error));
}
export function buildRuntimeInitializationAlert(error) {
const message = runtimeErrorMessage(error).trim();
if (isWebGlContextCreationError(error)) {
return {
severity: "error",
blocking: true,
summary: "WebGL unavailable",
title: "Browser WebGL is unavailable",
message: "CAD Viewer needs browser WebGL to render 3D models, but this browser could not create a WebGL context.",
resolution: "Enable hardware acceleration or software WebGL in the browser, update the graphics or Mesa drivers, then reload CAD Viewer. In Chrome, check chrome://gpu for the WebGL status."
};
}
return {
severity: "error",
blocking: true,
summary: "CAD Viewer startup failed",
title: "CAD Viewer could not start",
message: message || "The 3D renderer failed before CAD Viewer finished starting.",
resolution: "Reload CAD Viewer. If the problem persists, check the browser console for the renderer startup error."
};
}