first commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# cadjs
|
||||
|
||||
`cadjs` is the shared JavaScript runtime for CAD Viewer, documentation previews,
|
||||
and generated snapshot browser assets. It owns reusable parsing, scene-building,
|
||||
explicit-CAD rendering, STEP topology, selector, mesh, robot-description, and
|
||||
artifact helpers without depending on React or application chrome.
|
||||
|
||||
Implicit CAD model loading, shader rendering, snapshots, SDF sampling, and
|
||||
mesh exports live in the sibling `implicitjs` package. Consumers that need
|
||||
implicit CAD should depend on `implicitjs` directly alongside `cadjs`.
|
||||
|
||||
## Install
|
||||
|
||||
In this workbench, consumers link the package directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"cadjs": "file:../packages/cadjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The package exports source files so local consumers can alias or install it and
|
||||
pick up edits without copying generated bundles.
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/index.js`: public package entrypoint.
|
||||
- `src/common/`: browser-safe render pipeline used by interactive viewer,
|
||||
docs previews, and snapshot browser runtime.
|
||||
- `src/lib/`: lower-level first-party helpers for assets, formats, mesh
|
||||
decoding, selector runtimes, STEP sidecars, and URDF/SRDF/SDF.
|
||||
- `src/lib/viewer/`: shared non-React 3D viewer runtime helpers such as picking,
|
||||
clipping, drawing geometry, reference geometry, stage theme, scene scale, and
|
||||
visual state.
|
||||
- `scripts/run-tests.mjs`: package test discovery and Node test runner wrapper.
|
||||
- `docs/`: reference docs for package-owned APIs.
|
||||
|
||||
Tests live beside the modules they cover as `*.test.js` or `*.test.mjs`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Keep this package UI-framework agnostic. Do not add React components, Tailwind
|
||||
helpers, browser workbench state, or CAD Viewer chrome utilities here. Those
|
||||
belong in consumer apps such as `viewer/`.
|
||||
|
||||
Prefer moving logic into `cadjs` when it is reusable across:
|
||||
|
||||
- interactive CAD Viewer rendering,
|
||||
- docs or marketing previews,
|
||||
- generated snapshot browser runtime,
|
||||
- testable CAD parsing or sidecar preparation.
|
||||
|
||||
Prefer moving implicit model, shader, snapshot, SDF, or mesh-export behavior
|
||||
into `implicitjs` instead.
|
||||
|
||||
Keep app-specific workflows in the app. A useful rule of thumb: `cadjs` should
|
||||
understand CAD data and rendering state, while `viewer/` should understand user
|
||||
interface and product workflow.
|
||||
|
||||
## Commands
|
||||
|
||||
From this package directory:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
From the workbench repository root:
|
||||
|
||||
```bash
|
||||
npm --prefix packages/cadjs test
|
||||
```
|
||||
|
||||
The test command discovers all `*.test.js` and `*.test.mjs` files under
|
||||
`src/`. To run one file:
|
||||
|
||||
```bash
|
||||
node scripts/run-tests.mjs src/lib/themeSettings.test.js
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- [Render pipeline](./docs/render-pipeline.md): `loadSource`, `buildModel`,
|
||||
`renderModel`, and `captureModel` stages shared by viewer, docs, and
|
||||
snapshots.
|
||||
@@ -0,0 +1,362 @@
|
||||
# Render Pipeline
|
||||
|
||||
`cadjs` exposes a staged render pipeline for shared viewer, docs, and generated
|
||||
snapshot browser-runtime work:
|
||||
|
||||
```js
|
||||
const source = await loadSource(input, sourceOptions);
|
||||
const model = buildModel(THREE, source, modelOptions);
|
||||
const viewport = renderModel(THREE, model, viewportOptions);
|
||||
const result = await captureModel(viewport, captureOptions);
|
||||
```
|
||||
|
||||
The stages keep ownership narrow:
|
||||
|
||||
- `loadSource` owns source and sidecar loading plus file-kind validation.
|
||||
- `buildModel` owns the CAD object graph, records, selection, clipping,
|
||||
materials, topology/display edges, and STEP parameter effects.
|
||||
- `renderModel` owns renderer, scene, camera, lighting, background, floor,
|
||||
framing, resizing, and render loop concerns.
|
||||
- `captureModel` owns deterministic snapshot outputs without filesystem writes.
|
||||
|
||||
The CAD skill's Python snapshot CLI remains responsible for job parsing, path
|
||||
resolution, Playwright routing, and writing returned outputs to disk.
|
||||
|
||||
## Modules
|
||||
|
||||
### `common/source.js`
|
||||
|
||||
```js
|
||||
import {
|
||||
loadSource,
|
||||
stepParameterFrameRuntime
|
||||
} from "cadjs/common/source.js";
|
||||
```
|
||||
|
||||
`loadSource(input, options)` returns a normalized render source:
|
||||
|
||||
```js
|
||||
{
|
||||
kind,
|
||||
meshData,
|
||||
selectorRuntime,
|
||||
displayEdgeRuntime,
|
||||
stepParameterSource,
|
||||
resolved,
|
||||
url,
|
||||
glbUrl,
|
||||
cadPath
|
||||
}
|
||||
```
|
||||
|
||||
Accepted input fields:
|
||||
|
||||
- `kind`: `step`, `stp`, `glb`, `stl`, `3mf`, or inferred from a URL.
|
||||
- `meshData`: already-loaded mesh data. If present, no mesh URL fetch is needed.
|
||||
- `url`: source URL for non-STEP GLB loading.
|
||||
- `glbUrl` or `resolved.glbUrl`: STEP/STP hidden GLB sidecar URL.
|
||||
- `cadPath` or `resolved.inputPath`: CAD path used by STEP selectors.
|
||||
- `selectorRuntime` and `displayEdgeRuntime`: preloaded runtimes when a caller
|
||||
already owns sidecar loading.
|
||||
- `stepParameters`: raw STEP render values or animation envelope.
|
||||
- `stepParameterUrl` or `resolved.stepParameterUrl`: `.step.js`/`.stp.js`
|
||||
parameter sidecar URL.
|
||||
|
||||
STEP-only options are rejected for non-STEP sources. The old shared `params`
|
||||
field is rejected; use `stepParameters`.
|
||||
|
||||
Use `stepParameterFrameRuntime(stepParameterSource, frameIndex)` to turn the
|
||||
loaded STEP parameter source into the runtime object accepted by `buildModel`
|
||||
for a still or animation frame.
|
||||
|
||||
### `common/cadScene.js`
|
||||
|
||||
```js
|
||||
import {
|
||||
buildModel,
|
||||
fitCameraToModel
|
||||
} from "cadjs/common/cadScene.js";
|
||||
```
|
||||
|
||||
`buildModel(THREE, source, settings)` returns a model API:
|
||||
|
||||
```js
|
||||
{
|
||||
source,
|
||||
meshData,
|
||||
root,
|
||||
modelGroup,
|
||||
edgesGroup,
|
||||
displayRecords,
|
||||
records,
|
||||
bounds,
|
||||
radius,
|
||||
runtime,
|
||||
update(nextSettings),
|
||||
dispose()
|
||||
}
|
||||
```
|
||||
|
||||
`source` can be a `loadSource()` result or raw mesh data. The model owns the
|
||||
Three.js object graph and its mutable state.
|
||||
|
||||
Common settings:
|
||||
|
||||
- `theme`: normalized or raw theme settings.
|
||||
- `displayMode`: `solid`, `rendered`, `transparent`, `hidden_edges`,
|
||||
`hidden_lines_removed`, `unshaded`, or `wireframe`.
|
||||
- `scale`/`sceneScale`: CAD or robot scene scale.
|
||||
- `selection`: internal selection/filtering state. `focus`, `refs`, and `hide`
|
||||
filter rendered parts before records are built. Viewer-only fields such as
|
||||
`selectedPartIds`, `hiddenPartIds`, and `showEdges` affect visual state.
|
||||
- `clip`: normalized clip-plane settings.
|
||||
- `stepParameters`: STEP parameter runtime object, usually from
|
||||
`stepParameterFrameRuntime()`.
|
||||
- `parameterSetup`: set `false` to skip sidecar setup lifecycle calls.
|
||||
- `renderPartsIndividually`: build per-part records instead of a whole mesh.
|
||||
- `edgeRendering`: declarative edge rendering configuration.
|
||||
|
||||
Declarative screen-space edge rendering:
|
||||
|
||||
```js
|
||||
buildModel(THREE, source, {
|
||||
edgeRendering: {
|
||||
mode: "screen-space",
|
||||
Line2,
|
||||
LineGeometry,
|
||||
LineSegments2,
|
||||
LineSegmentsGeometry,
|
||||
LineMaterial,
|
||||
wireframeEdgeColor: "#111827"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The model keeps screen-space line material bookkeeping internal through
|
||||
`runtime.screenSpaceLineMaterials` and `runtime.syncScreenSpaceLineMaterials()`.
|
||||
Callers should not provide callbacks that create edge objects.
|
||||
|
||||
`model.update(nextSettings)` merges mutable settings, rebuilds geometry only
|
||||
when needed, reapplies material/selection/clip/STEP parameter state, and returns
|
||||
the same model API. `model.dispose()` releases model-owned scene objects and
|
||||
STEP parameter cleanup hooks.
|
||||
|
||||
`fitCameraToModel(THREE, camera, bounds, options)` is the shared orthographic
|
||||
camera framing helper used by interactive rendering.
|
||||
|
||||
### `common/renderModel.js`
|
||||
|
||||
Use this module for interactive browser canvases, including the docs hero.
|
||||
|
||||
```js
|
||||
import { renderModel } from "cadjs/common/renderModel.js";
|
||||
```
|
||||
|
||||
`renderModel(THREE, model, options)` returns an interactive viewport API:
|
||||
|
||||
```js
|
||||
{
|
||||
THREE,
|
||||
model,
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
ready,
|
||||
resize(),
|
||||
render(),
|
||||
start(),
|
||||
stop(),
|
||||
capturePng(),
|
||||
dispose()
|
||||
}
|
||||
```
|
||||
|
||||
Common options:
|
||||
|
||||
- `canvas`: existing canvas for the renderer.
|
||||
- `hostElement`/`container`: element used for responsive sizing.
|
||||
- `renderer`: caller-owned renderer. If omitted, one is created.
|
||||
- `scene` and `camera`: caller-owned scene/camera. If omitted, defaults are
|
||||
created.
|
||||
- `theme`/`themeSettings`: background and lighting settings.
|
||||
- `alpha`, `antialias`, `powerPreference`, `preserveDrawingBuffer`,
|
||||
`logarithmicDepthBuffer`, `shadows`: renderer controls.
|
||||
- `direction`, `up`, `padding`, `scale`/`sceneScale`: framing controls.
|
||||
- `pixelRatio`, `maxPixelRatio`: output density controls.
|
||||
- `autoResize`: set `false` to disable `ResizeObserver`.
|
||||
- `autoStart`: set `true` to start an animation loop.
|
||||
- `autoRender`: set `false` to prevent the initial render.
|
||||
- `beforeRender({ deltaSeconds, viewport })`: per-frame hook for animation.
|
||||
- `disposeModel`: set `false` when the caller will dispose the model.
|
||||
|
||||
`dispose()` stops animation, disconnects resize observation, removes the model
|
||||
root from the scene, and disposes the created renderer/model unless ownership
|
||||
was explicitly retained by options.
|
||||
|
||||
### `common/renderMeshScene.js`
|
||||
|
||||
Use this module for deterministic headless snapshot rendering.
|
||||
|
||||
```js
|
||||
import {
|
||||
renderJobContext,
|
||||
modelOptionsForRenderJob,
|
||||
renderModel,
|
||||
captureModel,
|
||||
renderMeshJob
|
||||
} from "cadjs/common/renderMeshScene.js";
|
||||
```
|
||||
|
||||
`renderJobContext(meshData, job)` normalizes snapshot-owned render policy:
|
||||
appearance, display, scene scale, outputs, STEP topology edge visibility, and
|
||||
warnings.
|
||||
|
||||
`modelOptionsForRenderJob(context, job)` converts that policy into
|
||||
`buildModel()` settings.
|
||||
|
||||
Snapshot `renderModel(THREE, model, { job, context })` returns a headless
|
||||
viewport:
|
||||
|
||||
```js
|
||||
{
|
||||
THREE,
|
||||
model,
|
||||
scene,
|
||||
renderer,
|
||||
orthographicCamera,
|
||||
perspectiveCamera,
|
||||
context,
|
||||
sceneBuildStarted,
|
||||
ready,
|
||||
dispose()
|
||||
}
|
||||
```
|
||||
|
||||
This `renderModel` is intentionally separate from `common/renderModel.js`.
|
||||
It uses snapshot sizing, theme environment, floor, orthographic/perspective
|
||||
camera presets, and deterministic renderer settings.
|
||||
|
||||
`captureModel(viewport, { job })` returns data only:
|
||||
|
||||
- `mode: "view"`: PNG data URLs in `outputs`.
|
||||
- `mode: "orbit"`: the caller composes frames into GIF output.
|
||||
- `mode: "section"`: PNG data URLs or SVG text in `outputs`.
|
||||
- `mode: "list"`: part list and bounds.
|
||||
|
||||
It does not write files. The CAD skill snapshot CLI writes the returned data to
|
||||
disk. Source checkouts use `packages/cadjs`; generated snapshot browser assets
|
||||
bundle this entrypoint into `skills/cad/scripts/snapshot/runtime`.
|
||||
|
||||
`renderMeshJob(meshData, job)` is a compatibility wrapper that builds a context,
|
||||
builds a model, renders/captures it, and disposes owned resources.
|
||||
|
||||
## STEP Parameters
|
||||
|
||||
Shared render APIs use the name `stepParameters`.
|
||||
|
||||
Raw render values can be direct parameter values:
|
||||
|
||||
```json
|
||||
{
|
||||
"drive": 180,
|
||||
"ringVisible": false
|
||||
}
|
||||
```
|
||||
|
||||
Or an animation envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"values": {
|
||||
"ringVisible": true
|
||||
},
|
||||
"animate": {
|
||||
"drive": { "from": 0, "to": 1260 }
|
||||
},
|
||||
"durationSeconds": 6,
|
||||
"fps": 18,
|
||||
"loop": true
|
||||
}
|
||||
```
|
||||
|
||||
`common/stepParameters.js` validates these values against the loaded STEP
|
||||
parameter sidecar schema, normalizes defaults, and computes per-frame values.
|
||||
`loadSource()` uses it to populate `source.stepParameterSource`; callers can
|
||||
then use `stepParameterFrameRuntime()` when passing parameters into
|
||||
`buildModel()`.
|
||||
|
||||
## Examples
|
||||
|
||||
Interactive viewer/docs usage:
|
||||
|
||||
```js
|
||||
import * as THREE from "three";
|
||||
import { loadSource, stepParameterFrameRuntime } from "cadjs/common/source.js";
|
||||
import { buildModel } from "cadjs/common/cadScene.js";
|
||||
import { renderModel } from "cadjs/common/renderModel.js";
|
||||
|
||||
const source = await loadSource({
|
||||
kind: "step",
|
||||
glbUrl: "/models/.part.step.glb",
|
||||
stepParameterUrl: "/models/.part.step.js",
|
||||
cadPath: "models/part.step",
|
||||
stepParameters: { drive: 180 }
|
||||
});
|
||||
|
||||
const model = buildModel(THREE, source, {
|
||||
theme,
|
||||
displayMode: "solid",
|
||||
stepParameters: stepParameterFrameRuntime(source.stepParameterSource, 0)
|
||||
});
|
||||
|
||||
const viewport = renderModel(THREE, model, {
|
||||
canvas,
|
||||
hostElement: canvas.parentElement,
|
||||
theme,
|
||||
autoStart: true
|
||||
});
|
||||
```
|
||||
|
||||
Headless snapshot usage:
|
||||
|
||||
```js
|
||||
import * as THREE from "three";
|
||||
import { loadSource } from "cadjs/common/source.js";
|
||||
import { buildModel } from "cadjs/common/cadScene.js";
|
||||
import {
|
||||
captureModel,
|
||||
modelOptionsForRenderJob,
|
||||
renderJobContext,
|
||||
renderModel
|
||||
} from "cadjs/common/renderMeshScene.js";
|
||||
|
||||
const source = await loadSource(job);
|
||||
const context = renderJobContext(source.meshData, job);
|
||||
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
|
||||
const viewport = renderModel(THREE, model, { job, context });
|
||||
|
||||
try {
|
||||
const result = await captureModel(viewport, { job });
|
||||
// Write result.outputs in the CAD skill snapshot CLI or another caller-owned layer.
|
||||
} finally {
|
||||
viewport.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
Orbit GIF jobs default to 6 fps over 12 seconds for a calmer review spin
|
||||
without increasing default render frame count.
|
||||
Override with `orbit.fps` and `orbit.durationSeconds` when a shorter or denser
|
||||
render is needed.
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
- Do not write files from shared render APIs. Return data to the owning CLI or
|
||||
application layer.
|
||||
- Do not expose object-construction callbacks for edges. Use declarative
|
||||
`edgeRendering`.
|
||||
- Keep STEP-only options explicitly STEP-named and reject them for non-STEP
|
||||
sources.
|
||||
- Dispose viewports and models that you create.
|
||||
- Prefer `loadSource -> buildModel -> renderModel -> captureModel` for new
|
||||
shared render code instead of loading assets or constructing render scenes
|
||||
inline.
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user