first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Plugins
This directory contains plugin packages for production agent installs. Local
development should link the source skills directly, as described in
`CONTRIBUTING.md`.
## CAD
`plugins/cad` is the first plugin package. Its canonical release version lives
in `plugins/cad/VERSION`, and it bundles every supported CAD Skills skill as a
generated production copy of the canonical `skills/` directories.
The plugin is intentionally thin:
- `.codex-plugin/marketplace.json` describes the Codex marketplace package
list for repo-root installs.
- `.claude-plugin/marketplace.json` describes the Claude Code marketplace
package list for repo-root installs.
- `plugins/cad/.codex-plugin/plugin.json` describes the Codex plugin.
- `plugins/cad/.claude-plugin/plugin.json` describes the Claude Code plugin.
- `plugins/cad/VERSION` records the package version.
- `plugins/cad/skills/` is a generated production copy of the root `skills/`
sources. Refresh it with `scripts/bundle/bundle-plugin.sh`.
## Provider Support
The `cad` plugin currently has native provider manifests for:
- Codex: `plugins/cad/.codex-plugin/plugin.json`
- Claude Code: `plugins/cad/.claude-plugin/plugin.json`
@@ -0,0 +1,23 @@
{
"name": "cad",
"version": "0.3.9",
"description": "CAD, robotics, fabrication, and local review skills.",
"author": {
"name": "earthtojake",
"url": "https://github.com/earthtojake"
},
"homepage": "https://www.cadskills.xyz",
"repository": "https://github.com/earthtojake/text-to-cad",
"license": "MIT",
"keywords": [
"cad",
"robotics",
"fabrication",
"skills",
"step",
"urdf",
"sdf",
"gcode"
],
"skills": "./skills/"
}
@@ -0,0 +1,41 @@
{
"name": "cad",
"version": "0.3.9",
"description": "CAD, robotics, fabrication, and local review skills packaged for agent plugin installs.",
"author": {
"name": "earthtojake",
"url": "https://github.com/earthtojake"
},
"homepage": "https://www.cadskills.xyz",
"repository": "https://github.com/earthtojake/text-to-cad",
"license": "MIT",
"keywords": [
"cad",
"robotics",
"fabrication",
"skills",
"step",
"urdf",
"sdf",
"gcode"
],
"skills": "./skills/",
"interface": {
"displayName": "CAD",
"shortDescription": "CAD, robotics, fabrication, and local review skills.",
"longDescription": "CAD Skills packages local agent workflows for generating CAD, inspecting models, sourcing STEP parts, writing robot description files, slicing mesh files, and preparing fabrication handoffs.",
"developerName": "earthtojake",
"category": "Productivity",
"capabilities": [
"Write",
"Interactive"
],
"websiteURL": "https://www.cadskills.xyz",
"defaultPrompt": [
"Create a CAD model and open it in CAD Viewer.",
"Generate a URDF and review it locally.",
"Slice a mesh into printer-ready G-code."
],
"brandColor": "#00A676"
}
}
+18
View File
@@ -0,0 +1,18 @@
# CAD Plugin
This plugin bundles the supported CAD Skills skill set for production agent
installs while keeping the root `skills/` directory as the source of truth.
Local development should link source skills directly from this checkout.
The bundled `skills/` directory is generated by `scripts/bundle/bundle-plugin.sh`
and should not be edited directly.
The canonical release version is recorded in `plugins/cad/VERSION`.
Bundled skills are generated from the root `skills/` directory.
Codex consumes `.codex-plugin/plugin.json`. Claude Code consumes
`.claude-plugin/plugin.json`.
Provider behavior:
- Codex: native plugin package through `.codex-plugin/plugin.json`.
- Claude: native plugin package through `.claude-plugin/plugin.json`.
+1
View File
@@ -0,0 +1 @@
0.3.9
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,209 @@
---
name: bambu-labs
description: Dry-run, upload, and cautiously initiate local Bambu Lab print jobs from validated plain `.gcode`, using Bambu LAN FTPS/MQTT handoffs.
---
# Bambu Labs
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill for local-network Bambu Lab print handoffs after a plain `.gcode`
file already exists and has been validated. This skill does not slice models.
## Safety Rules
- Default to dry-run plans. Real printer traffic requires `--execute`.
- Never start a print without `--execute --confirm-start-print`.
- Pause and cancel controls are live printer requests; default to dry-run plans.
Canceling a print requires `--execute --confirm-cancel-print`.
- Treat an explicit user request to print or start a specific job as live-start
authorization; do not pause for a second confirmation solely for physical
checks. Still validate the G-code, inspect the dry-run payload, read printer
status, prefer upload-only before upload-start, state the physical checks, and
stop if validation/status/intent is unsafe or ambiguous.
- Do not ask for the printer serial by default; fetch it from the printer TLS certificate with `serial` or let `send` cache it.
- Prefer workspace-root `bambu-printers.json` over repeating access codes in commands. The file is local config and should be ignored by Git.
- Before a live start, state the physical checks: clear build plate, correct plate/filament/nozzle, safe surroundings, and operator nearby.
- Publishing MQTT is only a start request. Confirm acceptance with printer status/UI and physical observation.
## CAD Viewer Handoff
After completing Bambu work that creates or modifies a local supported print artifact such as `.gcode` or `.3mf`, you must ALWAYS hand the explicit file path to `$cad-viewer` when that skill is installed. `$cad-viewer` must start CAD Viewer if it is not already running and return link(s) to the relevant created or updated file(s); if `$cad-viewer` is unavailable or startup fails, report that instead of silently omitting the handoff.
## Workflow
1. Generate and validate plain G-code with `$gcode`.
If no slicer is installed, install OrcaSlicer and retry; do not treat the missing slicer as a blocker. On macOS, prefer `brew install --cask orcaslicer`.
2. Configure the printer. The user can either give the IP/access code in the thread and let the agent write JSON, or edit `bambu-printers.json` directly.
For a new printer setup or onboarding request, read
`references/new-printer-onboarding.md` first. Walk the user through the
model-specific touchscreen steps to find the IP and LAN access code, and make
**Enable LAN Only** plus **Enable Developer Mode** explicit before running
local start workflows.
```bash
python scripts/bambu_lan_print.py config set \
--printer a1-mini \
--host 192.168.1.34 \
--access-code 12345678 \
--model a1-mini \
--fetch-serial
```
Manual JSON shape:
```json
{
"printers": {
"a1-mini": {
"host": "192.168.1.34",
"access_code": "12345678",
"model": "a1-mini"
}
}
}
```
On A1/A1 Mini, find the IP and LAN access code on the printer touchscreen under
network/LAN settings. Enable LAN Only and Developer Mode when offered, then
power-cycle before retrying local start commands.
3. Read status before live work:
```bash
python scripts/bambu_lan_print.py status \
--printer a1-mini \
--push-all \
--wait-seconds 10
```
4. Dry-run the exact handoff, inspect the JSON payload, then run upload-only.
Only after upload succeeds should you run upload-start. If the user explicitly
asked to print or start the job, proceed to `upload-start --execute
--confirm-start-print` after the validation, status, and upload checks pass. If
the user only asked to prepare, slice, upload, or review, stop before the start
request.
## Handoff Modes
`--handoff template-project` is the validated A1 Mini path from this repo's LAN
debugging. It starts from validated plain `.gcode`, copies a known-good
same-printer `.gcode.3mf` template, replaces `Metadata/plate_N.gcode`, writes
the plate MD5, uploads the project to the FTPS root, and publishes
`print.project_file` with `url: ftp:///<name>.gcode.3mf`.
```bash
python scripts/bambu_lan_print.py send \
--printer a1-mini \
--gcode /tmp/job.gcode \
--handoff template-project \
--template-project /path/to/same-printer-template.gcode.3mf \
--action upload-start
```
Execute after review when the user explicitly asked to print or start, or after
physical confirmation when intent is unclear:
```bash
python scripts/bambu_lan_print.py send \
--printer a1-mini \
--gcode /tmp/job.gcode \
--handoff template-project \
--template-project /path/to/same-printer-template.gcode.3mf \
--action upload-start \
--execute \
--confirm-start-print
```
`--handoff plain` uploads `cache/<name>.gcode` and publishes
`print.gcode_file`. Keep it for diagnostics or printers/firmware where this is
known to work. On the tested A1 Mini, direct plain G-code was uploaded
successfully but `gcode_file` failed or was ignored, so do not use it as the
A1 Mini live-start path.
`--handoff bambox-project` packages plain `.gcode` with `bambox`, uploads the
`.gcode.3mf` project to FTPS root, and publishes `print.project_file`.
Currently enabled only for `p1s-0.4` with `PLA`, `ASA`, or `PETG-CF`.
Known but disabled until validated profiles exist: `a1-mini-0.4`, `a1-0.4`,
`x1c-0.4`, and `p1p-0.4`.
## Common Debugging Commands
Fetch/cache serial:
```bash
python scripts/bambu_lan_print.py serial \
--printer a1-mini \
--json
```
Clear a stale printer error after fixing the underlying cause:
```bash
python scripts/bambu_lan_print.py clear-error \
--printer a1-mini \
--execute
```
Use `--mqtt-qos 1 --wait-after-publish 10` on `send` when debugging whether the
printer acknowledged the MQTT publish and what status it reported immediately
afterward.
## Print Controls
For a running print, use dedicated print-control commands rather than ad hoc
MQTT snippets. These commands publish only a control request; they do not upload
files or start a new job. Read status after execution to confirm the printer
state changed.
Dry-run pause payload:
```bash
python scripts/bambu_lan_print.py pause \
--printer a1-mini
```
Execute pause and collect printer reports:
```bash
python scripts/bambu_lan_print.py pause \
--printer a1-mini \
--execute \
--mqtt-qos 1 \
--wait-after-publish 10
```
Dry-run cancel payload. The Bambu LAN command sent to the printer is `stop`:
```bash
python scripts/bambu_lan_print.py cancel \
--printer a1-mini
```
Execute cancel only when the user explicitly asks to cancel/stop the print or
after confirmation when intent is ambiguous:
```bash
python scripts/bambu_lan_print.py cancel \
--printer a1-mini \
--execute \
--confirm-cancel-print \
--mqtt-qos 1 \
--wait-after-publish 10
```
## Failure Modes
- `gcode_file` returns `result: fail` or leaves the printer `IDLE`: plain G-code upload worked, but the firmware rejected or ignored direct local start. For A1 Mini, switch to `template-project`.
- Project uploaded under `cache/` starts then fails with `print_error: 83935248` or `0500-C010`: clear the error, upload project handoffs to FTPS root, and use `ftp:///<name>.gcode.3mf`.
- `file:///sdcard/cache/...` or local HTTP URLs appear accepted but nothing starts: stop using those URL forms for this workflow.
- Bambu Studio or OrcaSlicer project export crashes on macOS: do not keep retrying GUI-backed project export. Use OrcaSlicer for plain `.gcode`, then this skill for handoff.
- Stale `gcode_state: FAILED` or HMS after enabling Developer Mode: clear the printer error and power-cycle before retrying.
- FTPS login works but upload fails with `553` or missing `cache/`: check printer storage/SD card status before MQTT start.
- MQTT status works but start does not: confirm serial, access code, Developer Mode/LAN Only status, and the exact handoff payload before retrying.
Read `references/new-printer-onboarding.md` for new printer setup,
`references/local-lan-protocol.md` for protocol details, and
`references/real-printer-checklist.md` before first live use on a new printer.
@@ -0,0 +1,4 @@
interface:
display_name: "Bambu Labs"
short_description: "Dry-run and start local Bambu G-code handoffs"
default_prompt: "Use $bambu-labs to dry-run a validated plain G-code handoff before starting it."
@@ -0,0 +1,123 @@
# Bambu Lab Local LAN Protocol Notes
Use this reference only when planning or debugging against a real local printer.
Bambu does not provide this skill with a stable public local-print API
contract, so treat these as observed FTPS/MQTT behaviors.
## Required Inputs
- Validated plain `.gcode`.
- Printer LAN IP/hostname.
- Printer access code.
- Printer serial for MQTT topic `device/{serial}/request`; fetch it from the
printer TLS certificate with `serial` or let `send` fetch/cache it.
- Handoff mode: `template-project`, `plain`, or `bambox-project`.
Workspace-root `bambu-printers.json` stores printer IDs, hostnames, access codes, models,
and cached serials. It is local config and should be ignored by Git.
## Transport
- FTPS upload: implicit TLS on port `990`.
- MQTT control/status: TLS on port `8883`.
- Username: `bblp`.
- Password: printer access code.
- TLS verification is off by default because local printers commonly use
device/self-signed certificates.
- The helper rejects public IPs/hostnames unless `--allow-nonprivate-host` is
set.
- FTPS data connections may require TLS session reuse. The helper reuses the
control TLS session for uploads and listings.
## MQTT Topics
- Request topic: `device/{serial}/request`.
- Report topic: `device/{serial}/report`.
- `status --push-all` subscribes to reports, then publishes `pushing.pushall`
to request a full state report.
- Publishing a start payload is a request, not proof of accepted motion.
## Handoff Payloads
### Template Project
Validated on an A1 Mini during local LAN debugging:
1. Start from a validated plain `.gcode`.
2. Copy a known-good same-printer `.gcode.3mf` template.
3. Replace `Metadata/plate_N.gcode` and update `Metadata/plate_N.gcode.md5`.
4. Upload the resulting `.gcode.3mf` to FTPS root, not `cache/`.
5. Publish `print.project_file` with root FTP URL.
Representative payload:
```json
{
"print": {
"command": "project_file",
"param": "Metadata/plate_1.gcode",
"project_id": "0",
"profile_id": "0",
"task_id": "0",
"subtask_id": "0",
"subtask_name": "job",
"url": "ftp:///job.gcode.3mf",
"md5": "PROJECT_MD5_UPPERCASE",
"timelapse": false,
"bed_type": "auto",
"bed_levelling": true,
"flow_cali": true,
"vibration_cali": false,
"layer_inspect": true,
"use_ams": false,
"ams_mapping": ""
}
}
```
### Plain G-code
The plain path uploads `cache/<job>.gcode` and publishes:
```json
{
"print": {
"command": "gcode_file",
"param": "cache/job.gcode"
}
}
```
On the tested A1 Mini, byte-for-byte verified uploads still produced
`gcode_file` failure/idle behavior. Use this path only for diagnostics or
printer firmware where it has been validated.
### Bambox Project
The optional `bambox-project` path packages plain G-code into `.gcode.3mf` for
enabled profiles, validates the archive, uploads it to FTPS root, and publishes
the same `project_file` shape as template projects. Current enabled profile is
`p1s-0.4`; A1/A1 Mini are disabled until a validated bambox profile exists.
## Observed Failure Modes
- **Direct G-code rejected:** MQTT report after `gcode_file` may include
`{"command":"gcode_file","result":"fail","reason":"error string"}`. Stop and
use a project handoff.
- **Direct G-code ignored:** uploaded file exists, HMS is empty, target
temperatures stay at zero, and `gcode_state` remains `IDLE`. Do not keep
cycling path variants.
- **Project in `cache/` fails:** `project_file` can be accepted and then fail
with `print_error: 83935248` / `0500-C010`. Clear the error and upload project
files to FTPS root with `ftp:///<name>.gcode.3mf`.
- **HTTP/file URLs inert:** `file:///sdcard/cache/...` and local HTTP URLs may
be accepted without fetching or starting. Do not use them for this workflow.
- **Bambu/Orca project export crash:** GUI-backed CLI project export on macOS
can crash inside AppKit/BambuStudio. Use slicer CLI only for plain `.gcode`.
- **Stale printer state:** after LAN Only/Developer Mode changes, clear errors
and power-cycle. Status can retain stale `FAILED`/HMS values.
- **Storage unavailable:** if FTPS auth works but upload fails with `553`, or
status reports no SD/storage, resolve storage before MQTT start.
Use `clear-error --execute` only after the underlying cause is fixed. It
publishes `print.clean_print_error` and does not start motion.
@@ -0,0 +1,139 @@
# New Printer Onboarding
Use this reference whenever the user asks to set up, add, pair, configure, or
test a new Bambu Lab printer for local LAN handoff. The goal is a calm,
step-by-step setup where the user always knows what is safe, what is private,
and what the agent will do next.
## Agent Flow
1. Start by confirming this is setup only. Say that no upload or print will
happen during onboarding unless the user explicitly asks later.
2. Ask for the printer model or family if it is not already known. Use a stable
local printer id such as `a1-mini`, `a1`, `p1s`, `p1p`, `x1c`, or `h2d`.
3. Tell the user you need two values from the printer touchscreen:
printer IP address and LAN access code. Explain that the serial is fetched
from the printer certificate and should not be requested by default.
4. Immediately give the model-specific touchscreen steps below. Do not just ask
for the IP and access code without helping the user find them.
5. Make the LAN prerequisites very explicit: the user must **Enable LAN Only**
and **Enable Developer Mode** before live local start commands. If either
option exists and is off, stop setup and ask them to enable it first.
6. After the user provides the IP and access code, store them with `config set`
in workspace-root `bambu-printers.json`, using `--fetch-serial`. Do not repeat the
access code in the final response.
7. Run `status --push-all --wait-seconds 10` and summarize the result in plain
language: reachable or not, idle/running/error, SD/storage present, and any
`print_error` or HMS entries.
8. If setup succeeds, offer the next safe step as a dry-run handoff plan. Do not
upload or start anything during onboarding unless the user explicitly asks.
## What To Say First
Use a short, reassuring prompt like this:
```text
I can set this up safely. I need the printer's LAN IP address and LAN access
code from the touchscreen. Before you send them, please enable LAN Only and
Developer Mode; local starts often fail or become read-only if Developer Mode is
off. I will store the code only in the workspace-local ignored config and fetch the
serial from the printer automatically.
```
Then give the matching steps for their printer family.
## Touchscreen Steps By Printer Family
### A1 / A1 Mini
1. On the printer touchscreen, open **Settings**.
2. Open **Network** or **WLAN**.
3. Connect the printer to the same trusted local network as this computer.
4. **Enable LAN Only**.
5. **Enable Developer Mode** in the same network/LAN area when shown.
6. Record the IPv4 address shown on the network screen.
7. Record the LAN access code shown near the LAN Only or Developer Mode
controls. If it is all zeros, refresh/regenerate it or toggle LAN Only off
and on, then record the new code.
8. Power-cycle the printer after changing LAN Only or Developer Mode.
### P1P / P1S
1. On the printer screen, open **Settings**.
2. Open **Network** or **WLAN**.
3. Confirm the printer is on the same trusted local network as this computer.
4. **Enable LAN Only**.
5. **Enable Developer Mode**. If it is not visible, update firmware or stop
before live local start workflows.
6. Record the printer IPv4 address.
7. Record the LAN access code. If the code is all zeros or blank, refresh it or
toggle LAN Only off and on, then record the new code.
8. Power-cycle the printer before the first local status check.
### X1 / X1C / X1E
1. On the printer touchscreen, open **Settings**.
2. Open **Network** or **WLAN**.
3. Confirm the printer is connected to the same trusted local network.
4. **Enable LAN Only**.
5. **Enable Developer Mode**. If it is not visible, update firmware or stop
before live local start workflows.
6. Record the IPv4 address.
7. Record the LAN access code from the LAN/network screen.
8. Power-cycle the printer after changing LAN settings.
### H2D / Newer Bambu Printers
1. Open **Settings** on the printer touchscreen.
2. Open **Network**, **WLAN**, or the printer's LAN settings panel.
3. Confirm local network connectivity.
4. **Enable LAN Only**.
5. **Enable Developer Mode**.
6. Record the IPv4 address and LAN access code.
7. Power-cycle after changing LAN settings.
For an unknown Bambu model, guide the user to the printer's network/WLAN
settings, then require the same four items before continuing: same local
network, **Enable LAN Only**, **Enable Developer Mode**, and collect IP plus
LAN access code.
## Agent Commands
Use the active Python environment:
```bash
python scripts/bambu_lan_print.py config set \
--printer a1-mini \
--host 192.168.1.34 \
--access-code 12345678 \
--model a1-mini \
--fetch-serial
```
Then check status:
```bash
python scripts/bambu_lan_print.py status \
--printer a1-mini \
--push-all \
--wait-seconds 10
```
Use the model-specific printer id in both commands. If sandboxing blocks local
network access, rerun the same command with permission to contact the printer.
## Smooth Failure Handling
- If the printer is unreachable, ask the user to confirm the IP, same Wi-Fi or
VLAN reachability, and that the printer is awake.
- If MQTT status fails but TLS serial fetch worked, first confirm **Developer
Mode** is enabled, then power-cycle and retry status.
- If the access code is rejected or all zeros, ask the user to refresh the LAN
access code on the printer and rerun `config set --fetch-serial`.
- If status returns stale `FAILED`, `print_error`, or HMS entries after enabling
LAN Only or Developer Mode, stop. Ask the user to resolve the physical issue,
power-cycle, then retry status. Use `clear-error --execute` only after the
underlying cause is fixed.
- If setup succeeds, do not imply that live printing is validated. Say that LAN
connectivity is configured and status works; live printing still requires the
real-printer checklist, a dry-run plan, upload-only, and supervised start.
@@ -0,0 +1,56 @@
# Real Printer Checklist
Use this before any `--execute` run against a physical Bambu Lab printer.
## Credentials And Network
- Confirm the printer IP/hostname is on the trusted local network.
- Confirm LAN Only/Developer Mode is enabled when the printer exposes it,
especially on A1/A1 Mini.
- Store the access code in workspace-root `bambu-printers.json` with `config set`; do not
print it in final messages.
- Fetch/cache serial with `serial` or `config set --fetch-serial`.
- Run `status --push-all` and verify the intended printer responds.
- If LAN settings changed after failed starts, clear stale errors and
power-cycle before retrying.
## Job
- Confirm `$gcode` generated and validated the plain `.gcode`.
- Confirm scale, orientation, supports, material profile, nozzle, and bed type.
- For A1 Mini LAN start, prefer `--handoff template-project` with a known-good
same-printer `.gcode.3mf` template.
- Use `--handoff plain` only for diagnostics or firmware where `gcode_file` is
already validated.
- Use `--handoff bambox-project` only when the exact printer/nozzle profile is
enabled by the script.
- For project handoffs, upload to FTPS root and use `ftp:///<name>.gcode.3mf`.
## Physical Printer
If the current user request explicitly asks to print or start the job, treat that
request as live-start authorization for this checklist. State these physical
checks before the live command and proceed if automated validation and printer
status are healthy. Ask for another confirmation only when the request intent is
ambiguous or a validation/status check raises concern.
- Build plate is installed, clear, clean, and appropriate for the material.
- No failed-print remnants, loose tools, tape scraps, or debris are in the
printer.
- Filament is loaded and appropriate for the sliced file.
- Operator is nearby for heat-up, homing, and first-layer observation.
- Camera or direct observation is available.
## First Live Sequence
1. Run the exact `send` command without `--execute` and inspect the plan.
2. Run `send --action upload --execute` first.
3. Check status/UI/storage if possible.
4. Re-run the dry `send --action upload-start` plan.
5. Run `send --action upload-start --execute --confirm-start-print` when the
user explicitly asked to print/start, or after confirmation when intent is
unclear.
6. Poll status and watch the printer until the first layer is clearly normal.
If status reports `print_error` or HMS after a failed attempt, stop. Resolve the
cause, optionally run `clear-error --execute`, then poll status before retrying.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 earthtojake
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,95 @@
---
name: cad-viewer
description: Start or reuse CAD Viewer and return review links for explicit CAD, implicit CAD, robot-description, and G-code files. Use when visually reviewing `.step`, `.stp`, `.implicit.js`, `.implicit.mjs`, `.glb`, `.stl`, `.3mf`, `.gcode`, `.dxf`, `.urdf`, `.srdf`, or `.sdf` files, especially when handed off from CAD, implicit-cad, G-code, URDF, SRDF, or SDF generation skills.
---
# CAD Viewer
Provenance: maintained in [earthtojake/text-to-cad](https://github.com/earthtojake/text-to-cad).
Use the installed local skill files as the runtime source of truth; the
repository link is only for provenance and release review.
Use this skill to open existing or newly generated CAD, implicit CAD,
robot-description, DXF, or plain FDM G-code files in CAD Viewer and hand back
live review links. The expected input is one or more explicit file paths.
## Start Viewer
Start or reuse one local CAD Viewer with `npm run agent:start`, passing the
absolute artifact directory as `--dir`. The `agent:start` launcher owns port
selection, compatible-server reuse, directory activation, and the `?dir=` query
parameter. Dev-mode viewers are reused only for matching git identities; dist
bundle viewers can be reused across git branches when their viewer versions
match. It activates reused servers through the Viewer's lightweight directory
activation API, without requiring agents to probe ports or trigger catalog
scans manually. Use the Viewer URL printed by `agent:start` as-is, then add only
a `file=` query value for the artifact you want to review.
Choose `--dir` as the absolute directory that contains the model
artifacts and sidecars, commonly `<repo>/models` or the consuming project's
equivalent model directory. The `file=` value must be relative to that `--dir`.
Do not manually choose ports, probe servers, rewrite `?dir=`, or start a
separate Viewer just to change directories.
Run from this skill directory:
```bash
npm --prefix scripts/viewer run agent:start -- --host 127.0.0.1 --dir <absolute-model-root>
```
Use the printed Viewer URL and append `file=`:
```bash
http://127.0.0.1:<printed-port>/?dir=/absolute/project/models&file=path/to/model.step
```
If a non-Viewer process or another worktree's Viewer occupies the candidate
port, the launcher will continue automatically. In sandboxed agent environments,
local binding or probe failures such as `EPERM` or `EACCES` can be expected;
rerun the same command with the needed permission/escalation.
## Links
- Before returning any `file=` link, resolve `<dir>/<file>` and confirm the
artifact exists. Pass the generated artifact (e.g. `.step`), not its
generator source (e.g. `.py`). If the resolved path is missing, do not
return the link, and instead report the problem and point to the correct
generated artifact path.
- Return one Viewer URL per requested file.
- Start/reuse the Viewer once per absolute directory `--dir`, then append
`file=<path>` for each requested file. The file path must be relative to
`--dir`.
- For directory-only review links, return the URL printed by `agent:start`
without adding `file=`.
- Do not stop an existing Viewer server unless the user asks.
- If Viewer startup fails, report the failure and continue with the owning skill's non-GUI validation or artifacts.
## Claude Preview
The viewer port is dynamic — it is chosen at startup and may differ across
worktrees. To integrate with the Claude Preview tool, add `--json` to the
`agent:start` command:
```bash
npm --prefix scripts/viewer run agent:start -- --host 127.0.0.1 --dir <absolute-model-root> --json
```
The launcher writes a JSON result line to stdout after the human-readable lines.
Parse it by taking the last line of stdout that begins with `{`:
```json
{"url":"http://127.0.0.1:<port>/?dir=<absolute-model-root>","port":<port>,"action":"reuse"}
```
`action` is `"reuse"` when an existing server was reused and is immediately
ready, or `"start"` when a new server process was spawned and may still be
initializing. For a `"start"` result, probe `GET /__cad/server` on the base
URL (e.g. `http://127.0.0.1:<port>/__cad/server`) until it returns HTTP 200
before passing the `url` value to the Claude Preview tool.
## References
- Read `references/development.md` when the user asks to modify, debug, or
iterate on CAD Viewer source.
- Read `references/viewer-features.md` when you need supported file types, Viewer controls, or file-specific feature details.
- Read `references/moveit2-server.md` only when the user specifically needs optional SRDF MoveIt2 IK or path-planning controls.
@@ -0,0 +1,4 @@
interface:
display_name: "CAD Viewer"
short_description: "Open CAD, G-code, and robot files in Viewer."
default_prompt: "Use $cad-viewer to start CAD Viewer and return review links for explicit CAD, G-code, or robot-description files."
@@ -0,0 +1,12 @@
# CAD Viewer Development
Use this reference only when the user asks to modify, debug, or iterate on CAD
Viewer source.
CAD Viewer source development happens in the root
source workspace, not inside this generated skill runtime.
If you are not currently working directly in the editable source workspace, ask
the user for the correct checkout and run the agent from that repository.
Once inside the repository, read root `AGENTS.md` for guidance on modifying and running the Viewer.
@@ -0,0 +1,29 @@
# MoveIt2 Server
Load this only when the user specifically needs CAD Viewer's optional SRDF IK or path-planning controls. Plain SRDF review and normal Viewer links do not require MoveIt2.
## Start
Run from the `cad-viewer` skill directory:
```bash
npm --prefix scripts/viewer run moveit2:setup
npm --prefix scripts/viewer run moveit2:check
npm --prefix scripts/viewer run moveit2:serve
```
The server defaults to `ws://127.0.0.1:8765/ws`. CAD Viewer connects to that URL when `VIEWER_MOVEIT2_WS_URL` or the browser `?moveit2Ws=` query points at it.
Use the configured ROS 2 / MoveIt2 environment. Do not install ROS 2 or MoveIt2 packages into the repository CAD `.venv`.
## Viewer Controls
Open an `.srdf` file, expand the right-side `MoveIt2` sheet, and use:
- Status: linked SRDF and MoveIt2 server status.
- Target: planning group, end effector, target frame, and X/Y/Z target coordinates.
- Solver: IK timeout, attempts, and tolerance.
- Planning: pipeline, planner ID, plan time, velocity scale, and acceleration scale.
- Actions: select pose from the model, reset to the current pose, solve pose, or plan to pose.
Report whether the environment check passed, whether pose solving/planning worked, and any viewer/server error text.
@@ -0,0 +1,22 @@
# CAD Viewer Features
Load this only when a task needs Viewer file-support details or UI control guidance.
## Supported Files
- `.step`, `.stp`: STEP/STP review through hidden GLB sidecars; supports assembly trees, part hide/show, inspect/focus, face/edge/vertex/part selection, copied `#...` CAD references, display modes, clip planes, and optional STEP module parameters/animations when a sidecar module exists.
- `.stl`, `.3mf`, `.glb`: mesh viewing with orbit/pan/zoom, screenshots, appearance controls, and solid/wireframe display where available.
- `.dxf`: read-only flat-pattern viewing, plus plate thickness and bend direction/angle controls when bend preview data is available.
- `.gcode`: diagnostic toolpath preview; shows layer-colored extrusion ribbons, optional travel moves, visible-layer and detail controls, feature markers, movement stats, bounds, and parser warnings. It does not reslice, simulate firmware, or replace G-code validation.
- `.urdf`: robot link/mesh viewing with movable joint sliders, reset pose, and copied joint values.
- `.srdf`: linked-URDF viewing with planning groups, group-state presets, joint controls, and optional MoveIt2 IK/planning controls.
- `.sdf`: SDF model/world viewing with metadata, counts, warnings, and joint controls when available.
## Controls
- Navigation: left-drag to orbit, right/middle-drag to pan, wheel or pinch to zoom, and Arrow/WASD keys to orbit. Use the view sphere for top/bottom/front/back/left/right views; click its center for the default isometric view.
- File browser: toggle the left CAD Viewer sidebar, search files/ids/paths, expand folders, select entries, or switch files from the breadcrumb menus.
- Floating toolbar: `Select` copies STEP topology references, `Draw` opens annotation tools, `Select Pose` appears for robot target picking when available, `Open orbit preview` starts an auto-rotating preview, and the copy/download buttons capture screenshots.
- Drawing tools: freehand, line, arrow, expand, rectangle, circle, fill, erase, undo, redo, and clear.
- File sheet: open the right sheet for file-specific controls such as STEP tree/parameters, G-code layers/detail/travel, DXF thickness/bends, URDF/SRDF/SDF joints and metadata, plus display and appearance controls.
- Display/appearance: use the file sheet and theme menu for solid/wireframe/x-ray modes, STEP clipping, an independent Explode toggle for animated vertical STEP disassembly, surface colors, edge visibility/detail, highlight styling, backdrop, floor/grid, lighting, theme presets, and color mode.
@@ -0,0 +1 @@
--editable ./scripts/viewer/packages/cadpy
@@ -0,0 +1,12 @@
node_modules
.env
.env.*
!.env.example
!.env.*.example
__pycache__
*.py[cod]
.pytest_cache
tmp
!dist
!dist/**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
import{L as z,F as I,B as H,C as P,a as G,V as _,b as N}from"./three.module-BB51Dq4E.js";class O extends z{constructor(h){super(h)}load(h,T,V,y){const b=this,m=new I(this.manager);m.setPath(this.path),m.setResponseType("arraybuffer"),m.setRequestHeader(this.requestHeader),m.setWithCredentials(this.withCredentials),m.load(h,function(v){try{T(b.parse(v))}catch(A){y?y(A):console.error(A),b.manager.itemError(h)}},V,y)}parse(h){function T(t){const e=new DataView(t),o=32/8*3+32/8*3*3+16/8,a=e.getUint32(80,!0);if(80+32/8+a*o===e.byteLength)return!0;const i=[115,111,108,105,100];for(let s=0;s<5;s++)if(V(i,e,s))return!1;return!0}function V(t,e,o){for(let a=0,f=t.length;a<f;a++)if(t[a]!==e.getUint8(o+a))return!1;return!0}function y(t){const e=new DataView(t),o=e.getUint32(80,!0);let a,f,i,s=!1,w,S,C,L,U;for(let n=0;n<70;n++)e.getUint32(n,!1)==1129270351&&e.getUint8(n+4)==82&&e.getUint8(n+5)==61&&(s=!0,w=new Float32Array(o*3*3),S=e.getUint8(n+6)/255,C=e.getUint8(n+7)/255,L=e.getUint8(n+8)/255,U=e.getUint8(n+9)/255);const g=84,r=50,c=new H,p=new Float32Array(o*3*3),d=new Float32Array(o*3*3),x=new P;for(let n=0;n<o;n++){const F=g+n*r,D=e.getFloat32(F,!0),B=e.getFloat32(F+4,!0),R=e.getFloat32(F+8,!0);if(s){const l=e.getUint16(F+48,!0);(l&32768)===0?(a=(l&31)/31,f=(l>>5&31)/31,i=(l>>10&31)/31):(a=S,f=C,i=L)}for(let l=1;l<=3;l++){const E=F+l*12,u=n*3*3+(l-1)*3;p[u]=e.getFloat32(E,!0),p[u+1]=e.getFloat32(E+4,!0),p[u+2]=e.getFloat32(E+8,!0),d[u]=D,d[u+1]=B,d[u+2]=R,s&&(x.set(a,f,i).convertSRGBToLinear(),w[u]=x.r,w[u+1]=x.g,w[u+2]=x.b)}}return c.setAttribute("position",new G(p,3)),c.setAttribute("normal",new G(d,3)),s&&(c.setAttribute("color",new G(w,3)),c.hasColors=!0,c.alpha=U),c}function b(t){const e=new H,o=/solid([\s\S]*?)endsolid/g,a=/facet([\s\S]*?)endfacet/g,f=/solid\s(.+)/;let i=0;const s=/[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source,w=new RegExp("vertex"+s+s+s,"g"),S=new RegExp("normal"+s+s+s,"g"),C=[],L=[],U=[],g=new _;let r,c=0,p=0,d=0;for(;(r=o.exec(t))!==null;){p=d;const x=r[0],n=(r=f.exec(x))!==null?r[1]:"";for(U.push(n);(r=a.exec(x))!==null;){let B=0,R=0;const l=r[0];for(;(r=S.exec(l))!==null;)g.x=parseFloat(r[1]),g.y=parseFloat(r[2]),g.z=parseFloat(r[3]),R++;for(;(r=w.exec(l))!==null;)C.push(parseFloat(r[1]),parseFloat(r[2]),parseFloat(r[3])),L.push(g.x,g.y,g.z),B++,d++;R!==1&&console.error("THREE.STLLoader: Something isn't right with the normal of face number "+i),B!==3&&console.error("THREE.STLLoader: Something isn't right with the vertices of face number "+i),i++}const F=p,D=d-p;e.userData.groupNames=U,e.addGroup(F,D,c),c++}return e.setAttribute("position",new N(C,3)),e.setAttribute("normal",new N(L,3)),e}function m(t){return typeof t!="string"?new TextDecoder().decode(t):t}function v(t){if(typeof t=="string"){const e=new Uint8Array(t.length);for(let o=0;o<t.length;o++)e[o]=t.charCodeAt(o)&255;return e.buffer||e}else return t}const A=v(h);return T(A)?y(A):b(m(h))}}export{O as STLLoader};
//# sourceMappingURL=STLLoader-C7hGonJR.js.map
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
function c(r){r.computeBoundingBox();const t=r.boundingBox?.min,e=r.boundingBox?.max;return!t||!e?{min:[0,0,0],max:[0,0,0]}:{min:[t.x,t.y,t.z],max:[e.x,e.y,e.z]}}const l=Math.PI/4;function f(r,t){const e=r.clone();e.deleteAttribute?.("normal");const o=typeof t=="function"?t(e,l):e;return o!==e&&e.dispose?.(),o.getAttribute("normal")||o.computeVertexNormals?.(),o}function u(r,t){const e=r.getIndex?.();if(e?.count>0)return new Uint32Array(e.array);const o=new Uint32Array(t);for(let n=0;n<t;n+=1)o[n]=n;return o}function d(r,{toCreasedNormals:t}={}){const e=f(r,t),o=r.getAttribute("position"),n=e.getAttribute("position"),s=e.getAttribute("normal");try{if(!o||o.itemSize!==3||!n||n.itemSize!==3)throw new Error("STL geometry is missing vertex positions");const a=n.count;return{vertices:new Float32Array(n.array),indices:u(e,a),normals:s?.itemSize===3?new Float32Array(s.array):new Float32Array(n.array.length),colors:new Float32Array(0),edge_indices:new Uint32Array(0),bounds:c(r),parts:[],has_source_colors:!1,sourceColor:"",sourceFormat:"stl"}}finally{e.dispose?.()}}async function y(r){const[{STLLoader:t},{toCreasedNormals:e}]=await Promise.all([import("./STLLoader-C7hGonJR.js"),import("./BufferGeometryUtils-xu92ooFc.js")]),n=new t().parse(r);try{return d(n,{toCreasedNormals:e})}finally{n.dispose?.()}}function m(r){const t=[r?.vertices?.buffer,r?.indices?.buffer,r?.normals?.buffer,r?.colors?.buffer,r?.edge_indices?.buffer,r?.surfaceEdgeBarycentric?.buffer,r?.surfaceEdgeClass?.buffer,r?.guide_line_segments?.buffer].filter(e=>e instanceof ArrayBuffer&&e.byteLength>0);return[...new Set(t)]}const i=new Map;function b(r,t){return new Error(`Failed to fetch ${r}: ${t.status} ${t.statusText}`)}async function g(r,t){const e=await fetch(r,{signal:t});if(!e.ok)throw b(r,e);return e.arrayBuffer()}self.addEventListener("message",async r=>{const t=r.data||{},e=t.id;if(!e)return;if(t.type==="cancel"){i.get(e)?.abort(),i.delete(e);return}if(t.type!=="loadStl")return;const o=new AbortController;i.set(e,o);try{const n=await g(t.url,o.signal),s=await y(n);if(o.signal.aborted)return;self.postMessage({id:e,ok:!0,meshData:s},m(s))}catch(n){o.signal.aborted||self.postMessage({id:e,ok:!1,error:{name:n?.name||"Error",message:n instanceof Error?n.message:String(n)}})}finally{i.delete(e)}});
//# sourceMappingURL=stlMeshWorker-0yVf-FuZ.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Browse cached CAD parts, assemblies, and prompt-ready references" />
<title>CAD Viewer</title>
<script>
(function () {
const root = document.documentElement;
const storageKey = "cad-viewer:color-scheme";
const prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
let preference = "system";
try {
const storedPreference = window.localStorage && window.localStorage.getItem(storageKey);
if (storedPreference === "light" || storedPreference === "dark" || storedPreference === "system") {
preference = storedPreference;
}
} catch {
preference = "system";
}
const mode = preference === "system" ? (prefersDark ? "dark" : "light") : preference;
root.dataset.themePreference = preference;
root.dataset.theme = mode;
root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode;
})();
</script>
<script type="module" crossorigin src="/assets/index-BkFzql5p.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-7jnMmQ9s.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-CtsphK4U.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-icons-FFp724MI.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-three-SYzIooV9.js">
<link rel="stylesheet" crossorigin href="/assets/index-vRirKMey.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,3 @@
from pathlib import Path
__path__.append(str(Path(__file__).resolve().parent / "moveit2_server"))
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
MOVEIT2_SERVER_CONDA_ENV_NAME="${MOVEIT2_SERVER_CONDA_ENV_NAME:-moveit2-server}"
find_conda() {
if [[ -n "${MOVEIT2_SERVER_CONDA_EXE:-}" && -x "${MOVEIT2_SERVER_CONDA_EXE}" ]]; then
printf '%s\n' "${MOVEIT2_SERVER_CONDA_EXE}"
return 0
fi
if [[ -n "${CONDA_EXE:-}" && -x "${CONDA_EXE}" ]]; then
printf '%s\n' "${CONDA_EXE}"
return 0
fi
if command -v conda >/dev/null 2>&1; then
command -v conda
return 0
fi
local candidate
for candidate in \
"${HOME}/miniforge3/bin/conda" \
"${HOME}/mambaforge/bin/conda" \
"${HOME}/miniconda3/bin/conda" \
"${HOME}/anaconda3/bin/conda" \
"/opt/conda/bin/conda"
do
if [[ -x "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
printf 'Could not find conda. Install Miniforge, put conda on PATH, or set MOVEIT2_SERVER_CONDA_EXE/CONDA_EXE.\n' >&2
return 1
}
find_repo_root() {
if [[ -n "${MOVEIT2_SERVER_REPO_ROOT:-}" ]]; then
if [[ ! -d "${MOVEIT2_SERVER_REPO_ROOT}" ]]; then
printf 'MOVEIT2_SERVER_REPO_ROOT does not exist: %s\n' "${MOVEIT2_SERVER_REPO_ROOT}" >&2
return 1
fi
(cd "${MOVEIT2_SERVER_REPO_ROOT}" && pwd -P)
return 0
fi
local root
if root="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "${root}"
return 0
fi
printf 'Could not find a repository root from the current directory. Run from the robot project repo or set MOVEIT2_SERVER_REPO_ROOT.\n' >&2
return 1
}
@@ -0,0 +1,14 @@
name: moveit2-server
channels:
- conda-forge
- robostack-jazzy
dependencies:
- python=3.12
- pip
- websockets
- ros-jazzy-ros-base
- ros-jazzy-rclpy
- ros-jazzy-geometry-msgs
- ros-jazzy-moveit-py
- ros-jazzy-moveit-planners-ompl
- ros-jazzy-rmw-cyclonedds-cpp
@@ -0,0 +1 @@
"""MoveIt2 websocket server for SRDF workflows."""
@@ -0,0 +1,904 @@
from __future__ import annotations
from math import isfinite
import posixpath
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from moveit2_server.protocol import MotionProtocolError, SUPPORTED_REQUEST_TYPES
from moveit2_server.srdf_source import SrdfSource, read_srdf_source
DEFAULT_CAD_DIRECTORY = ""
DEFAULT_IK_TIMEOUT = 0.05
DEFAULT_IK_ATTEMPTS = 1
DEFAULT_POSITION_TOLERANCE = 0.002
DEFAULT_PLANNING_PIPELINE = "ompl"
DEFAULT_PLANNER_ID = "RRTConnectkConfigDefault"
DEFAULT_PLANNING_TIME = 1.0
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _path_is_inside(child_path: Path, parent_path: Path) -> bool:
try:
child_path.resolve().relative_to(parent_path.resolve())
return True
except ValueError:
return False
def _file_version(file_path: Path) -> str:
try:
stats = file_path.stat()
except FileNotFoundError:
return ""
if not stats.st_mode:
return ""
return f"{stats.st_size:x}-{stats.st_mtime_ns:x}"
def _combined_version(paths: list[Path]) -> str:
return "|".join(f"{path.name}:{_file_version(path)}" for path in paths)
def _normalize_relative_path(value: Any, *, label: str, suffix: str | None = None) -> str:
raw_value = str(value or "").strip().replace("\\", "/").lstrip("/")
normalized = posixpath.normpath(raw_value)
if not raw_value or normalized in {"", ".", ".."} or normalized.startswith("../"):
raise MotionProtocolError(f"{label} must stay inside the repository: {raw_value or '(missing)'}")
if suffix and not normalized.lower().endswith(suffix):
raise MotionProtocolError(f"{label} must end in {suffix}: {normalized}")
return normalized
def normalize_cad_directory(value: Any = DEFAULT_CAD_DIRECTORY) -> str:
if value is None:
value = DEFAULT_CAD_DIRECTORY
raw_value = str(value).strip()
if not raw_value:
return ""
return _normalize_relative_path(raw_value, label="dir")
def normalize_file_ref(value: Any) -> str:
return _normalize_relative_path(value, label="file", suffix=".srdf")
def _file_ref_relative_to_cad_dir(file_ref: str, *, cad_dir: str, cad_root: Path) -> str:
if not cad_dir:
return file_ref
prefix = f"{cad_dir.rstrip('/')}/"
if not file_ref.startswith(prefix):
return file_ref
scan_relative_ref = file_ref[len(prefix):]
if scan_relative_ref and not (cad_root / file_ref).is_file() and (cad_root / scan_relative_ref).is_file():
return scan_relative_ref
return file_ref
def _resolve_srdf_urdf_path(srdf_source: SrdfSource, *, srdf_path: Path, repo_root: Path) -> Path:
raw_value = str(srdf_source.urdf_ref or "").strip()
if not raw_value:
raise MotionProtocolError("SRDF must include tcad:urdf metadata")
if "\\" in raw_value:
raise MotionProtocolError("SRDF tcad:urdf path must use POSIX '/' separators")
raw_path = Path(raw_value)
if raw_path.is_absolute():
raise MotionProtocolError("SRDF tcad:urdf path must be relative")
candidate = (srdf_path.parent / raw_path).resolve()
if not _path_is_inside(candidate, repo_root):
raise MotionProtocolError(f"SRDF tcad:urdf path must stay inside the repository: {raw_value}")
if candidate.suffix.lower() != ".urdf":
raise MotionProtocolError(f"SRDF tcad:urdf path must end in .urdf: {raw_value}")
if not candidate.is_file():
raise MotionProtocolError(f"SRDF tcad:urdf file does not exist: {raw_value}")
return candidate
def _urdf_robot(urdf_path: Path) -> dict[str, Any]:
try:
root = ET.parse(urdf_path).getroot()
except FileNotFoundError as exc:
raise MotionProtocolError(f"URDF file does not exist: {urdf_path}") from exc
except ET.ParseError as exc:
raise MotionProtocolError(f"URDF file is invalid XML: {urdf_path}") from exc
if root.tag != "robot":
raise MotionProtocolError("URDF root must be <robot>")
links = {
str(link.get("name") or "").strip()
for link in root.findall("link")
if str(link.get("name") or "").strip()
}
joints: dict[str, dict[str, str]] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
if not name:
continue
parent_element = joint.find("parent")
child_element = joint.find("child")
joint_type = str(joint.get("type") or "").strip()
limit_element = joint.find("limit")
lower = _optional_finite_float(limit_element.get("lower") if limit_element is not None else None)
upper = _optional_finite_float(limit_element.get("upper") if limit_element is not None else None)
joints[name] = {
"type": joint_type,
"parent": str(parent_element.get("link") if parent_element is not None else "").strip(),
"child": str(child_element.get("link") if child_element is not None else "").strip(),
"lower": lower if joint_type in {"revolute", "prismatic"} else None,
"upper": upper if joint_type in {"revolute", "prismatic"} else None,
"mimic": joint.find("mimic") is not None,
}
return {
"name": str(root.get("name") or "").strip(),
"links": links,
"joints": joints,
}
def _optional_finite_float(value: Any) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if isfinite(parsed) else None
def _active_joint_names(robot: dict[str, Any]) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name, joint in joints.items()
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic"))
]
def _all_joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
if not base_link or not tip_link:
return []
if base_link == tip_link:
return []
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = {}
for raw_name, raw_joint in joints.items():
if not isinstance(raw_joint, dict):
continue
parent = str(raw_joint.get("parent") or "").strip()
child = str(raw_joint.get("child") or "").strip()
if parent and child:
by_parent.setdefault(parent, []).append((str(raw_name), raw_joint))
stack: list[tuple[str, list[tuple[str, dict[str, Any]]]]] = [(base_link, [])]
visited: set[str] = set()
while stack:
link_name, path = stack.pop()
if link_name == tip_link:
return [joint_name for joint_name, _joint in path]
if link_name in visited:
continue
visited.add(link_name)
for joint_name, joint in reversed(by_parent.get(link_name, [])):
child = str(joint.get("child") or "").strip()
if child:
stack.append((child, [*path, (joint_name, joint)]))
return []
def _joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
names: list[str] = []
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
joint = joints.get(joint_name)
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic")):
names.append(joint_name)
return names
def _append_unique(target: list[str], values: list[str]) -> None:
seen = set(target)
for value in values:
if value not in seen:
target.append(value)
seen.add(value)
def _joint_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> list[str]:
explicit_joint_names = list(group.get("jointNames") or [])
if explicit_joint_names:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name in explicit_joint_names
if isinstance(joints.get(str(name)), dict)
and str(joints[str(name)].get("type") or "").strip() != "fixed"
and not bool(joints[str(name)].get("mimic"))
]
names: list[str] = []
for chain in group.get("chains") or []:
if not isinstance(chain, dict):
continue
_append_unique(
names,
_joint_names_for_chain(
robot,
base_link=str(chain.get("baseLink") or "").strip(),
tip_link=str(chain.get("tipLink") or "").strip(),
),
)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups") or []:
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
_append_unique(
names,
_joint_names_for_group(
subgroup,
robot=robot,
groups_by_name=groups_by_name,
visited=visited,
),
)
return names
def _positive_float(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be positive") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be positive")
return numeric
def _positive_int(value: Any, *, label: str, default: int) -> int:
if value is None:
value = default
try:
numeric = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a positive integer") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be a positive integer")
return numeric
def _scaling(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be between 0 and 1") from exc
if numeric <= 0 or numeric > 1:
raise MotionProtocolError(f"{label} must be between 0 and 1")
return numeric
def _boolean(value: Any, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _string(value: Any, *, label: str, default: str = "") -> str:
normalized = str(value if value is not None else default).strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _optional_object(value: Any, *, label: str) -> dict[str, Any]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
return value
def _validate_srdf_inventory(metadata: dict[str, Any], *, robot: dict[str, Any]) -> dict[str, Any]:
if str(metadata.get("robotName") or "").strip() != str(robot.get("name") or "").strip():
raise MotionProtocolError("SRDF robotName must match the linked URDF robot name")
link_names = robot.get("links")
joint_payload = robot.get("joints")
if not isinstance(link_names, set) or not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
joint_names = set(joint_payload)
groups = _validate_planning_groups(metadata.get("planningGroups"), link_names=link_names, joint_names=joint_names)
group_by_name = {group["name"]: group for group in groups}
end_effectors = _validate_end_effectors(
metadata.get("endEffectors"),
link_names=link_names,
groups=group_by_name,
robot=robot,
)
group_states = _validate_group_states(metadata.get("groupStates"), groups=group_by_name, robot=robot)
disabled_pairs = _validate_disabled_pairs(metadata.get("disabledCollisionPairs"), link_names=link_names)
return {
**metadata,
"planningGroups": groups,
"endEffectors": end_effectors,
"groupStates": group_states,
"disabledCollisionPairs": disabled_pairs,
}
def _group_tip_link(group: Any) -> str:
if isinstance(group, dict):
link_names = group.get("linkNames")
if isinstance(link_names, list) and link_names:
return str(link_names[-1])
chains = group.get("chains")
if isinstance(chains, list) and chains:
chain = chains[-1]
if isinstance(chain, dict):
return str(chain.get("tipLink") or "")
return ""
link_names = getattr(group, "link_names", ())
if link_names:
return str(link_names[-1])
chains = getattr(group, "chains", ())
if chains:
return str(getattr(chains[-1], "tip_link", "") or "")
return ""
def _end_effector_link(end_effector: Any, groups_by_name: dict[str, Any]) -> str:
group_name = str(getattr(end_effector, "group", "") or "")
group = groups_by_name.get(group_name)
if group is not None:
group_tip = _group_tip_link(group)
if group_tip:
return group_tip
return ""
def _srdf_inventory_from_source(srdf_source: SrdfSource, *, srdf_path: Path, urdf_path: Path) -> dict[str, Any]:
groups_by_name = {group.name: group for group in srdf_source.planning_groups}
return {
"urdf": str(urdf_path),
"srdf": str(srdf_path),
"robotName": srdf_source.robot_name,
"planningGroups": [
{
"name": group.name,
"jointNames": list(group.joint_names),
"linkNames": list(group.link_names),
"chains": [
{"baseLink": chain.base_link, "tipLink": chain.tip_link}
for chain in group.chains
],
"subgroups": list(group.subgroups),
}
for group in srdf_source.planning_groups
],
"endEffectors": [
{
"name": end_effector.name,
"link": _end_effector_link(end_effector, groups_by_name) or end_effector.parent_link,
"parentLink": end_effector.parent_link,
"group": end_effector.group,
"parentGroup": end_effector.parent_group,
}
for end_effector in srdf_source.end_effectors
],
"groupStates": [
{
"name": group_state.name,
"group": group_state.group,
"jointValuesByName": group_state.joint_values_by_name,
"jointValuesByNameRad": group_state.joint_values_by_name,
}
for group_state in srdf_source.group_states
],
"disabledCollisionPairs": [
{
"link1": pair.link1,
"link2": pair.link2,
"reason": pair.reason,
"source": pair.source,
}
for pair in srdf_source.disabled_collision_pairs
],
}
def _validate_planning_groups(value: Any, *, link_names: set[str], joint_names: set[str]) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value:
raise MotionProtocolError("SRDF planningGroups must be a non-empty array")
groups: list[dict[str, Any]] = []
seen = set()
for raw_group in value:
if not _plain_object(raw_group):
raise MotionProtocolError("SRDF planning group must be an object")
name = _string(raw_group.get("name"), label="SRDF planning group name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF planning group {name}")
seen.add(name)
group_joint_names = _validate_names(raw_group.get("jointNames"), allowed=joint_names, label=f"SRDF planning group {name} jointNames")
group_link_names = _validate_names(raw_group.get("linkNames"), allowed=link_names, label=f"SRDF planning group {name} linkNames")
chains = raw_group.get("chains") if isinstance(raw_group.get("chains"), list) else []
normalized_chains: list[dict[str, str]] = []
for raw_chain in chains:
if not _plain_object(raw_chain):
raise MotionProtocolError(f"SRDF planning group {name} chains must contain objects")
base_link = _string(raw_chain.get("baseLink"), label=f"SRDF planning group {name} chain baseLink")
tip_link = _string(raw_chain.get("tipLink"), label=f"SRDF planning group {name} chain tipLink")
if base_link not in link_names or tip_link not in link_names:
raise MotionProtocolError(f"SRDF planning group {name} chain references missing URDF link")
normalized_chains.append({"baseLink": base_link, "tipLink": tip_link})
subgroups = [str(group).strip() for group in raw_group.get("subgroups", []) if str(group).strip()]
groups.append({
"name": name,
"jointNames": group_joint_names,
"linkNames": group_link_names,
"chains": normalized_chains,
"subgroups": subgroups,
})
group_names = {group["name"] for group in groups}
for group in groups:
for subgroup in group["subgroups"]:
if subgroup not in group_names:
raise MotionProtocolError(f"SRDF planning group {group['name']} references missing subgroup {subgroup}")
return groups
def _validate_names(value: Any, *, allowed: set[str], label: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError(f"{label} must be an array")
names: list[str] = []
seen = set()
for raw_name in value:
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty names")
if name in seen:
raise MotionProtocolError(f"{label} includes duplicate {name}")
if name not in allowed:
raise MotionProtocolError(f"{label} references missing name {name}")
seen.add(name)
names.append(name)
return names
def _link_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> set[str]:
links = set(str(link) for link in group.get("linkNames", []) if str(link))
joints = robot.get("joints")
if not isinstance(joints, dict):
return links
def add_joint_links(joint_name: str) -> None:
joint = joints.get(joint_name)
if not isinstance(joint, dict):
return
child = str(joint.get("child") or "").strip()
if child:
links.add(child)
for joint_name in group.get("jointNames", []):
add_joint_links(str(joint_name))
for chain in group.get("chains", []):
if not isinstance(chain, dict):
continue
base_link = str(chain.get("baseLink") or "").strip()
tip_link = str(chain.get("tipLink") or "").strip()
if tip_link:
links.add(tip_link)
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
add_joint_links(joint_name)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups", []):
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
links.update(_link_names_for_group(subgroup, robot=robot, groups_by_name=groups_by_name, visited=visited))
links.discard("")
return links
def _joint_adjacent_to_any_link(robot: dict[str, Any], parent_link: str, child_links: set[str]) -> bool:
joints = robot.get("joints")
if not isinstance(joints, dict):
return False
for joint in joints.values():
if not isinstance(joint, dict):
continue
parent = str(joint.get("parent") or "").strip()
child = str(joint.get("child") or "").strip()
if (parent == parent_link and child in child_links) or (child == parent_link and parent in child_links):
return True
return False
def _validate_end_effectors(
value: Any,
*,
link_names: set[str],
groups: dict[str, dict[str, Any]],
robot: dict[str, Any],
) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF endEffectors must be an array")
end_effectors: list[dict[str, Any]] = []
seen = set()
for raw_end_effector in value:
if not _plain_object(raw_end_effector):
raise MotionProtocolError("SRDF end effector must be an object")
name = _string(raw_end_effector.get("name"), label="SRDF end effector name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF end effector {name}")
seen.add(name)
parent_link = _string(raw_end_effector.get("parentLink"), label=f"SRDF end effector {name} parentLink")
if parent_link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentLink {parent_link}")
group_name = _string(raw_end_effector.get("group"), label=f"SRDF end effector {name} group")
if group_name not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing group {group_name}")
parent_group = str(raw_end_effector.get("parentGroup") or "").strip()
if parent_group and parent_group not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentGroup {parent_group}")
link = str(raw_end_effector.get("link") or _group_tip_link(groups[group_name]) or parent_link).strip()
if link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing link {link}")
end_effector_links = _link_names_for_group(groups[group_name], robot=robot, groups_by_name=groups)
if parent_group:
parent_group_links = _link_names_for_group(groups[parent_group], robot=robot, groups_by_name=groups)
overlap = sorted(end_effector_links & parent_group_links)
if overlap:
raise MotionProtocolError(f"SRDF end effector {name} group shares link(s) with parentGroup: {overlap}")
if parent_link not in parent_group_links:
raise MotionProtocolError(
f"SRDF end effector {name} parentLink {parent_link} is not in parentGroup {parent_group}"
)
if end_effector_links and parent_link not in end_effector_links and not _joint_adjacent_to_any_link(
robot,
parent_link,
end_effector_links,
):
raise MotionProtocolError(f"SRDF end effector {name} parentLink is not adjacent to its group")
end_effectors.append({
"name": name,
"link": link,
"parentLink": parent_link,
"group": group_name,
"parentGroup": parent_group,
})
return end_effectors
def _validate_group_state_joint_value(state_name: str, joint_name: str, value: float, joint: dict[str, Any]) -> None:
joint_type = str(joint.get("type") or "").strip()
if joint_type == "fixed":
raise MotionProtocolError(f"SRDF group state {state_name} cannot set fixed joint {joint_name}")
if bool(joint.get("mimic")):
raise MotionProtocolError(f"SRDF group state {state_name} cannot set mimic joint {joint_name}")
if joint_type == "continuous":
return
lower = joint.get("lower")
upper = joint.get("upper")
if isinstance(lower, float) and value < lower:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is below its URDF lower limit")
if isinstance(upper, float) and value > upper:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is above its URDF upper limit")
def _validate_group_states(value: Any, *, groups: dict[str, dict[str, Any]], robot: dict[str, Any]) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF groupStates must be an array")
states: list[dict[str, Any]] = []
seen_keys: set[str] = set()
joint_payload = robot.get("joints")
if not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
for raw_state in value:
if not _plain_object(raw_state):
raise MotionProtocolError("SRDF group state must be an object")
name = _string(raw_state.get("name"), label="SRDF group state name")
group = _string(raw_state.get("group"), label=f"SRDF group state {name} group")
state_key = f"{group}/{name}"
if state_key in seen_keys:
raise MotionProtocolError(f"Duplicate SRDF group state {state_key}")
seen_keys.add(state_key)
if group not in groups:
raise MotionProtocolError(f"SRDF group state {name} references missing group {group}")
group_joint_names = set(_joint_names_for_group(groups[group], robot=robot, groups_by_name=groups))
joint_values = raw_state.get("jointValuesByName")
legacy_joint_values = raw_state.get("jointValuesByNameRad")
if joint_values is None and legacy_joint_values is not None:
joint_values = legacy_joint_values
if not _plain_object(joint_values):
raise MotionProtocolError(f"SRDF group state {name} jointValuesByName must be an object")
parsed_values: dict[str, float] = {}
for raw_joint_name, raw_value in joint_values.items():
joint_name = str(raw_joint_name or "").strip()
joint = joint_payload.get(joint_name)
if not isinstance(joint, dict):
raise MotionProtocolError(f"SRDF group state {name} references missing joint {joint_name}")
if joint_name not in group_joint_names:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} is not in group {group}")
try:
parsed_value = float(raw_value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be numeric") from exc
if not isfinite(parsed_value):
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be finite")
_validate_group_state_joint_value(name, joint_name, parsed_value, joint)
parsed_values[joint_name] = parsed_value
states.append({"name": name, "group": group, "jointValuesByName": parsed_values, "jointValuesByNameRad": parsed_values})
return states
def _validate_disabled_pairs(value: Any, *, link_names: set[str]) -> list[dict[str, str]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF disabledCollisionPairs must be an array")
pairs: list[dict[str, str]] = []
seen: set[str] = set()
for raw_pair in value:
if not _plain_object(raw_pair):
raise MotionProtocolError("SRDF disabled collision pair must be an object")
link1 = _string(raw_pair.get("link1"), label="SRDF disabled collision link1")
link2 = _string(raw_pair.get("link2"), label="SRDF disabled collision link2")
if link1 == link2:
raise MotionProtocolError("SRDF disabled collision pair cannot repeat the same link")
if link1 not in link_names or link2 not in link_names:
raise MotionProtocolError("SRDF disabled collision pair references missing URDF link")
pair_key = "/".join(sorted((link1, link2)))
if pair_key in seen:
raise MotionProtocolError(f"Duplicate SRDF disabled collision pair {pair_key}")
seen.add(pair_key)
reason = str(raw_pair.get("reason") or "").strip()
if not reason:
raise MotionProtocolError(f"SRDF disabled collision pair {pair_key} requires a reason")
pairs.append({
"link1": link1,
"link2": link2,
"reason": reason,
"source": str(raw_pair.get("source") or "manual").strip(),
})
return pairs
def _moveit_settings(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("moveit2"), label="moveit2")
def _target_payload(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("target"), label="target")
def _find_group(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for group in metadata.get("planningGroups", []):
if str(group.get("name") or "") == name:
return group
raise MotionProtocolError(f"Selected planning group {name} is not defined by the SRDF")
def _find_end_effector(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for end_effector in metadata.get("endEffectors", []):
if str(end_effector.get("name") or "") == name:
return end_effector
raise MotionProtocolError(f"Selected end effector {name} is not defined by the SRDF")
def _default_end_effector(metadata: dict[str, Any], planning_group_name: str) -> dict[str, Any] | None:
end_effectors = list(metadata.get("endEffectors", []))
for end_effector in end_effectors:
if str(end_effector.get("parentGroup") or "") == planning_group_name:
return end_effector
return end_effectors[0] if end_effectors else None
def _build_command(
*,
command_name: str,
payload: Any,
metadata: dict[str, Any],
robot: dict[str, Any],
) -> dict[str, Any]:
moveit2 = _moveit_settings(payload)
target = _target_payload(payload)
default_group = metadata["planningGroups"][0]
planning_group_name = _string(
moveit2.get("planningGroup"),
label="moveit2.planningGroup",
default=str(default_group.get("name") or ""),
)
planning_group = _find_group(metadata, planning_group_name)
default_end_effector = _default_end_effector(metadata, planning_group_name)
selected_end_effector_name = str(moveit2.get("endEffector") or target.get("endEffector") or "").strip()
selected_end_effector = _find_end_effector(metadata, selected_end_effector_name) if selected_end_effector_name else default_end_effector
if selected_end_effector is None:
raise MotionProtocolError("SRDF must define an end effector for MoveIt2 pose requests")
robot_links = robot.get("links")
if not isinstance(robot_links, set):
raise MotionProtocolError("URDF robot inventory is invalid")
target_frame = _string(
moveit2.get("targetFrame") or target.get("frame"),
label="moveit2.targetFrame",
default=next(iter(sorted(robot_links)), ""),
)
if target_frame not in robot_links:
raise MotionProtocolError(f"moveit2.targetFrame references missing URDF link {target_frame}")
ik = _optional_object(moveit2.get("ik"), label="moveit2.ik")
planning = _optional_object(moveit2.get("planning"), label="moveit2.planning")
target_has_orientation = "quat_xyzw" in target or "rpy" in target
position_only_ik = _boolean(ik.get("positionOnly"), default=not target_has_orientation)
if not target_has_orientation and not position_only_ik:
raise MotionProtocolError("target orientation is required when moveit2.ik.positionOnly is false")
groups_by_name = {
str(group.get("name") or ""): group
for group in metadata.get("planningGroups", [])
if isinstance(group, dict) and str(group.get("name") or "")
}
joint_names = _joint_names_for_group(planning_group, robot=robot, groups_by_name=groups_by_name)
target_link = _string(
moveit2.get("targetLink") or target.get("targetLink") or target.get("link"),
label="moveit2.targetLink",
default=str(selected_end_effector.get("link") or ""),
)
if target_link not in robot_links:
raise MotionProtocolError(f"moveit2.targetLink references missing URDF link {target_link}")
command = {
"planningGroup": planning_group_name,
"jointNames": joint_names,
"endEffectors": [
{
"name": selected_end_effector["name"],
"link": target_link,
"inferredLink": selected_end_effector["link"],
"frame": target_frame,
"planningGroup": planning_group_name,
"jointNames": joint_names,
"positionTolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
}
],
"ik": {
"timeout": _positive_float(ik.get("timeout"), label="moveit2.ik.timeout", default=DEFAULT_IK_TIMEOUT),
"attempts": _positive_int(ik.get("attempts"), label="moveit2.ik.attempts", default=DEFAULT_IK_ATTEMPTS),
"positionOnly": position_only_ik,
"tolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
},
"planner": {
"pipeline": _string(planning.get("pipeline"), label="moveit2.planning.pipeline", default=DEFAULT_PLANNING_PIPELINE),
"plannerId": _string(planning.get("plannerId"), label="moveit2.planning.plannerId", default=DEFAULT_PLANNER_ID),
"planningTime": _positive_float(planning.get("planningTime"), label="moveit2.planning.planningTime", default=DEFAULT_PLANNING_TIME),
"maxVelocityScalingFactor": _scaling(
planning.get("maxVelocityScalingFactor"),
label="moveit2.planning.maxVelocityScalingFactor",
default=1.0,
),
"maxAccelerationScalingFactor": _scaling(
planning.get("maxAccelerationScalingFactor"),
label="moveit2.planning.maxAccelerationScalingFactor",
default=1.0,
),
},
}
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name}")
return command
def build_moveit2_context(
*,
repo_root: str | Path,
dir: Any = DEFAULT_CAD_DIRECTORY,
file: Any,
type: Any,
payload: Any = None,
) -> dict[str, Any]:
command_name = str(type or "").strip()
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name or '(missing)'}")
resolved_repo_root = Path(repo_root).resolve()
cad_dir = normalize_cad_directory(dir)
file_ref = normalize_file_ref(file)
cad_root = (resolved_repo_root / cad_dir).resolve()
if not _path_is_inside(cad_root, resolved_repo_root):
raise MotionProtocolError(f"dir must stay inside the repository: {cad_dir}")
file_ref = _file_ref_relative_to_cad_dir(file_ref, cad_dir=cad_dir, cad_root=cad_root)
srdf_path = (cad_root / file_ref).resolve()
if not _path_is_inside(srdf_path, cad_root):
root_label = cad_dir or "the repository"
raise MotionProtocolError(f"file must stay inside {root_label}: {file_ref}")
if not srdf_path.is_file():
raise MotionProtocolError(f"SRDF file does not exist: {file_ref}")
try:
srdf_source = read_srdf_source(srdf_path)
except Exception as exc:
raise MotionProtocolError(str(exc)) from exc
urdf_path = _resolve_srdf_urdf_path(srdf_source, srdf_path=srdf_path, repo_root=resolved_repo_root)
robot = _urdf_robot(urdf_path)
inventory_payload = _srdf_inventory_from_source(srdf_source, srdf_path=srdf_path, urdf_path=urdf_path)
srdf_inventory = _validate_srdf_inventory(inventory_payload, robot=robot)
command = _build_command(command_name=command_name, payload=payload, metadata=srdf_inventory, robot=robot)
version_paths = [urdf_path, srdf_path]
return {
"repoRoot": str(resolved_repo_root),
"dir": cad_dir,
"file": file_ref,
"urdfPath": str(urdf_path),
"srdfPath": str(srdf_path),
"modelAssetHash": _combined_version(version_paths),
"commandName": command_name,
"command": command,
"srdf": srdf_inventory,
}
@@ -0,0 +1,29 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from moveit2_server.protocol import MotionProtocolError, MotionRequest
from moveit2_server.moveit_py import MoveItPyAdapter
@lru_cache(maxsize=1)
def _adapter() -> MoveItPyAdapter:
return MoveItPyAdapter()
def dispatch(request: MotionRequest) -> dict[str, Any]:
adapter = _adapter()
if request.type == "srdf.solvePose":
result = adapter.solve_pose(request)
elif request.type == "srdf.planToPose":
result = adapter.plan_to_pose(request)
else:
raise MotionProtocolError(f"Unsupported request type {request.type}")
if not isinstance(result, dict):
raise MotionProtocolError("MoveIt2 adapter result must be an object")
if result.get("ok") is False:
raise RuntimeError(str(result.get("message") or "MoveIt2 request failed"))
result.pop("ok", None)
return result
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import json
import time
from typing import Any
def _json_list(value: str, label: str) -> list[Any]:
payload = json.loads(value)
if not isinstance(payload, list):
raise ValueError(f"{label} must be a JSON array")
return payload
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m moveit2_server.joint_state_seed")
parser.add_argument("--names", required=True, help="JSON array of joint names")
parser.add_argument("--positions", required=True, help="JSON array of joint positions in ROS units")
parser.add_argument("--duration", type=float, default=8.0)
parser.add_argument("--rate", type=float, default=30.0)
args = parser.parse_args(argv)
names = [str(name) for name in _json_list(args.names, "names")]
positions = [float(position) for position in _json_list(args.positions, "positions")]
if len(names) != len(positions):
raise ValueError("names and positions must have the same length")
if args.duration <= 0 or args.rate <= 0:
raise ValueError("duration and rate must be positive")
import rclpy
from sensor_msgs.msg import JointState
rclpy.init(args=None)
node = rclpy.create_node("moveit2_server_joint_state_seed")
publisher = node.create_publisher(JointState, "/joint_states", 10)
deadline = time.monotonic() + args.duration
period = 1.0 / args.rate
try:
while time.monotonic() < deadline and rclpy.ok():
message = JointState()
message.header.stamp = node.get_clock().now().to_msg()
message.name = names
message.position = positions
publisher.publish(message)
rclpy.spin_once(node, timeout_sec=0.0)
time.sleep(period)
finally:
try:
node.destroy_node()
except Exception:
pass
try:
rclpy.shutdown()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,589 @@
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
import subprocess
import sys
from typing import Any
import xml.etree.ElementTree as ET
from moveit2_server.protocol import (
MotionProtocolError,
normalize_joint_values,
normalize_motion_target,
)
def _deg_to_rad(value: float) -> float:
return (float(value) * math.pi) / 180.0
def _rad_to_deg(value: float) -> float:
return (float(value) * 180.0) / math.pi
def _solve_command(request: Any) -> dict[str, Any]:
return request.command
def _target_end_effector_name(request: Any) -> str:
target = request.payload.get("target") if isinstance(request.payload, dict) else None
if not isinstance(target, dict):
return ""
return str(target.get("endEffector") or "").strip()
def _selected_end_effector(request: Any) -> dict[str, Any]:
command = _solve_command(request)
end_effectors = command.get("endEffectors", [])
target_name = _target_end_effector_name(request)
if target_name:
for end_effector in end_effectors:
if str(end_effector.get("name", "")).strip() == target_name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {target_name}")
for end_effector in end_effectors:
return end_effector
raise MotionProtocolError("moveit_py command endEffectors must be non-empty")
def _joint_names(request: Any) -> list[str]:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
joint_names = [
str(name).strip()
for name in end_effector.get("jointNames", command.get("jointNames", []))
if str(name).strip()
]
if not joint_names:
raise MotionProtocolError("moveit_py command jointNames must be non-empty")
return joint_names
def _end_effector(request: Any, name: str) -> dict[str, Any]:
end_effector = _selected_end_effector(request)
if str(end_effector.get("name", "")).strip() == name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {name}")
def _planning_group(request: Any) -> str:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
planning_group = str(
end_effector.get("planningGroup")
or command.get("planningGroup")
or request.command.get("planningGroup")
or ""
).strip()
if not planning_group:
raise MotionProtocolError("moveit_py command planningGroup must be non-empty")
return planning_group
def _robot_description_for_moveit(urdf_path: Path) -> str:
"""Return URDF XML with relative mesh paths rewritten as file URIs."""
try:
tree = ET.parse(urdf_path)
except ET.ParseError:
return urdf_path.read_text(encoding="utf-8")
root = tree.getroot()
changed = False
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename") or "").strip()
if not filename or "://" in filename or filename.startswith("package:"):
continue
path = Path(filename)
if not path.is_absolute():
path = urdf_path.parent / path
mesh.set("filename", path.resolve().as_uri())
changed = True
if not changed:
return urdf_path.read_text(encoding="utf-8")
return ET.tostring(root, encoding="unicode")
def _active_urdf_joints(urdf_path: Path) -> list[tuple[str, str]]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return []
joints: list[tuple[str, str]] = []
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name and joint_type != "fixed":
joints.append((name, joint_type))
return joints
def _urdf_joint_types(urdf_path: Path) -> dict[str, str]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return {}
joint_types: dict[str, str] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name:
joint_types[name] = joint_type
return joint_types
def _joint_types_for_request(request: Any) -> dict[str, str]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _urdf_joint_types(urdf_path)
if joint_types:
return joint_types
command = _solve_command(request)
fallback_names = [str(name).strip() for name in command.get("jointNames", []) if str(name).strip()]
if not fallback_names:
fallback_names = _joint_names(request)
return {name: "revolute" for name in fallback_names}
def _native_start_joint_values(request: Any, joint_types: dict[str, str] | None = None) -> dict[str, float]:
joint_types = joint_types or _joint_types_for_request(request)
payload = request.payload if isinstance(request.payload, dict) else {}
if "startJointValuesByName" in payload:
return normalize_joint_values(payload.get("startJointValuesByName"), label="startJointValuesByName")
legacy_values = normalize_joint_values(payload.get("startJointValuesByNameDeg"), label="startJointValuesByNameDeg")
return {
name: float(value) if str(joint_types.get(name) or "").strip() == "prismatic" else _deg_to_rad(value)
for name, value in legacy_values.items()
}
def _native_to_legacy_joint_value(value: float, joint_type: str) -> float:
return float(value) if str(joint_type or "").strip() == "prismatic" else _rad_to_deg(value)
def _native_to_legacy_joint_values(values: dict[str, float], joint_types: dict[str, str]) -> dict[str, float]:
return {
name: _native_to_legacy_joint_value(value, str(joint_types.get(name) or "revolute"))
for name, value in values.items()
}
def _moveit2_groups(request: Any) -> list[dict[str, Any]]:
srdf = request.context.get("srdf", {}) if isinstance(request.context, dict) else {}
groups = srdf.get("planningGroups", []) if isinstance(srdf, dict) else []
return [group for group in groups if isinstance(group, dict)]
def _supports_kdl_kinematics(group: dict[str, Any]) -> bool:
if group.get("subgroups"):
return False
return bool(group.get("jointNames") or group.get("chains"))
def _kinematics_config(request: Any) -> dict[str, Any]:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return {
str(group.get("name")): {
"kinematics_solver": "kdl_kinematics_plugin/KDLKinematicsPlugin",
"kinematics_solver_search_resolution": 0.005,
"kinematics_solver_timeout": float(ik.get("timeout") or 0.05),
"position_only_ik": bool(ik.get("positionOnly", True)),
}
for group in _moveit2_groups(request)
if str(group.get("name") or "").strip() and _supports_kdl_kinematics(group)
}
def _planning_pipeline_config(request: Any) -> dict[str, Any]:
planner = request.command.get("planner", {}) if isinstance(request.command, dict) else {}
pipeline = str(planner.get("pipeline") or "ompl")
planner_id = str(planner.get("plannerId") or "RRTConnectkConfigDefault")
planning_time = float(planner.get("planningTime") or 1.0)
def request_params() -> dict[str, Any]:
return {
"planning_attempts": int(request.command.get("ik", {}).get("attempts") or 1),
"planning_pipeline": pipeline,
"planner_id": planner_id,
"planning_time": planning_time,
"max_velocity_scaling_factor": float(planner.get("maxVelocityScalingFactor") or 1.0),
"max_acceleration_scaling_factor": float(planner.get("maxAccelerationScalingFactor") or 1.0),
}
return {
"planning_pipelines": {
"pipeline_names": [pipeline],
},
pipeline: {
"planning_plugin": "ompl_interface/OMPLPlanner",
"planning_plugins": ["ompl_interface/OMPLPlanner"],
"request_adapters": [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
],
"start_state_max_bounds_error": 0.1,
},
"plan_request_params": request_params(),
"ompl_rrtc": {
"plan_request_params": request_params(),
},
}
def _joint_state_seed(request: Any) -> tuple[list[str], list[float]]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _joint_types_for_request(request)
joint_values = _native_start_joint_values(request, joint_types)
joints = _active_urdf_joints(urdf_path)
if not joints:
joints = [(name, "revolute") for name in _joint_names(request)]
names: list[str] = []
positions: list[float] = []
for name, joint_type in joints:
names.append(name)
del joint_type
positions.append(float(joint_values.get(name, 0.0)))
return names, positions
class _JointStateSeeder:
def __init__(self, request: Any, *, duration_sec: float = 8.0, rate_hz: float = 30.0) -> None:
self._request = request
self._duration_sec = duration_sec
self._rate_hz = rate_hz
self._process: subprocess.Popen[bytes] | None = None
def start(self) -> None:
names, positions = _joint_state_seed(self._request)
if not names:
return
self._process = subprocess.Popen(
[
sys.executable,
"-m",
"moveit2_server.joint_state_seed",
"--names",
json.dumps(names),
"--positions",
json.dumps(positions),
"--duration",
str(self._duration_sec),
"--rate",
str(self._rate_hz),
],
stdout=subprocess.DEVNULL,
)
def stop(self) -> None:
if not self._process or self._process.poll() is not None:
return
self._process.terminate()
try:
self._process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=1.0)
class MoveItPyAdapter:
"""MoveIt 2 adapter used by the local ROS 2 MoveIt2 server."""
def __init__(self) -> None:
self._moveit_by_key: dict[str, Any] = {}
def _config_dict(self, request: Any) -> dict[str, Any]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
srdf_path = Path(str(request.context.get("srdfPath") or ""))
if not urdf_path.is_file():
raise RuntimeError(f"URDF file does not exist: {urdf_path}")
config: dict[str, Any] = {
"robot_description": _robot_description_for_moveit(urdf_path),
"robot_description_kinematics": _kinematics_config(request),
}
if srdf_path.is_file():
config["robot_description_semantic"] = srdf_path.read_text(encoding="utf-8")
config.update(_planning_pipeline_config(request))
config["planning_scene_monitor_options"] = {
"name": "planning_scene_monitor",
"robot_description": "robot_description",
"joint_state_topic": "/joint_states",
"attached_collision_object_topic": "/moveit_cpp/planning_scene_monitor",
"publish_planning_scene_topic": "/moveit_cpp/publish_planning_scene",
"monitored_planning_scene_topic": "/moveit_cpp/monitored_planning_scene",
"wait_for_initial_state_timeout": 5.0,
}
ompl_config = config.setdefault("ompl", {})
if isinstance(ompl_config, dict):
ompl_config.setdefault("planning_plugin", "ompl_interface/OMPLPlanner")
ompl_config.setdefault("planning_plugins", ["ompl_interface/OMPLPlanner"])
ompl_config.setdefault("request_adapters", [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
])
ompl_config.setdefault("start_state_max_bounds_error", 0.1)
planning_scene_monitor_options = config.setdefault("planning_scene_monitor_options", {})
if isinstance(planning_scene_monitor_options, dict):
current_timeout = float(planning_scene_monitor_options.get("wait_for_initial_state_timeout") or 0.0)
planning_scene_monitor_options["wait_for_initial_state_timeout"] = max(current_timeout, 5.0)
return config
def _moveit(self, request: Any) -> Any:
try:
import rclpy
from moveit.planning import MoveItPy
except ImportError as exc:
raise RuntimeError(
"moveit_py adapter requires a ROS 2 environment with rclpy and moveit_py. "
f"Current interpreter: {sys.executable}. Start moveit2_server from a RoboStack/ROS shell."
) from exc
cache_key = str(request.context.get("modelAssetHash") or request.context.get("urdfPath") or "")
if cache_key in self._moveit_by_key:
return self._moveit_by_key[cache_key]
if not rclpy.ok():
rclpy.init(args=None)
node_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:10] if cache_key else "default"
config = self._config_dict(request)
joint_state_seeder = _JointStateSeeder(request)
joint_state_seeder.start()
try:
try:
moveit = MoveItPy(
node_name=f"moveit2_server_{node_hash}",
config_dict=config,
)
except TypeError:
# Older bindings do not expose config_dict; they require equivalent
# ROS parameters to be supplied by the launch environment.
moveit = MoveItPy(node_name=f"moveit2_server_{node_hash}")
finally:
joint_state_seeder.stop()
self._moveit_by_key[cache_key] = moveit
return moveit
def _planning_component(self, request: Any, planning_group: str) -> Any:
return self._moveit(request).get_planning_component(planning_group)
def _robot_state(self, request: Any) -> Any:
try:
from moveit.core.robot_state import RobotState
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import RobotState for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
robot_state = RobotState(self._moveit(request).get_robot_model())
joint_values = _native_start_joint_values(request)
joint_names = _joint_names(request)
positions = [float(joint_values.get(name, 0.0)) for name in joint_names]
planning_group = _planning_group(request)
if hasattr(robot_state, "set_joint_group_positions"):
robot_state.set_joint_group_positions(planning_group, positions)
elif hasattr(robot_state, "set_joint_positions"):
robot_state.set_joint_positions(joint_names, positions)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint setter")
return robot_state
def _pose_stamped(self, request: Any) -> tuple[Any, dict[str, Any]]:
try:
from geometry_msgs.msg import PoseStamped
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import geometry_msgs.msg.PoseStamped for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
target = normalize_motion_target(request.payload)
end_effector = _end_effector(request, target["endEffector"])
pose = PoseStamped()
pose.header.frame_id = target["frame"]
pose.pose.position.x = target["xyz"][0]
pose.pose.position.y = target["xyz"][1]
pose.pose.position.z = target["xyz"][2]
quat_xyzw = target.get("quat_xyzw") or (0.0, 0.0, 0.0, 1.0)
pose.pose.orientation.x = quat_xyzw[0]
pose.pose.orientation.y = quat_xyzw[1]
pose.pose.orientation.z = quat_xyzw[2]
pose.pose.orientation.w = quat_xyzw[3]
return pose, end_effector
def _position_only_ik(self, request: Any) -> bool:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return bool(ik.get("positionOnly", True))
def _position_goal_constraints(self, target_pose: Any, end_effector: dict[str, Any]) -> list[Any]:
try:
from geometry_msgs.msg import Pose
from moveit_msgs.msg import Constraints, PositionConstraint
from shape_msgs.msg import SolidPrimitive
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import MoveIt position constraint messages for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
sphere = SolidPrimitive()
sphere.type = SolidPrimitive.SPHERE
sphere.dimensions = [float(end_effector.get("positionTolerance") or 0.002)]
center = Pose()
center.orientation.w = 1.0
center.position.x = target_pose.pose.position.x
center.position.y = target_pose.pose.position.y
center.position.z = target_pose.pose.position.z
position_constraint = PositionConstraint()
position_constraint.header.frame_id = target_pose.header.frame_id
position_constraint.link_name = str(end_effector.get("link") or "")
position_constraint.constraint_region.primitives.append(sphere)
position_constraint.constraint_region.primitive_poses.append(center)
position_constraint.weight = 1.0
constraints = Constraints()
constraints.name = f"{position_constraint.link_name}_position_goal"
constraints.position_constraints.append(position_constraint)
return [constraints]
def _joint_values_from_state(self, robot_state: Any, request: Any) -> dict[str, float]:
joint_names = _joint_names(request)
planning_group = _planning_group(request)
if hasattr(robot_state, "get_joint_group_positions"):
values = robot_state.get_joint_group_positions(planning_group)
elif hasattr(robot_state, "get_joint_positions"):
values = robot_state.get_joint_positions(joint_names)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint getter")
return {
joint_name: float(values[index])
for index, joint_name in enumerate(joint_names)
if index < len(values)
}
def solve_pose(self, request: Any) -> dict[str, Any]:
robot_state = self._robot_state(request)
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
if not hasattr(robot_state, "set_from_ik"):
raise RuntimeError("moveit_py RobotState does not expose set_from_ik")
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
timeout = float(ik.get("timeout") or 0.05)
attempts = int(ik.get("attempts") or 1)
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout, attempts)
except TypeError:
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout)
except TypeError:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"))
if not ok:
return {
"ok": False,
"message": "MoveIt did not find a pose solution",
}
joint_values = self._joint_values_from_state(robot_state, request)
joint_types = _joint_types_for_request(request)
return {
"jointValuesByName": joint_values,
"jointValuesByNameDeg": _native_to_legacy_joint_values(joint_values, joint_types),
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
def _serialize_trajectory(self, trajectory: Any, joint_names: list[str], request: Any) -> dict[str, Any]:
if hasattr(trajectory, "get_robot_trajectory_msg"):
trajectory = trajectory.get_robot_trajectory_msg()
joint_trajectory = getattr(trajectory, "joint_trajectory", trajectory)
source_joint_names = list(getattr(joint_trajectory, "joint_names", []) or joint_names)
joint_types = _joint_types_for_request(request)
points = []
for point in getattr(joint_trajectory, "points", []) or []:
duration = getattr(point, "time_from_start", None)
seconds = float(getattr(duration, "sec", 0)) + (float(getattr(duration, "nanosec", 0)) / 1_000_000_000)
positions_by_name = {
name: float(point.positions[index])
for index, name in enumerate(source_joint_names)
if index < len(getattr(point, "positions", []))
}
native_positions = [positions_by_name.get(name, 0.0) for name in joint_names]
legacy_positions = [
_native_to_legacy_joint_value(positions_by_name.get(name, 0.0), str(joint_types.get(name) or "revolute"))
for name in joint_names
]
points.append({
"timeFromStartSec": seconds,
"positions": native_positions,
"positionsDeg": legacy_positions,
})
if len(points) > 1 and all(point["timeFromStartSec"] <= 0.0 for point in points):
total_joint_delta = 0.0
for previous, current in zip(points, points[1:], strict=False):
total_joint_delta += sum(
abs(current["positionsDeg"][index] - previous["positionsDeg"][index])
for index in range(min(len(previous["positionsDeg"]), len(current["positionsDeg"])))
)
total_duration = min(max(total_joint_delta / 240.0, 0.75), 4.0)
step_duration = total_duration / float(len(points) - 1)
for index, point in enumerate(points):
point["timeFromStartSec"] = index * step_duration
return {
"jointNames": joint_names,
"points": points,
}
def plan_to_pose(self, request: Any) -> dict[str, Any]:
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
planning_component = self._planning_component(request, planning_group)
start_state = self._robot_state(request)
planning_component.set_start_state(robot_state=start_state)
if self._position_only_ik(request):
try:
planning_component.set_goal_state(
motion_plan_constraints=self._position_goal_constraints(target_pose, end_effector),
)
except TypeError:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
else:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
plan_result = planning_component.plan()
if not plan_result:
return {
"ok": False,
"message": "MoveIt planning did not find a trajectory",
}
joint_names = _joint_names(request)
trajectory = self._serialize_trajectory(plan_result.trajectory, joint_names, request)
final_joint_values: dict[str, float] = {}
final_joint_values_legacy: dict[str, float] = {}
if trajectory["points"]:
final_joint_values = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positions"],
strict=False,
))
final_joint_values_legacy = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positionsDeg"],
strict=False,
))
return {
"jointValuesByName": final_joint_values,
"jointValuesByNameDeg": final_joint_values_legacy,
"trajectory": trajectory,
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
@@ -0,0 +1,222 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Any
SUPPORTED_REQUEST_TYPES = {"srdf.solvePose", "srdf.planToPose"}
SUPPORTED_PROTOCOL_VERSION = 1
_ABSOLUTE_PATH_RE = re.compile(r"(?<![\w.])/(?:[^\s'\"<>:,;(){}[\]]+/)+[^\s'\"<>:,;(){}[\]]+")
class MotionProtocolError(ValueError):
"""Raised when a MoveIt2 server request is malformed."""
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _string(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _number(value: Any, label: str) -> float:
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a finite number") from exc
if numeric != numeric or numeric in (float("inf"), float("-inf")):
raise MotionProtocolError(f"{label} must be a finite number")
return numeric
def normalize_xyz(value: Any, label: str = "target.xyz") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def normalize_quat_xyzw(value: Any, label: str = "target.quat_xyzw") -> tuple[float, float, float, float]:
if not isinstance(value, list) or len(value) != 4:
raise MotionProtocolError(f"{label} must be a 4-number array")
quat = (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
_number(value[3], f"{label}[3]"),
)
norm = math.sqrt(sum(component * component for component in quat))
if norm <= 1e-12:
raise MotionProtocolError(f"{label} must not be zero length")
return tuple(component / norm for component in quat)
def normalize_rpy(value: Any, label: str = "target.rpy") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def _quat_xyzw_from_rpy(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
return normalize_quat_xyzw([
(sr * cp * cy) - (cr * sp * sy),
(cr * sp * cy) + (sr * cp * sy),
(cr * cp * sy) - (sr * sp * cy),
(cr * cp * cy) + (sr * sp * sy),
])
def normalize_joint_values(value: Any, label: str = "startJointValuesByNameDeg") -> dict[str, float]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
normalized: dict[str, float] = {}
for raw_name, raw_value in value.items():
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty joint names")
normalized[name] = _number(raw_value, f"{label}.{name}")
return normalized
def normalize_protocol_version(value: Any) -> int:
if value is None:
return SUPPORTED_PROTOCOL_VERSION
try:
version = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError("protocolVersion must be an integer") from exc
if version != SUPPORTED_PROTOCOL_VERSION:
raise MotionProtocolError(f"Unsupported protocolVersion {version}")
return version
@dataclass(frozen=True)
class WireMessage:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
@dataclass(frozen=True)
class MotionRequest:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
context: dict[str, Any]
command: dict[str, Any]
def normalize_wire_message(message: Any) -> WireMessage:
if not _plain_object(message):
raise MotionProtocolError("MoveIt2 request must be an object")
request_id = _string(message.get("id"), "id")
request_type = _string(message.get("type"), "type")
protocol_version = normalize_protocol_version(message.get("protocolVersion"))
if request_type not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {request_type}")
payload = message.get("payload")
if not _plain_object(payload):
raise MotionProtocolError("payload must be an object")
return WireMessage(
id=request_id,
type=request_type,
protocol_version=protocol_version,
payload=payload,
)
def normalize_request(message: Any, *, context: dict[str, Any]) -> MotionRequest:
wire = normalize_wire_message(message)
if not _plain_object(context):
raise MotionProtocolError("request context must be an object")
command = context.get("command")
if not _plain_object(command):
raise MotionProtocolError("request context command must be an object")
return MotionRequest(
id=wire.id,
type=wire.type,
protocol_version=wire.protocol_version,
payload=wire.payload,
context=context,
command=command,
)
def normalize_motion_target(payload: dict[str, Any]) -> dict[str, Any]:
target = payload.get("target")
if not _plain_object(target):
raise MotionProtocolError("target must be an object")
has_quat = "quat_xyzw" in target
has_rpy = "rpy" in target
if has_quat and has_rpy:
raise MotionProtocolError("target must include exactly one of quat_xyzw or rpy")
quat_xyzw: tuple[float, float, float, float] | None = None
orientation_mode = "position_only"
if has_quat:
quat_xyzw = normalize_quat_xyzw(target.get("quat_xyzw"))
orientation_mode = "quat_xyzw"
elif has_rpy:
quat_xyzw = _quat_xyzw_from_rpy(*normalize_rpy(target.get("rpy")))
orientation_mode = "rpy"
return {
"endEffector": _string(target.get("endEffector"), "target.endEffector"),
"frame": _string(target.get("frame"), "target.frame"),
"xyz": normalize_xyz(target.get("xyz")),
"quat_xyzw": quat_xyzw,
"orientationMode": orientation_mode,
}
def success_response(request_id: str, result: dict[str, Any]) -> dict[str, Any]:
return {
"id": request_id,
"ok": True,
"result": result,
}
def _sanitize_error_message(message: str) -> str:
def replace(match: re.Match[str]) -> str:
path = match.group(0)
basename = path.rstrip("/").rsplit("/", 1)[-1]
return f".../{basename}" if basename else "<path>"
return _ABSOLUTE_PATH_RE.sub(replace, message)
def error_response(request_id: str, error: BaseException, *, debug: bool = False) -> dict[str, Any]:
message = str(error)
if not debug:
message = _sanitize_error_message(message)
return {
"id": request_id,
"ok": False,
"error": {
"code": error.__class__.__name__,
"message": message,
},
}
@@ -0,0 +1,108 @@
from __future__ import annotations
import argparse
import asyncio
import importlib
import json
import os
from pathlib import Path
from typing import Any
from moveit2_server.context import DEFAULT_CAD_DIRECTORY, build_moveit2_context
from moveit2_server.dispatcher import dispatch
from moveit2_server.protocol import error_response, normalize_request, normalize_wire_message, success_response
def _json_dumps(message: dict[str, Any]) -> str:
return json.dumps(message, separators=(",", ":"))
async def handle_message(raw_message: str, *, repo_root: Path) -> str:
request_id = ""
debug_errors = os.environ.get("MOVEIT2_SERVER_DEBUG_ERRORS", "").strip().lower() in {"1", "true", "yes", "on"}
try:
message = json.loads(raw_message)
wire = normalize_wire_message(message)
request_id = wire.id
payload_dir = wire.payload.get("dir", DEFAULT_CAD_DIRECTORY)
context = build_moveit2_context(
repo_root=repo_root,
dir=payload_dir,
file=wire.payload.get("file"),
type=wire.type,
payload=wire.payload,
)
request = normalize_request(message, context=context)
return _json_dumps(success_response(request.id, dispatch(request)))
except Exception as exc:
return _json_dumps(error_response(request_id, exc, debug=debug_errors))
async def handle_client(websocket: Any, path: str | None = None, *, repo_root: Path) -> None:
async for raw_message in websocket:
await websocket.send(await handle_message(str(raw_message), repo_root=repo_root))
async def run_server(*, repo_root: Path, host: str, port: int) -> None:
try:
from websockets.asyncio.server import serve
except ImportError:
from websockets import serve
async with serve(lambda websocket, path=None: handle_client(websocket, path, repo_root=repo_root), host, port):
print(f"moveit2_server listening on ws://{host}:{port}/ws", flush=True)
await asyncio.Future()
def _check_import(module_name: str, attribute_path: str | None = None) -> str:
module = importlib.import_module(module_name)
current: Any = module
if attribute_path:
for attribute in attribute_path.split("."):
current = getattr(current, attribute)
return str(getattr(module, "__file__", "built-in"))
def check_environment(*, repo_root: Path) -> dict[str, Any]:
checks = {
"repoRoot": str(repo_root),
"rmwImplementation": os.environ.get("RMW_IMPLEMENTATION", ""),
"imports": {
"websockets": _check_import("websockets"),
"rclpy": _check_import("rclpy"),
"geometry_msgs.msg.PoseStamped": _check_import("geometry_msgs.msg", "PoseStamped"),
"moveit.planning.MoveItPy": _check_import("moveit.planning", "MoveItPy"),
},
}
return checks
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="moveit2_server")
parser.add_argument(
"--repo-root",
default=str(Path.cwd()),
help="Repository root that contains the CAD scan directory.",
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--check",
action="store_true",
help="Verify required Python, ROS 2, MoveIt, and websocket imports, then exit.",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
repo_root = Path(args.repo_root).resolve()
if args.check:
print(json.dumps(check_environment(repo_root=repo_root), indent=2, sort_keys=True))
return 0
asyncio.run(run_server(repo_root=repo_root, host=args.host, port=args.port))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,261 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from pathlib import Path
from typing import Literal
import xml.etree.ElementTree as ET
SRDF_SUFFIX = ".srdf"
SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf"
LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer"
class SrdfSourceError(ValueError):
pass
@dataclass(frozen=True)
class SrdfChain:
base_link: str
tip_link: str
@dataclass(frozen=True)
class SrdfPlanningGroup:
name: str
joint_names: tuple[str, ...]
link_names: tuple[str, ...]
chains: tuple[SrdfChain, ...]
subgroups: tuple[str, ...]
@dataclass(frozen=True)
class SrdfEndEffector:
name: str
parent_link: str
group: str
parent_group: str
@dataclass(frozen=True)
class SrdfGroupState:
name: str
group: str
joint_values_by_name: dict[str, float]
@property
def joint_values_by_name_rad(self) -> dict[str, float]:
return self.joint_values_by_name
@dataclass(frozen=True)
class SrdfDisabledCollisionPair:
link1: str
link2: str
reason: str
source: Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"] = "manual"
@dataclass(frozen=True)
class SrdfSource:
file_ref: str
source_path: Path
robot_name: str
urdf_ref: str
planning_groups: tuple[SrdfPlanningGroup, ...]
end_effectors: tuple[SrdfEndEffector, ...]
group_states: tuple[SrdfGroupState, ...]
disabled_collision_pairs: tuple[SrdfDisabledCollisionPair, ...]
def read_srdf_source(srdf_path: Path) -> SrdfSource:
resolved_path = srdf_path.resolve()
if resolved_path.suffix.lower() != SRDF_SUFFIX:
raise SrdfSourceError(f"{resolved_path} is not an SRDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise SrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SRDF XML") from exc
return parse_srdf_root(root, source_path=resolved_path)
def parse_srdf_root(root: ET.Element, *, source_path: Path) -> SrdfSource:
if root.tag != "robot":
raise SrdfSourceError(f"{_relative_to_repo(source_path)} root element must be <robot>")
robot_name = str(root.attrib.get("name") or "").strip()
if not robot_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} robot name is required")
planning_groups: list[SrdfPlanningGroup] = []
group_names: list[str] = []
for group_element in root.findall("group"):
name = str(group_element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group name is required")
group_names.append(name)
planning_groups.append(
SrdfPlanningGroup(
name=name,
joint_names=_child_names(group_element, "joint"),
link_names=_child_names(group_element, "link"),
chains=tuple(
SrdfChain(
base_link=str(chain.attrib.get("base_link") or "").strip(),
tip_link=str(chain.attrib.get("tip_link") or "").strip(),
)
for chain in group_element.findall("chain")
),
subgroups=_child_names(group_element, "group"),
)
)
_raise_on_duplicates(group_names, source_path=source_path, label="group")
end_effectors: list[SrdfEndEffector] = []
end_effector_names: list[str] = []
for element in root.findall("end_effector"):
name = str(element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} end_effector name is required")
end_effector_names.append(name)
end_effectors.append(
SrdfEndEffector(
name=name,
parent_link=str(element.attrib.get("parent_link") or "").strip(),
group=str(element.attrib.get("group") or "").strip(),
parent_group=str(element.attrib.get("parent_group") or "").strip(),
)
)
_raise_on_duplicates(end_effector_names, source_path=source_path, label="end_effector")
group_states: list[SrdfGroupState] = []
group_state_keys: list[str] = []
for element in root.findall("group_state"):
name = str(element.attrib.get("name") or "").strip()
group = str(element.attrib.get("group") or "").strip()
if not name or not group:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state requires name and group")
group_state_keys.append(f"{group}/{name}")
joint_values: dict[str, float] = {}
joint_names: list[str] = []
for joint_element in element.findall("joint"):
joint_name = str(joint_element.attrib.get("name") or "").strip()
if not joint_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state joint name is required")
joint_names.append(joint_name)
try:
joint_value = float(joint_element.attrib.get("value"))
except (TypeError, ValueError) as exc:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be numeric"
) from exc
if not isfinite(joint_value):
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be finite"
)
joint_values[joint_name] = joint_value
_raise_on_duplicates(joint_names, source_path=source_path, label=f"group_state {name!r} joint")
group_states.append(SrdfGroupState(name=name, group=group, joint_values_by_name=joint_values))
_raise_on_duplicates(group_state_keys, source_path=source_path, label="group_state")
disabled_pairs: list[SrdfDisabledCollisionPair] = []
pair_keys: list[str] = []
for element in root.findall("disable_collisions"):
link1 = str(element.attrib.get("link1") or "").strip()
link2 = str(element.attrib.get("link2") or "").strip()
if not link1 or not link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions requires link1 and link2")
if link1 == link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions cannot repeat the same link")
reason = str(element.attrib.get("reason") or "").strip()
if not reason:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} disable_collisions for {link1!r}/{link2!r} requires a reason"
)
pair_key = "/".join(sorted((link1, link2)))
pair_keys.append(pair_key)
disabled_pairs.append(
SrdfDisabledCollisionPair(
link1=link1,
link2=link2,
reason=reason,
source=_disabled_collision_source(reason),
)
)
_raise_on_duplicates(pair_keys, source_path=source_path, label="disable_collisions")
return SrdfSource(
file_ref=_relative_to_repo(source_path.resolve()),
source_path=source_path.resolve(),
robot_name=robot_name,
urdf_ref=_linked_urdf_ref(root),
planning_groups=tuple(planning_groups),
end_effectors=tuple(end_effectors),
group_states=tuple(group_states),
disabled_collision_pairs=tuple(disabled_pairs),
)
def _local_name(tag: str) -> str:
return str(tag or "").rsplit("}", 1)[-1].split(":", 1)[-1]
def _linked_urdf_ref(root: ET.Element) -> str:
for child in list(root):
if _local_name(child.tag) != "urdf":
continue
tag = str(child.tag or "")
if (
tag.startswith(f"{{{SRDF_METADATA_NAMESPACE}}}")
or tag.startswith(f"{{{LEGACY_EXPLORER_NAMESPACE}}}")
or tag.startswith("tcad:")
or tag.startswith("explorer:")
):
return str(child.attrib.get("path") or "").strip()
return ""
def _child_names(element: ET.Element, tag: str) -> tuple[str, ...]:
names: list[str] = []
for child in element.findall(tag):
name = str(child.attrib.get("name") or "").strip()
if name:
names.append(name)
return tuple(names)
def _raise_on_duplicates(names: list[str], *, source_path: Path, label: str) -> None:
seen = set()
duplicates = []
for name in names:
if name in seen:
duplicates.append(name)
seen.add(name)
if duplicates:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} has duplicate {label} name(s): {sorted(set(duplicates))!r}"
)
def _disabled_collision_source(
reason: str,
) -> Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"]:
normalized = str(reason or "").strip().lower()
if "adjacent" in normalized:
return "adjacent"
if any(token in normalized for token in ("never", "always", "sample", "default")):
return "sampled"
if "setup" in normalized or "assistant" in normalized:
return "setup_assistant"
if "assum" in normalized:
return "assumed"
return "manual"
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "moveit2_server"
version = "0.3.9"
description = "Local MoveIt2 websocket server for SRDF solve and planning requests."
requires-python = ">=3.12"
dependencies = [
"websockets>=12",
]
[project.scripts]
moveit2_server = "moveit2_server.server:main"
[tool.setuptools]
packages = ["moveit2_server"]
[tool.setuptools.package-dir]
moveit2_server = "moveit2_server"
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
exec "${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" \
python -m moveit2_server.server \
--repo-root "${REPO_ROOT}" \
--host "${MOVEIT2_SERVER_HOST:-127.0.0.1}" \
--port "${MOVEIT2_SERVER_PORT:-8765}" \
"$@"
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=conda-common.sh
source "${SCRIPT_DIR}/conda-common.sh"
REPO_ROOT="$(find_repo_root)"
CONDA="$(find_conda)"
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
"${CONDA}" env update --file "${SCRIPT_DIR}/environment.yml" --prune
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m pip install -e "${SCRIPT_DIR}"
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
@@ -0,0 +1,13 @@
{
"name": "cad-viewer-runtime",
"private": true,
"type": "module",
"version": "0.3.9",
"scripts": {
"serve": "node backend/server.mjs",
"start": "node backend/server.mjs",
"moveit2:setup": "moveit2_server/setup.sh",
"moveit2:check": "moveit2_server/check-moveit2-server.sh",
"moveit2:serve": "moveit2_server/run-moveit2-server.sh"
}
}
@@ -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);
@@ -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;
}
@@ -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
}
};
}
@@ -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";

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