first commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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/**
|
||||
+28692
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
text-to-cad-main/plugins/cad/skills/cad-viewer/scripts/viewer/dist/assets/GLTFLoader-B3ztFMBW.js.map
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -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
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
Vendored
+2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+916
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -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
|
||||
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+3683
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+9
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+4053
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+52
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
+39
@@ -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>
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from pathlib import Path
|
||||
|
||||
__path__.append(str(Path(__file__).resolve().parent / "moveit2_server"))
|
||||
text-to-cad-main/plugins/cad/skills/cad-viewer/scripts/viewer/moveit2_server/check-moveit2-server.sh
Executable
+14
@@ -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}"
|
||||
Executable
+53
@@ -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
|
||||
}
|
||||
+14
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
"""MoveIt2 websocket server for SRDF workflows."""
|
||||
+904
@@ -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,
|
||||
}
|
||||
+29
@@ -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
|
||||
+61
@@ -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())
|
||||
+589
@@ -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},
|
||||
}
|
||||
+222
@@ -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,
|
||||
},
|
||||
}
|
||||
+108
@@ -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())
|
||||
+261
@@ -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()
|
||||
+21
@@ -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"
|
||||
Executable
+19
@@ -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}" \
|
||||
"$@"
|
||||
+16
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "cadjs",
|
||||
"version": "0.3.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cadjs",
|
||||
"version": "0.3.9",
|
||||
"dependencies": {
|
||||
"gifenc": "^1.0.3",
|
||||
"three": "0.160.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gifenc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz",
|
||||
"integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.160.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.160.0.tgz",
|
||||
"integrity": "sha512-DLU8lc0zNIPkM7rH5/e1Ks1Z8tWCGRq6g8mPowdDJpw1CFBJMU7UoJjC6PefXW7z//SSl0b2+GCw14LB+uDhng==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(packageRoot, "..", "..");
|
||||
const sourceRoot = path.join(packageRoot, "src");
|
||||
|
||||
function collectTests(dir, tests = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
collectTests(entryPath, tests);
|
||||
} else if (/\.test\.[cm]?js$/u.test(entry.name)) {
|
||||
tests.push(entryPath);
|
||||
}
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
const requestedTests = process.argv.slice(2).map((testPath) => path.resolve(packageRoot, testPath));
|
||||
const tests = (requestedTests.length ? requestedTests : collectTests(sourceRoot)).sort();
|
||||
if (!tests.length) {
|
||||
console.error("No cadjs tests found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [
|
||||
"--test",
|
||||
"--experimental-default-type=module",
|
||||
...tests,
|
||||
], {
|
||||
cwd: packageRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...(fs.existsSync(path.join(repoRoot, ".venv", "bin", "python"))
|
||||
? { CAD_PYTHON: path.join(repoRoot, ".venv", "bin", "python") }
|
||||
: {}),
|
||||
...(fs.existsSync(path.join(repoRoot, "packages", "cadpy", "src"))
|
||||
? { CAD_PYTHONPATH: path.join(repoRoot, "packages", "cadpy", "src") }
|
||||
: {}),
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
+2203
File diff suppressed because it is too large
Load Diff
+337
@@ -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
|
||||
);
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
+381
@@ -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;
|
||||
}
|
||||
+29
@@ -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}`
|
||||
}))
|
||||
};
|
||||
}
|
||||
+232
@@ -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;
|
||||
}
|
||||
+29
@@ -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>
|
||||
+661
@@ -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;
|
||||
}
|
||||
+1055
File diff suppressed because it is too large
Load Diff
+223
@@ -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;
|
||||
}
|
||||
+573
@@ -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");
|
||||
}
|
||||
+224
@@ -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
|
||||
};
|
||||
}
|
||||
+512
@@ -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));
|
||||
}
|
||||
+323
@@ -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;
|
||||
}
|
||||
}
|
||||
+173
@@ -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
|
||||
};
|
||||
}
|
||||
+43
@@ -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`);
|
||||
}
|
||||
+103
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
+1688
File diff suppressed because it is too large
Load Diff
+254
@@ -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
|
||||
);
|
||||
}
|
||||
+434
@@ -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);
|
||||
}
|
||||
+39
@@ -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));
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export * from "./common/cadScene.js";
|
||||
export * from "./common/renderModel.js";
|
||||
export * from "./common/source.js";
|
||||
export * from "./common/themeSettings.js";
|
||||
+531
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+174
@@ -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(",")}`;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "../common/displaySettings.js";
|
||||
+1278
File diff suppressed because it is too large
Load Diff
+504
@@ -0,0 +1,504 @@
|
||||
const DXF_RENDER_SCHEMA_VERSION = 1;
|
||||
const SUPPORTED_ENTITY_TYPES = new Set(["LINE", "ARC", "CIRCLE", "LWPOLYLINE"]);
|
||||
const ANGLE_EPSILON = 1e-9;
|
||||
|
||||
function toFiniteNumber(value, fallback = 0) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : fallback;
|
||||
}
|
||||
|
||||
function semanticKindForLayer(layerName) {
|
||||
return String(layerName || "").trim().toLowerCase().includes("bend") ? "bend" : "cut";
|
||||
}
|
||||
|
||||
function normalizeLayerName(value) {
|
||||
const text = String(value || "").trim();
|
||||
return text || "0";
|
||||
}
|
||||
|
||||
function normalizeAngle(angleDeg) {
|
||||
const value = angleDeg % 360;
|
||||
return value < 0 ? value + 360 : value;
|
||||
}
|
||||
|
||||
function angleInSweep(angleDeg, startAngleDeg, sweepAngleDeg) {
|
||||
const absSweepAngleDeg = Math.abs(sweepAngleDeg);
|
||||
if (absSweepAngleDeg >= 360 - ANGLE_EPSILON) {
|
||||
return true;
|
||||
}
|
||||
if (sweepAngleDeg >= 0) {
|
||||
const normalizedDelta = (normalizeAngle(angleDeg) - normalizeAngle(startAngleDeg) + 360) % 360;
|
||||
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
|
||||
}
|
||||
const normalizedDelta = (normalizeAngle(startAngleDeg) - normalizeAngle(angleDeg) + 360) % 360;
|
||||
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
|
||||
}
|
||||
|
||||
function pointOnCircle(center, radius, angleDeg) {
|
||||
const radians = (angleDeg * Math.PI) / 180;
|
||||
return [
|
||||
center[0] + radius * Math.cos(radians),
|
||||
center[1] + radius * Math.sin(radians)
|
||||
];
|
||||
}
|
||||
|
||||
function arcExtremaPoints(arc) {
|
||||
const endAngleDeg = arc.startAngleDeg + arc.sweepAngleDeg;
|
||||
const points = [
|
||||
pointOnCircle(arc.center, arc.radius, arc.startAngleDeg),
|
||||
pointOnCircle(arc.center, arc.radius, endAngleDeg)
|
||||
];
|
||||
for (const candidateAngle of [0, 90, 180, 270]) {
|
||||
if (angleInSweep(candidateAngle, arc.startAngleDeg, arc.sweepAngleDeg)) {
|
||||
points.push(pointOnCircle(arc.center, arc.radius, candidateAngle));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function expandBounds(current, nextBounds) {
|
||||
if (!current) {
|
||||
return nextBounds;
|
||||
}
|
||||
return {
|
||||
minX: Math.min(current.minX, nextBounds.minX),
|
||||
minY: Math.min(current.minY, nextBounds.minY),
|
||||
maxX: Math.max(current.maxX, nextBounds.maxX),
|
||||
maxY: Math.max(current.maxY, nextBounds.maxY)
|
||||
};
|
||||
}
|
||||
|
||||
function lineBounds(line) {
|
||||
return {
|
||||
minX: Math.min(line.start[0], line.end[0]),
|
||||
minY: Math.min(line.start[1], line.end[1]),
|
||||
maxX: Math.max(line.start[0], line.end[0]),
|
||||
maxY: Math.max(line.start[1], line.end[1])
|
||||
};
|
||||
}
|
||||
|
||||
function circleBounds(circle) {
|
||||
return {
|
||||
minX: circle.center[0] - circle.radius,
|
||||
minY: circle.center[1] - circle.radius,
|
||||
maxX: circle.center[0] + circle.radius,
|
||||
maxY: circle.center[1] + circle.radius
|
||||
};
|
||||
}
|
||||
|
||||
function arcBounds(arc) {
|
||||
const points = arcExtremaPoints(arc);
|
||||
const xs = points.map((point) => point[0]);
|
||||
const ys = points.map((point) => point[1]);
|
||||
return {
|
||||
minX: Math.min(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxX: Math.max(...xs),
|
||||
maxY: Math.max(...ys)
|
||||
};
|
||||
}
|
||||
|
||||
function screenPoint(point, { minX, maxY }) {
|
||||
return [point[0] - minX, maxY - point[1]];
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
const rounded = Math.round(toFiniteNumber(value) * 1_000_000) / 1_000_000;
|
||||
return Math.abs(rounded) < ANGLE_EPSILON ? 0 : rounded;
|
||||
}
|
||||
|
||||
function parseRecordPairs(text) {
|
||||
const normalized = String(text || "").replace(/\r\n?/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
if (lines.length && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
if (lines.length % 2 !== 0) {
|
||||
throw new Error("DXF group code stream is malformed");
|
||||
}
|
||||
const pairs = [];
|
||||
for (let index = 0; index < lines.length; index += 2) {
|
||||
const code = Number.parseInt(lines[index].trim(), 10);
|
||||
if (!Number.isFinite(code)) {
|
||||
throw new Error(`Invalid DXF group code: ${JSON.stringify(lines[index])}`);
|
||||
}
|
||||
pairs.push({
|
||||
code,
|
||||
value: lines[index + 1] ?? ""
|
||||
});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function parseHeader(records) {
|
||||
let sourceUnits = 0;
|
||||
for (let index = 0; index < records.length; index += 1) {
|
||||
const record = records[index];
|
||||
if (record.code !== 9) {
|
||||
continue;
|
||||
}
|
||||
const variableName = String(record.value || "").trim();
|
||||
const valueRecord = records[index + 1];
|
||||
if (!valueRecord) {
|
||||
continue;
|
||||
}
|
||||
if (variableName === "$INSUNITS") {
|
||||
sourceUnits = Math.max(0, Math.trunc(toFiniteNumber(valueRecord.value, 0)));
|
||||
}
|
||||
}
|
||||
return {
|
||||
sourceUnits,
|
||||
defaultThicknessMm: 0
|
||||
};
|
||||
}
|
||||
|
||||
function parseLineEntity(records) {
|
||||
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
|
||||
const startX = toFiniteNumber(records.find((record) => record.code === 10)?.value);
|
||||
const startY = toFiniteNumber(records.find((record) => record.code === 20)?.value);
|
||||
const endX = toFiniteNumber(records.find((record) => record.code === 11)?.value);
|
||||
const endY = toFiniteNumber(records.find((record) => record.code === 21)?.value);
|
||||
return {
|
||||
layer,
|
||||
start: [startX, startY],
|
||||
end: [endX, endY]
|
||||
};
|
||||
}
|
||||
|
||||
function parseArcEntity(records) {
|
||||
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
|
||||
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
|
||||
if (radius <= 0) {
|
||||
throw new Error("Invalid DXF arc radius");
|
||||
}
|
||||
const startAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 50)?.value));
|
||||
const endAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 51)?.value));
|
||||
let sweepAngleDeg = (endAngleDeg - startAngleDeg + 360) % 360;
|
||||
if (sweepAngleDeg <= ANGLE_EPSILON) {
|
||||
sweepAngleDeg = 360;
|
||||
}
|
||||
return {
|
||||
layer,
|
||||
center: [
|
||||
toFiniteNumber(records.find((record) => record.code === 10)?.value),
|
||||
toFiniteNumber(records.find((record) => record.code === 20)?.value)
|
||||
],
|
||||
radius,
|
||||
startAngleDeg,
|
||||
sweepAngleDeg,
|
||||
endAngleDeg: startAngleDeg + sweepAngleDeg
|
||||
};
|
||||
}
|
||||
|
||||
function parseCircleEntity(records) {
|
||||
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
|
||||
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
|
||||
if (radius <= 0) {
|
||||
throw new Error("Invalid DXF circle radius");
|
||||
}
|
||||
return {
|
||||
layer,
|
||||
center: [
|
||||
toFiniteNumber(records.find((record) => record.code === 10)?.value),
|
||||
toFiniteNumber(records.find((record) => record.code === 20)?.value)
|
||||
],
|
||||
radius
|
||||
};
|
||||
}
|
||||
|
||||
function arcFromBulgeSegment(layer, start, end, bulge) {
|
||||
const dx = end[0] - start[0];
|
||||
const dy = end[1] - start[1];
|
||||
const chordLength = Math.hypot(dx, dy);
|
||||
if (chordLength <= ANGLE_EPSILON || Math.abs(bulge) <= ANGLE_EPSILON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const includedAngleRad = 4 * Math.atan(bulge);
|
||||
const radius = (chordLength * (1 + bulge * bulge)) / (4 * Math.abs(bulge));
|
||||
const midpoint = [
|
||||
(start[0] + end[0]) / 2,
|
||||
(start[1] + end[1]) / 2
|
||||
];
|
||||
const leftNormal = [-dy / chordLength, dx / chordLength];
|
||||
const centerOffset = (chordLength * (1 - bulge * bulge)) / (4 * bulge);
|
||||
const center = [
|
||||
midpoint[0] + leftNormal[0] * centerOffset,
|
||||
midpoint[1] + leftNormal[1] * centerOffset
|
||||
];
|
||||
return {
|
||||
layer,
|
||||
center,
|
||||
radius,
|
||||
startAngleDeg: normalizeAngle((Math.atan2(start[1] - center[1], start[0] - center[0]) * 180) / Math.PI),
|
||||
sweepAngleDeg: (includedAngleRad * 180) / Math.PI
|
||||
};
|
||||
}
|
||||
|
||||
function parseLwpolylineEntity(records) {
|
||||
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
|
||||
const flags = Math.trunc(toFiniteNumber(records.find((record) => record.code === 70)?.value, 0));
|
||||
const vertices = [];
|
||||
let currentVertex = null;
|
||||
for (const record of records) {
|
||||
if (record.code === 10) {
|
||||
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
|
||||
vertices.push(currentVertex);
|
||||
}
|
||||
currentVertex = { point: [toFiniteNumber(record.value), Number.NaN], bulge: 0 };
|
||||
continue;
|
||||
}
|
||||
if (record.code === 20 && currentVertex) {
|
||||
currentVertex.point[1] = toFiniteNumber(record.value);
|
||||
continue;
|
||||
}
|
||||
if (record.code === 42 && currentVertex) {
|
||||
currentVertex.bulge = toFiniteNumber(record.value);
|
||||
}
|
||||
}
|
||||
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
|
||||
vertices.push(currentVertex);
|
||||
}
|
||||
if (vertices.length < 2) {
|
||||
throw new Error("Invalid DXF LWPOLYLINE; expected at least 2 vertices");
|
||||
}
|
||||
const lines = [];
|
||||
const arcs = [];
|
||||
const addSegment = (startVertex, endVertex) => {
|
||||
const start = startVertex.point;
|
||||
const end = endVertex.point;
|
||||
if (start[0] === end[0] && start[1] === end[1]) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(startVertex.bulge) > ANGLE_EPSILON) {
|
||||
const arc = arcFromBulgeSegment(layer, start, end, startVertex.bulge);
|
||||
if (arc) {
|
||||
arcs.push(arc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
lines.push({ layer, start, end });
|
||||
};
|
||||
for (let index = 0; index < vertices.length - 1; index += 1) {
|
||||
addSegment(vertices[index], vertices[index + 1]);
|
||||
}
|
||||
if ((flags & 1) !== 0) {
|
||||
addSegment(vertices[vertices.length - 1], vertices[0]);
|
||||
}
|
||||
return { lines, arcs };
|
||||
}
|
||||
|
||||
function parseEntities(records) {
|
||||
const lines = [];
|
||||
const arcs = [];
|
||||
const circles = [];
|
||||
let index = 0;
|
||||
while (index < records.length) {
|
||||
const startRecord = records[index];
|
||||
if (startRecord.code !== 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const entityType = String(startRecord.value || "").trim().toUpperCase();
|
||||
if (entityType === "ENDSEC") {
|
||||
break;
|
||||
}
|
||||
const entityRecords = [];
|
||||
index += 1;
|
||||
while (index < records.length && records[index].code !== 0) {
|
||||
entityRecords.push(records[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (!SUPPORTED_ENTITY_TYPES.has(entityType)) {
|
||||
throw new Error(`Unsupported DXF entity ${entityType}`);
|
||||
}
|
||||
if (entityType === "LINE") {
|
||||
lines.push(parseLineEntity(entityRecords));
|
||||
continue;
|
||||
}
|
||||
if (entityType === "ARC") {
|
||||
arcs.push(parseArcEntity(entityRecords));
|
||||
continue;
|
||||
}
|
||||
if (entityType === "CIRCLE") {
|
||||
circles.push(parseCircleEntity(entityRecords));
|
||||
continue;
|
||||
}
|
||||
const polyline = parseLwpolylineEntity(entityRecords);
|
||||
lines.push(...polyline.lines);
|
||||
arcs.push(...polyline.arcs);
|
||||
}
|
||||
if (!lines.length && !arcs.length && !circles.length) {
|
||||
throw new Error("No supported DXF entities found");
|
||||
}
|
||||
return { lines, arcs, circles };
|
||||
}
|
||||
|
||||
function splitSections(records) {
|
||||
const sections = new Map();
|
||||
let index = 0;
|
||||
while (index < records.length) {
|
||||
const record = records[index];
|
||||
if (record.code !== 0 || String(record.value || "").trim().toUpperCase() !== "SECTION") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const nameRecord = records[index + 1];
|
||||
const sectionName = String(nameRecord?.value || "").trim().toUpperCase();
|
||||
index += 2;
|
||||
const sectionRecords = [];
|
||||
while (index < records.length) {
|
||||
const nextRecord = records[index];
|
||||
if (nextRecord.code === 0 && String(nextRecord.value || "").trim().toUpperCase() === "ENDSEC") {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
sectionRecords.push(nextRecord);
|
||||
index += 1;
|
||||
}
|
||||
sections.set(sectionName, sectionRecords);
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
function buildPathRecord(layerName, kind, pathData) {
|
||||
return {
|
||||
layer: layerName,
|
||||
kind,
|
||||
d: pathData
|
||||
};
|
||||
}
|
||||
|
||||
function touchLayer(layerSummary, layerName) {
|
||||
const existing = layerSummary.get(layerName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next = {
|
||||
name: layerName,
|
||||
kind: semanticKindForLayer(layerName),
|
||||
pathCount: 0,
|
||||
circleCount: 0
|
||||
};
|
||||
layerSummary.set(layerName, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseDxf(dxfText, { fileRef = "", sourceUrl = "" } = {}) {
|
||||
const records = parseRecordPairs(dxfText);
|
||||
const sections = splitSections(records);
|
||||
const header = parseHeader(sections.get("HEADER") || []);
|
||||
const entities = parseEntities(sections.get("ENTITIES") || []);
|
||||
|
||||
let rawBounds = null;
|
||||
for (const line of entities.lines) {
|
||||
rawBounds = expandBounds(rawBounds, lineBounds(line));
|
||||
}
|
||||
for (const arc of entities.arcs) {
|
||||
rawBounds = expandBounds(rawBounds, arcBounds(arc));
|
||||
}
|
||||
for (const circle of entities.circles) {
|
||||
rawBounds = expandBounds(rawBounds, circleBounds(circle));
|
||||
}
|
||||
if (!rawBounds) {
|
||||
throw new Error("Failed to compute DXF bounds");
|
||||
}
|
||||
|
||||
const width = Math.max(rawBounds.maxX - rawBounds.minX, 0);
|
||||
const height = Math.max(rawBounds.maxY - rawBounds.minY, 0);
|
||||
const pathRecords = [];
|
||||
const circleRecords = [];
|
||||
const layerSummary = new Map();
|
||||
|
||||
for (const line of entities.lines) {
|
||||
const start = screenPoint(line.start, { minX: rawBounds.minX, maxY: rawBounds.maxY });
|
||||
const end = screenPoint(line.end, { minX: rawBounds.minX, maxY: rawBounds.maxY });
|
||||
pathRecords.push(
|
||||
buildPathRecord(
|
||||
line.layer,
|
||||
semanticKindForLayer(line.layer),
|
||||
`M ${formatNumber(start[0])} ${formatNumber(start[1])} L ${formatNumber(end[0])} ${formatNumber(end[1])}`
|
||||
)
|
||||
);
|
||||
touchLayer(layerSummary, line.layer).pathCount += 1;
|
||||
}
|
||||
|
||||
for (const arc of entities.arcs) {
|
||||
const start = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg), {
|
||||
minX: rawBounds.minX,
|
||||
maxY: rawBounds.maxY
|
||||
});
|
||||
const end = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg + arc.sweepAngleDeg), {
|
||||
minX: rawBounds.minX,
|
||||
maxY: rawBounds.maxY
|
||||
});
|
||||
const largeArcFlag = Math.abs(arc.sweepAngleDeg) > 180 + ANGLE_EPSILON ? 1 : 0;
|
||||
const sweepFlag = arc.sweepAngleDeg >= 0 ? 0 : 1;
|
||||
pathRecords.push(
|
||||
buildPathRecord(
|
||||
arc.layer,
|
||||
semanticKindForLayer(arc.layer),
|
||||
`M ${formatNumber(start[0])} ${formatNumber(start[1])} A ${formatNumber(arc.radius)} ${formatNumber(arc.radius)} 0 ${largeArcFlag} ${sweepFlag} ${formatNumber(end[0])} ${formatNumber(end[1])}`
|
||||
)
|
||||
);
|
||||
touchLayer(layerSummary, arc.layer).pathCount += 1;
|
||||
}
|
||||
|
||||
for (const circle of entities.circles) {
|
||||
const center = screenPoint(circle.center, { minX: rawBounds.minX, maxY: rawBounds.maxY });
|
||||
circleRecords.push({
|
||||
layer: circle.layer,
|
||||
kind: semanticKindForLayer(circle.layer),
|
||||
cx: formatNumber(center[0]),
|
||||
cy: formatNumber(center[1]),
|
||||
r: formatNumber(circle.radius)
|
||||
});
|
||||
touchLayer(layerSummary, circle.layer).circleCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: DXF_RENDER_SCHEMA_VERSION,
|
||||
fileRef,
|
||||
sourceUrl,
|
||||
sourceUnits: header.sourceUnits,
|
||||
defaultThicknessMm: formatNumber(header.defaultThicknessMm),
|
||||
bounds: {
|
||||
minX: 0,
|
||||
minY: 0,
|
||||
maxX: formatNumber(width),
|
||||
maxY: formatNumber(height),
|
||||
width: formatNumber(width),
|
||||
height: formatNumber(height)
|
||||
},
|
||||
counts: {
|
||||
paths: pathRecords.length,
|
||||
circles: circleRecords.length,
|
||||
entities: pathRecords.length + circleRecords.length
|
||||
},
|
||||
layers: [...layerSummary.keys()].sort().map((name) => layerSummary.get(name)),
|
||||
geometry: {
|
||||
lines: entities.lines.map((line) => ({
|
||||
layer: line.layer,
|
||||
kind: semanticKindForLayer(line.layer),
|
||||
start: [formatNumber(line.start[0]), formatNumber(line.start[1])],
|
||||
end: [formatNumber(line.end[0]), formatNumber(line.end[1])]
|
||||
})),
|
||||
arcs: entities.arcs.map((arc) => ({
|
||||
layer: arc.layer,
|
||||
kind: semanticKindForLayer(arc.layer),
|
||||
center: [formatNumber(arc.center[0]), formatNumber(arc.center[1])],
|
||||
radius: formatNumber(arc.radius),
|
||||
startAngleDeg: formatNumber(arc.startAngleDeg),
|
||||
sweepAngleDeg: formatNumber(arc.sweepAngleDeg)
|
||||
})),
|
||||
circles: entities.circles.map((circle) => ({
|
||||
layer: circle.layer,
|
||||
kind: semanticKindForLayer(circle.layer),
|
||||
center: [formatNumber(circle.center[0]), formatNumber(circle.center[1])],
|
||||
radius: formatNumber(circle.radius)
|
||||
}))
|
||||
},
|
||||
paths: pathRecords,
|
||||
circles: circleRecords
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user