Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# CAD Viewer local development defaults.
# Start from the repo root, then open the printed URL with ?dir=/absolute/path:
# npm --prefix viewer run dev -- --host 127.0.0.1
VIEWER_ASSET_BACKEND=local-fs
# Optional local settings.
# VIEWER_DEFAULT_DIR=/absolute/path/to/models
# VIEWER_DEFAULT_FILE=assemblies/robot-arm/robot-arm.step
# Override the default repo/release links.
# VIEWER_GITHUB_URL=https://github.com/<owner>/<repo>
# Override the default Discord community link.
# VIEWER_DISCORD_URL=https://discord.gg/<invite>
# VIEWER_ALLOWED_HOSTS=localhost,127.0.0.1
# VIEWER_MOVEIT2_WS_URL=ws://127.0.0.1:8765
# VIEWER_CAD_PYTHON=./.venv/bin/python
# VIEWER_CAD_PYTHONPATH=packages/cadpy/src
# VIEWER_SERVER_REGISTRY=/tmp/cad-viewer-servers.json
# Hosted Vercel Blob backend settings.
# VIEWER_ASSET_BACKEND=vercel-blob
# VIEWER_VERCEL_BLOB_PREFIX=https://<store-id>.public.blob.vercel-storage.com/models
# VIEWER_VERCEL_BLOB_CATALOG_PATH=catalog.json
# BLOB_READ_WRITE_TOKEN=
# VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN=
+31
View File
@@ -0,0 +1,31 @@
# Vite / Node
node_modules
.vite
# Legacy Next.js build folders from prior runs
.next
.next-build
.next-dev
.next-verify
.vercel
*.tsbuildinfo
dist
dist-verify
coverage
tmp
# Python / test caches
__pycache__
*.py[cod]
.pytest_cache
# Package manager logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Explorer-local env files
.env
.env.*
!.env.example
!.env.*.example
+207
View File
@@ -0,0 +1,207 @@
# CAD Viewer
CAD Viewer is a browser workbench for inspecting CAD files,
robot-description files, and generated CAD artifacts from a URL-selected local
directory or hosted catalog. It is built for engineering review loops where you
need to open a model quickly, understand the source tree, copy stable `#...`
CAD references, and verify generated assets without leaving the browser.
## Features
- Scans a `?dir=` local root directory and mirrors its folder
structure in the sidebar.
- Opens `.step`, `.stp`, `.stl`, `.3mf`, `.glb`, `.gcode`, `.dxf`, `.urdf`,
`.srdf`, and `.sdf` entries.
- Uses hidden STEP GLB/topology sidecars for assembly structure, face/edge
picking, copied CAD references, and STEP parameter controls.
- Previews mesh files, DXF flat patterns, G-code toolpaths, URDF/SDF robots,
and SRDF group states in one app shell.
- Runs against either a local filesystem backend or hosted Vercel Blob storage.
- Can regenerate STEP GLB/topology artifacts when the CAD Python runtime is
available.
- Provides optional MoveIt2 websocket controls for SRDF IK and planning.
## Quick Start
Run these commands from this directory:
```bash
npm install
npm run test
npm run build
```
For local development, start the dev server and then pass a local directory and
directory-relative file path in the URL:
```bash
npm run dev -- --host 127.0.0.1
```
Open the URL printed by Vite and add paths, for example
`?dir=/path/to/root&file=assemblies/robot-arm/robot-arm.step`.
Local tools should not assume a fixed port. Use
`npm run agent:start -- --dir /path/to/root` for agent-driven review. It starts
at port `4178`, reuses a compatible existing Viewer, skips viewers with a
different launcher-provided `git` value or a different requested default
`--dir`, and starts on the first free candidate port. Use Vite's standard
`--port` argument only when a specific dev port is needed. Local dev and
production servers stay running unless `VIEWER_SERVER_LIFETIME_MS` is set or
production `serve` is started with `--shutdown-after <duration>`.
The local filesystem backend accepts absolute or startup-directory-relative
`?dir=` values directly in the Viewer URL. Once seen, the active directory is
stored in tab-local `sessionStorage`, so subsequent navigation can omit `?dir=`
and continue using the same directory. The `--dir` startup path is the first
active directory; when omitted, the startup directory is used. If multiple
directories have been scanned and no `?dir=` or stored directory is active, the
Viewer shows a directory picker. `?file=` is always relative to the active
directory.
Install the local Python artifact package when iterating on local STEP
regeneration:
```bash
python -m pip install -r requirements.txt
```
Agent handoff links from the cad-viewer skill should still include an absolute
`?dir=` on every returned URL, plus an absolute `file=` value for each requested
file. The session-storage fallback is for same-tab navigation, not for durable
review links.
## Project Layout
- `src/client/`: React app, browser state, styling, and viewer/workbench UI.
- `src/client/components/`: top-level CAD, DXF, workbench, and shadcn-style UI
components.
- `src/client/workbench/`: selection, persistence, file-sheet, alert, motion,
and reference helpers that are not React components.
- `src/client/ui/`: viewer-owned browser utilities such as clipboard, color
scheme, class merging, and DOM helpers.
- `src/server/`: local and hosted backend adapters, HTTP middleware, and the
local production server.
- `api/cad/`: Vercel serverless shims for `/__cad/*` routes.
- `scripts/`: developer and runtime launchers, plus the test runner.
- `docs/`: workflow reference docs for backend storage, browser persistence,
and MoveIt2.
- `moveit2_server/`: optional Python websocket backend for SRDF controls.
The shared non-React CAD runtime source lives in `../packages/cadjs`, and the
shared non-React implicit CAD runtime source lives in `../packages/implicitjs`.
On `develop`, `viewer/packages/*` is a symlinked development layout; keep
reusable parsing, rendering, sidecar, selector, topology, implicit shader,
snapshot, and export logic in the source packages.
## Common Commands
```bash
npm run dev # Vite dev server with local CAD API middleware
npm run agent:start # Launcher that chooses mode and reuses/selects a local port
npm run build # Production frontend build
npm run serve # Serve dist/ with the local or hosted backend
npm run test # Discover and run all JS tests
npm run upload:blob # Upload a hosted catalog and supported viewer assets
```
`npm run test` uses `scripts/run-tests.mjs`, which discovers
`*.test.js` and `*.test.mjs` under `src/` and `scripts/`. To run specific tests:
```bash
node scripts/run-tests.mjs src/client/workbench/sidebar.test.js
node scripts/run-tests.mjs src/server/localAssetBackend.test.mjs
```
## Runtime Configuration
Important environment variables:
- `VIEWER_ASSET_BACKEND`: `local-fs` for local files or `vercel-blob` for hosted
Blob assets.
- `VIEWER_DEFAULT_DIR`: default local directory used by Vite dev mode.
`npm run agent:start -- --dir <path>` sets this automatically when it launches
Vite.
- `VIEWER_DEFAULT_FILE`: active-directory-relative file opened when `?file=`
is absent and a `?dir=` or stored active directory is available.
- `VIEWER_SERVER_LIFETIME_MS`: optional server lifetime in milliseconds for
local dev and production servers. When unset, there is no automatic shutdown.
- `VIEWER_GITHUB_URL`: optional top-bar GitHub link target. When set, the
version label links to the matching GitHub release tag. For GitHub-hosted
repositories, the Viewer also checks the latest release and lightly marks the
version label when a newer release is available.
- `VIEWER_DISCORD_URL`: optional top-bar Discord community link target.
- `VIEWER_ALLOWED_HOSTS`: extra hostnames accepted by local Vite dev and
production servers.
- `VIEWER_MOVEIT2_WS_URL`: optional websocket URL for SRDF MoveIt2 controls.
- `VIEWER_CAD_PYTHON`: optional Python executable for local STEP regeneration.
- `VIEWER_CAD_PYTHONPATH` / `CAD_PYTHONPATH`: optional Python source path for
the `cadpy` package.
- `VIEWER_SERVER_REGISTRY`: optional local server registry JSON path.
- `VIEWER_GIT`: optional launcher-provided git identity, exposed as `git` by
`/__cad/server` and used by `npm run agent:start` to avoid reusing viewers
from other git worktrees or branches. Ordinary users should not need to set it
manually.
`VIEWER_LOCAL_ROOT_DIR` and `VIEWER_LOCAL_WORKSPACE_ROOT` are removed for local
filesystem viewing. Setting either variable, or using the old fixed-root startup
flag, is a hard startup error; use `--dir` for the startup default and an
absolute `?dir=` URL parameter for review links.
Vercel Blob backend variables:
- `VIEWER_VERCEL_BLOB_PREFIX`: Blob prefix for all catalog assets. For token-free
hosted deployments, use the public Blob URL for the prefix, such as
`https://<store-id>.public.blob.vercel-storage.com/models2`. Trusted local
upload scripts may use the path prefix, such as `models2`.
Hosted Vercel API routes are read-only. They serve the catalog and file assets
but do not regenerate STEP artifacts or require a Blob read/write token at
runtime. The hosted backend always reads `catalog.json` at the root of
`VIEWER_VERCEL_BLOB_PREFIX`; for the example above, that is
`https://<store-id>.public.blob.vercel-storage.com/models2/catalog.json`. Use
`VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN` or `BLOB_READ_WRITE_TOKEN` only for
trusted local upload or maintenance scripts. Hosted server metadata derives the
viewer URL from
Vercel's system environment variables (`VERCEL_PROJECT_PRODUCTION_URL`,
`VERCEL_URL`, then `VERCEL_BRANCH_URL`).
Upload a catalog and supported viewer assets from a local directory with
`npm run upload:blob -- /path/to/models`. Uploads exclude `mechbench/`,
`mechbench2/`, `7dof_arm/`, and Python source files by default; public Blob
catalogs omit Python source paths and URLs. Add a `.vieweruploadignore` file or
pass `--exclude <pattern>` for project-specific upload filters. Pass
`--skip-existing --fetch-missing-lfs` for publish-style uploads that reuse
matching remote catalog assets and fetch only the Git LFS objects needed for
new or changed uploads. The repository publish wrapper,
`scripts/viewer/upload-viewer-models-catalog.sh`, owns the branch-defined Blob
path prefix for model catalog uploads.
Production builds contain the frontend and initial catalog module only. CAD
assets are served by a backend and are not copied into `dist/`.
## Reference Docs
- [Backend storage](./docs/backend.md): local filesystem and Vercel Blob backend
contracts.
- [Browser storage](./docs/storage.md): URL, `localStorage`, and
`sessionStorage` ownership.
- [MoveIt2 server](./docs/moveit2-server.md): optional SRDF websocket backend.
- `cadjs` render pipeline: shared render APIs used by the viewer, docs, and
snapshot runtime. In this workbench, see
`../packages/cadjs/docs/render-pipeline.md`.
- `implicitjs` runtime: shared implicit CAD model, shader render, snapshot, and
export APIs. In this workbench, see `../packages/implicitjs/README.md`.
## Verification
Run the focused viewer checks before handing off viewer changes:
```bash
npm run test
npm run build
```
For UI behavior changes, also run `npm run dev -- --host 127.0.0.1`, open the
printed URL with `?dir=/absolute/root&file=path/to/model.step`,
and check that the app renders, selection works, and the browser console is
clean.
+6
View File
@@ -0,0 +1,6 @@
import { handleHostedCadApi } from "../../src/server/vercelApi.mjs";
export default async function handler(req, res) {
await handleHostedCadApi(req, res, { cadPath: "/__cad/catalog" });
}
+6
View File
@@ -0,0 +1,6 @@
import { handleHostedCadApi } from "../../src/server/vercelApi.mjs";
export default async function handler(req, res) {
await handleHostedCadApi(req, res, { cadPath: "/__cad/download" });
}
+6
View File
@@ -0,0 +1,6 @@
import { handleHostedCadApi } from "../../src/server/vercelApi.mjs";
export default async function handler(req, res) {
await handleHostedCadApi(req, res, { cadPath: "/__cad/reveal" });
}
+6
View File
@@ -0,0 +1,6 @@
import { handleHostedCadApi } from "../../src/server/vercelApi.mjs";
export default async function handler(req, res) {
await handleHostedCadApi(req, res, { cadPath: "/__cad/server" });
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/client/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/ui/utils",
"ui": "@/components/ui",
"lib": "@/ui",
"hooks": "@/hooks"
},
"registries": {}
}
+186
View File
@@ -0,0 +1,186 @@
# Backend Storage
CAD Viewer uses a small backend interface so the same React app can run against
local files during development and hosted object storage in deployment. Client
code talks to HTTP routes and catalog URLs; it does not read filesystem paths
directly.
## Interface
Backend implementations expose this core shape:
```js
{
kind,
readCatalog({ rootDir, fileRef }),
refreshCatalog({ rootDir, fileRef }),
resolveFileAssetAccess({ fileRef, asset, catalog }),
}
```
Trusted upload tooling for hosted storage serializes catalog JSON and uploads it
as `catalog.json`. Writable backends may expose
`writeAsset({ fileRef, body, contentType })` and
`generateStepArtifact({ fileRef, force, catalog })`.
Local filesystem backends also expose helpers used by Vite and the local
production server:
```js
{
resolveRoot(rootDir),
openFileAsset({ fileRef, asset, catalog }),
assetPathForFileRef(fileRef, { resolvedRoot }),
entryForSourcePath(catalog, resolvedRoot, sourcePath),
contentTypeForPath(filePath),
}
```
`readCatalog()` returns catalog JSON from the backend's source of truth.
`refreshCatalog()` lets an adapter update or regenerate that in-memory view.
Writable helpers may write servable CAD assets such as hidden STEP GLBs or run
local CAD generation.
## Local Filesystem
`src/server/localAssetBackend.mjs` is the development and local deployment
implementation. `readCatalog()` and `refreshCatalog()` scan
the absolute `?dir=` root for the current request, keep the catalog as an
in-memory object, and return schema v4 entries whose `file` values are absolute
paths plus `rootRelativeFile` values for URL navigation. The local backend does
not write `catalog.json` or any hidden catalog cache file.
Local filesystem deployments are intentionally URL-driven. `?dir=` may be
absolute or relative to the directory where the Viewer was started; when omitted
it defaults to the startup `--dir`, or to the startup directory if `--dir` was
not passed. That default directory is also the first active directory. `?file=`
values are always relative to the active `?dir=` directory.
`VIEWER_LOCAL_ROOT_DIR`, `VIEWER_LOCAL_WORKSPACE_ROOT`, and the old fixed-root
startup flag have been removed and now fail at startup.
The local backend serves asset bytes from the active root and writes regenerated
artifacts back into it. It rejects path traversal and only serves or writes
supported CAD Viewer asset types.
Local STEP GLB/topology regeneration calls the Python `cadpy` package. The root
viewer carries a generated, installable copy under `viewer/packages/cadpy`; run
`scripts/bundle/bundle-skill.sh cad-viewer` after changing `packages/cadpy`, then
install `viewer/requirements.txt` into the Python runtime used by the viewer.
The generated cad-viewer skill runtime bundles that same installable package
under `scripts/viewer/packages/cadpy` and does not need the repository root.
Vite dev mounts this backend for:
- `GET /__cad/server`
- `GET /__cad/catalog`
- `GET /__cad/asset?file=...`
- `GET /__cad/download?file=...&asset=output|source`
- `POST /__cad/reveal?file=...&asset=output|source`
- `POST /__cad/step-artifact`
`download` streams the requested asset bytes and works for both local and hosted
deployments. `reveal` opens the asset in Finder or the platform file manager and
is only available for the local filesystem backend. `asset=output` resolves the
catalog entry file itself; `asset=source` resolves optional source code, such as
a same-stem Python generator for Python-backed STEP files.
The local production server uses the same backend:
```bash
npm run build
npm run serve
```
Then open the printed server URL with
`?dir=/absolute/root&file=model.step`. Pass `--port <number>` to
`npm run serve --` only when the default production port is already in use.
## Vercel Blob
`src/server/vercelBlobAssetBackend.mjs` is the hosted storage adapter. Vercel
deployments construct it in read-only mode: the hosted API reads the catalog and
serves public Blob assets, but it does not write Blob objects or regenerate STEP
artifacts.
Hosted catalog reads are deliberately conservative about Blob traffic.
Sustained per-request fetches of the public catalog URL from shared serverless
egress IPs trip Vercel's abuse mitigation with intermittent `403 Forbidden`
responses, so the hosted backend caches the parsed catalog in-function for 60
seconds (serving the last good catalog if a refresh fails), hosted
`/__cad/catalog` responses carry `s-maxage`/`stale-while-revalidate`
cache-control so the Vercel CDN absorbs client polling, and hosted viewer
builds poll the catalog every 60 seconds instead of the local 2-second
development cadence.
Expected deployment shape:
- Build the frontend normally.
- Run a trusted local upload/maintenance script to publish supported assets and a
schema v4 `catalog.json` containing public Blob URLs.
- Deploy the viewer with `VIEWER_ASSET_BACKEND=vercel-blob` and a read-only
catalog configuration. Catalog warnings such as stale STEP artifacts are shown
in the UI instead of being fixed by the hosted app.
- Hosted Vercel routes ignore local filesystem `?dir=` query values and read the
configured hosted `catalog.json` instead.
The Vercel deployment entrypoints are intentionally thin:
```text
api/cad/server.js
api/cad/catalog.js
api/cad/download.js
api/cad/reveal.js
vercel.json
```
`vercel.json` rewrites the client-facing routes below to those API functions:
```text
/__cad/server
/__cad/catalog
/__cad/download
/__cad/reveal
```
The local production server can mount the Blob catalog path by environment:
```bash
VIEWER_ASSET_BACKEND=vercel-blob \
VIEWER_VERCEL_BLOB_PREFIX=https://<store-id>.public.blob.vercel-storage.com/models2 \
npm run serve
```
Upload a local directory catalog and supported viewer assets with:
```bash
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN=... \
VIEWER_VERCEL_BLOB_PREFIX=models2 \
npm --prefix viewer run upload:blob -- models
```
`BLOB_READ_WRITE_TOKEN` may be used instead of
`VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN`, matching the environment variable Vercel
Blob integrations create.
The uploader also reads `.vieweruploadignore` from the uploaded directory and
accepts repeated `--ignore-file` options for gitignore-style exclude patterns.
It excludes `mechbench/`, `mechbench2/`, `7dof_arm/`, and Python source files
by default, and public Blob catalogs omit Python source paths and URLs. Add
`--skip-existing --fetch-missing-lfs` for publish-style runs that compare
against the remote catalog, skip matching assets, and fetch only the Git LFS
objects needed for new or changed uploads. The repository publish wrapper,
`scripts/viewer/upload-viewer-models-catalog.sh`, owns the branch-defined Blob
path prefix for model catalog uploads. Catalog uploads use Vercel Blob's
minimum supported cache TTL so in-place `catalog.json` overwrites are visible
quickly, while large immutable model assets keep the default Blob cache policy.
Hosted catalog reads also append a catalog cache-busting query parameter so the
viewer does not inherit stale edge entries from earlier catalog overwrites.
For token-free read-only deployments, `VIEWER_VERCEL_BLOB_PREFIX` should be the
public Blob URL for the prefix directory. The hosted backend always reads
`catalog.json` at the root of that prefix. Trusted local upload or maintenance
scripts may use the path prefix, such as `models2`, with a read/write token.
The writable Blob helper methods remain available to local scripts that import
the backend directly and pass a Blob read/write token. They are not exposed by
the hosted Vercel API.
+114
View File
@@ -0,0 +1,114 @@
# MoveIt2 Server
The MoveIt2 server is an optional CAD Viewer backend module for SRDF IK and
path-planning controls. It is disabled for normal hosted viewer use and is only
needed when a user explicitly wants interactive solve or plan-to-pose controls.
## Install
From the viewer package or generated cad-viewer skill runtime directory:
```bash
npm run moveit2:setup
```
The setup script creates or updates the `moveit2-server` conda environment from
`moveit2_server/environment.yml`, installs the Python package in editable mode,
and runs a server import check.
Use a ROS 2 / MoveIt2 capable conda installation. Do not install ROS 2 or
MoveIt2 into the repository CAD `.venv`.
Useful environment variables:
```text
MOVEIT2_SERVER_CONDA_ENV_NAME
MOVEIT2_SERVER_CONDA_EXE
MOVEIT2_SERVER_REPO_ROOT
MOVEIT2_SERVER_HOST
MOVEIT2_SERVER_PORT
```
## Run
From the viewer package or generated cad-viewer skill runtime directory:
```bash
npm run moveit2:check
npm run moveit2:serve
```
The websocket endpoint defaults to:
```text
ws://127.0.0.1:8765/ws
```
CAD Viewer connects to that URL in local dev when MoveIt2 controls are enabled,
unless `VIEWER_MOVEIT2_WS_URL` or the browser `?moveit2Ws=` query parameter
overrides it.
## Hosted Backend
The MoveIt2 module is a long-running websocket backend, not a Vercel serverless
function. Host it in a ROS 2 / MoveIt2 capable VM or container by running the
same Python package and exposing its websocket URL:
```bash
MOVEIT2_SERVER_HOST=0.0.0.0 \
MOVEIT2_SERVER_PORT=8765 \
MOVEIT2_SERVER_REPO_ROOT=/srv/cad-viewer \
npm run moveit2:serve
```
Point the viewer at that endpoint with `VIEWER_MOVEIT2_WS_URL` at build time or
`?moveit2Ws=` at runtime. Production viewer builds keep MoveIt2 disabled unless
a websocket URL is explicitly configured by the deployment or user.
## Protocol
Requests use `protocolVersion: 1`.
Supported request types:
- `srdf.solvePose`
- `srdf.planToPose`
Use native joint-value fields:
- `startJointValuesByName`
- `jointValuesByName`
- trajectory `positions`
Legacy `*Deg` fields remain compatibility aliases. Angular values convert
between degrees and native radians; prismatic joints stay linear.
Pose targets require `target.frame`, `target.xyz`, and `target.endEffector`.
Set `target.targetLink` or `moveit2.targetLink` when the desired TCP is not
obvious.
Orientation can be provided as exactly one of:
```json
"quat_xyzw": [0, 0, 0, 1]
```
or:
```json
"rpy": [0, 0, 0]
```
If no orientation is provided, `moveit2.ik.positionOnly` defaults to true. If
`positionOnly` is false, an orientation must be supplied.
## Notes
- The server has no Python import dependency on the SRDF skill. It includes the
SRDF metadata parser it needs under `moveit2_server/moveit2_server`.
- Success depends on the linked URDF, SRDF, collision geometry, solver
availability, and MoveIt environment.
- The generated MoveIt config defaults are suitable for viewer smoke tests but
are not universal production planning settings.
- Cache invalidation is based on linked URDF/SRDF file size and modification
time.
+101
View File
@@ -0,0 +1,101 @@
# Browser Storage
CAD Viewer has four browser persistence tiers. Choose the smallest tier that
matches the lifetime and sharing behavior the user expects.
This doc covers browser state only. Catalogs, CAD assets, hidden STEP
GLB/topology artifacts, and hosted Blob uploads are backend concerns; use
[backend.md](./backend.md) for that interface.
## URL Query Params
Use query params only for shareable state that should survive copying a URL:
- `file`: active catalog entry, always relative to the active `dir` directory.
- `dir`: local filesystem directory to scan. It may be absolute or relative to
the directory where the Viewer server was started. When omitted, the Viewer
uses the active directory remembered for the tab, or the directory where the
server was started when that is the only active directory.
- `moveit2Ws`: explicit MoveIt2 websocket override for local or hosted sessions.
Do not put dense viewer state, panel state, drawing state, or per-file controls
in the URL.
## localStorage
Use `localStorage` sparingly. It is durable across tabs, browser restarts, and
unrelated CAD Viewer sessions, so it should only hold global preferences.
Current intended use:
- `cad-viewer:theme`: global saved-theme library, active theme id, and saved
appearance settings.
Avoid adding file-specific state to `localStorage`. If the value depends on the
selected file, the active root directory, a generated asset hash, or a tab
interaction, it belongs in per-file session state instead.
## Directory sessionStorage
Use directory-level `sessionStorage` for temporary app-wide UI state that should
survive reloads in the same browser tab, should not become a durable global
preference, and should not vary by selected file. Use
`src/client/workbench/persistence.js` rather than creating one-off storage keys.
Current keys:
```text
cad-viewer:directory-session:v1
cad-viewer:active-dir:v1
```
Current `cad-viewer:directory-session:v1` fields:
- `fileViewerOpen`: app-wide file viewer open/closed state.
- `fileViewerExpandedDirectoryIds`: app-wide open folder ids for the file
viewer tree. When absent, the first selected file on page load seeds the
initial expanded folder tree; an empty array means all folders are closed.
- `fileViewerWidthPx`: app-wide custom file viewer width, stored only when it
differs from the default.
- `fileSheetOpen`: app-wide file sheet open/closed state.
- `fileSheetWidthPx`: app-wide custom file sheet width, stored only when it
differs from the default.
- `theme`: app-wide unsaved theme settings for the current tab. Saved theme
presets and the active saved preset id still belong to `localStorage`.
`cad-viewer:active-dir:v1` stores the tab's preferred directory after a `?dir=`
URL is loaded or a directory is selected.
Do not put selected-file state, model controls, drawing state, or
generated-asset decisions in directory session state. Those belong in per-file
session state.
## Per-File sessionStorage
Prefer per-file `sessionStorage` for viewer state that should survive reloads in
the same browser tab without becoming a durable global preference. Use
`src/client/workbench/fileSessionState.js` rather than creating one-off storage
keys.
Per-file state is namespaced by the active root directory and keyed by file:
```text
cad-viewer:file-session:v1:<namespace>:<fileKey>
cad-viewer:file-session:index:v1:<namespace>
```
Per-file session state is intentionally tab-local. Do not sync these keys from
`storage` events; two tabs viewing the same file must be free to keep different
camera, display, tool, and sheet settings.
Existing slice intent:
- `tab`: file sheet section expansion, reference selection, part visibility,
camera, tools, and drawing history.
- `dxf`: DXF preview thickness and bend settings.
- `stepModule`: STEP module enablement, parameter values, and animation state.
- `urdf`: joint values and motion-planning controls.
- `largeFile`: large-file decisions such as selectable topology opt-in.
When adding another large-file control, reuse the `largeFile` slice instead of
adding a separate session storage key.
+34
View File
@@ -0,0 +1,34 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.jsx"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/client/*"]
}
}
}
@@ -0,0 +1,3 @@
from pathlib import Path
__path__.append(str(Path(__file__).resolve().parent / "moveit2_server"))
+14
View File
@@ -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}"
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
MOVEIT2_SERVER_CONDA_ENV_NAME="${MOVEIT2_SERVER_CONDA_ENV_NAME:-moveit2-server}"
find_conda() {
if [[ -n "${MOVEIT2_SERVER_CONDA_EXE:-}" && -x "${MOVEIT2_SERVER_CONDA_EXE}" ]]; then
printf '%s\n' "${MOVEIT2_SERVER_CONDA_EXE}"
return 0
fi
if [[ -n "${CONDA_EXE:-}" && -x "${CONDA_EXE}" ]]; then
printf '%s\n' "${CONDA_EXE}"
return 0
fi
if command -v conda >/dev/null 2>&1; then
command -v conda
return 0
fi
local candidate
for candidate in \
"${HOME}/miniforge3/bin/conda" \
"${HOME}/mambaforge/bin/conda" \
"${HOME}/miniconda3/bin/conda" \
"${HOME}/anaconda3/bin/conda" \
"/opt/conda/bin/conda"
do
if [[ -x "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
printf 'Could not find conda. Install Miniforge, put conda on PATH, or set MOVEIT2_SERVER_CONDA_EXE/CONDA_EXE.\n' >&2
return 1
}
find_repo_root() {
if [[ -n "${MOVEIT2_SERVER_REPO_ROOT:-}" ]]; then
if [[ ! -d "${MOVEIT2_SERVER_REPO_ROOT}" ]]; then
printf 'MOVEIT2_SERVER_REPO_ROOT does not exist: %s\n' "${MOVEIT2_SERVER_REPO_ROOT}" >&2
return 1
fi
(cd "${MOVEIT2_SERVER_REPO_ROOT}" && pwd -P)
return 0
fi
local root
if root="$(git -C "${PWD}" rev-parse --show-toplevel 2>/dev/null)"; then
printf '%s\n' "${root}"
return 0
fi
printf 'Could not find a repository root from the current directory. Run from the robot project repo or set MOVEIT2_SERVER_REPO_ROOT.\n' >&2
return 1
}
@@ -0,0 +1,14 @@
name: moveit2-server
channels:
- conda-forge
- robostack-jazzy
dependencies:
- python=3.12
- pip
- websockets
- ros-jazzy-ros-base
- ros-jazzy-rclpy
- ros-jazzy-geometry-msgs
- ros-jazzy-moveit-py
- ros-jazzy-moveit-planners-ompl
- ros-jazzy-rmw-cyclonedds-cpp
@@ -0,0 +1 @@
"""MoveIt2 websocket server for SRDF workflows."""
@@ -0,0 +1,904 @@
from __future__ import annotations
from math import isfinite
import posixpath
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
from moveit2_server.protocol import MotionProtocolError, SUPPORTED_REQUEST_TYPES
from moveit2_server.srdf_source import SrdfSource, read_srdf_source
DEFAULT_CAD_DIRECTORY = ""
DEFAULT_IK_TIMEOUT = 0.05
DEFAULT_IK_ATTEMPTS = 1
DEFAULT_POSITION_TOLERANCE = 0.002
DEFAULT_PLANNING_PIPELINE = "ompl"
DEFAULT_PLANNER_ID = "RRTConnectkConfigDefault"
DEFAULT_PLANNING_TIME = 1.0
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _path_is_inside(child_path: Path, parent_path: Path) -> bool:
try:
child_path.resolve().relative_to(parent_path.resolve())
return True
except ValueError:
return False
def _file_version(file_path: Path) -> str:
try:
stats = file_path.stat()
except FileNotFoundError:
return ""
if not stats.st_mode:
return ""
return f"{stats.st_size:x}-{stats.st_mtime_ns:x}"
def _combined_version(paths: list[Path]) -> str:
return "|".join(f"{path.name}:{_file_version(path)}" for path in paths)
def _normalize_relative_path(value: Any, *, label: str, suffix: str | None = None) -> str:
raw_value = str(value or "").strip().replace("\\", "/").lstrip("/")
normalized = posixpath.normpath(raw_value)
if not raw_value or normalized in {"", ".", ".."} or normalized.startswith("../"):
raise MotionProtocolError(f"{label} must stay inside the repository: {raw_value or '(missing)'}")
if suffix and not normalized.lower().endswith(suffix):
raise MotionProtocolError(f"{label} must end in {suffix}: {normalized}")
return normalized
def normalize_cad_directory(value: Any = DEFAULT_CAD_DIRECTORY) -> str:
if value is None:
value = DEFAULT_CAD_DIRECTORY
raw_value = str(value).strip()
if not raw_value:
return ""
return _normalize_relative_path(raw_value, label="dir")
def normalize_file_ref(value: Any) -> str:
return _normalize_relative_path(value, label="file", suffix=".srdf")
def _file_ref_relative_to_cad_dir(file_ref: str, *, cad_dir: str, cad_root: Path) -> str:
if not cad_dir:
return file_ref
prefix = f"{cad_dir.rstrip('/')}/"
if not file_ref.startswith(prefix):
return file_ref
scan_relative_ref = file_ref[len(prefix):]
if scan_relative_ref and not (cad_root / file_ref).is_file() and (cad_root / scan_relative_ref).is_file():
return scan_relative_ref
return file_ref
def _resolve_srdf_urdf_path(srdf_source: SrdfSource, *, srdf_path: Path, repo_root: Path) -> Path:
raw_value = str(srdf_source.urdf_ref or "").strip()
if not raw_value:
raise MotionProtocolError("SRDF must include tcad:urdf metadata")
if "\\" in raw_value:
raise MotionProtocolError("SRDF tcad:urdf path must use POSIX '/' separators")
raw_path = Path(raw_value)
if raw_path.is_absolute():
raise MotionProtocolError("SRDF tcad:urdf path must be relative")
candidate = (srdf_path.parent / raw_path).resolve()
if not _path_is_inside(candidate, repo_root):
raise MotionProtocolError(f"SRDF tcad:urdf path must stay inside the repository: {raw_value}")
if candidate.suffix.lower() != ".urdf":
raise MotionProtocolError(f"SRDF tcad:urdf path must end in .urdf: {raw_value}")
if not candidate.is_file():
raise MotionProtocolError(f"SRDF tcad:urdf file does not exist: {raw_value}")
return candidate
def _urdf_robot(urdf_path: Path) -> dict[str, Any]:
try:
root = ET.parse(urdf_path).getroot()
except FileNotFoundError as exc:
raise MotionProtocolError(f"URDF file does not exist: {urdf_path}") from exc
except ET.ParseError as exc:
raise MotionProtocolError(f"URDF file is invalid XML: {urdf_path}") from exc
if root.tag != "robot":
raise MotionProtocolError("URDF root must be <robot>")
links = {
str(link.get("name") or "").strip()
for link in root.findall("link")
if str(link.get("name") or "").strip()
}
joints: dict[str, dict[str, str]] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
if not name:
continue
parent_element = joint.find("parent")
child_element = joint.find("child")
joint_type = str(joint.get("type") or "").strip()
limit_element = joint.find("limit")
lower = _optional_finite_float(limit_element.get("lower") if limit_element is not None else None)
upper = _optional_finite_float(limit_element.get("upper") if limit_element is not None else None)
joints[name] = {
"type": joint_type,
"parent": str(parent_element.get("link") if parent_element is not None else "").strip(),
"child": str(child_element.get("link") if child_element is not None else "").strip(),
"lower": lower if joint_type in {"revolute", "prismatic"} else None,
"upper": upper if joint_type in {"revolute", "prismatic"} else None,
"mimic": joint.find("mimic") is not None,
}
return {
"name": str(root.get("name") or "").strip(),
"links": links,
"joints": joints,
}
def _optional_finite_float(value: Any) -> float | None:
if value is None:
return None
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if isfinite(parsed) else None
def _active_joint_names(robot: dict[str, Any]) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name, joint in joints.items()
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic"))
]
def _all_joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
if not base_link or not tip_link:
return []
if base_link == tip_link:
return []
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
by_parent: dict[str, list[tuple[str, dict[str, Any]]]] = {}
for raw_name, raw_joint in joints.items():
if not isinstance(raw_joint, dict):
continue
parent = str(raw_joint.get("parent") or "").strip()
child = str(raw_joint.get("child") or "").strip()
if parent and child:
by_parent.setdefault(parent, []).append((str(raw_name), raw_joint))
stack: list[tuple[str, list[tuple[str, dict[str, Any]]]]] = [(base_link, [])]
visited: set[str] = set()
while stack:
link_name, path = stack.pop()
if link_name == tip_link:
return [joint_name for joint_name, _joint in path]
if link_name in visited:
continue
visited.add(link_name)
for joint_name, joint in reversed(by_parent.get(link_name, [])):
child = str(joint.get("child") or "").strip()
if child:
stack.append((child, [*path, (joint_name, joint)]))
return []
def _joint_names_for_chain(robot: dict[str, Any], *, base_link: str, tip_link: str) -> list[str]:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
names: list[str] = []
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
joint = joints.get(joint_name)
if isinstance(joint, dict) and str(joint.get("type") or "").strip() != "fixed" and not bool(joint.get("mimic")):
names.append(joint_name)
return names
def _append_unique(target: list[str], values: list[str]) -> None:
seen = set(target)
for value in values:
if value not in seen:
target.append(value)
seen.add(value)
def _joint_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> list[str]:
explicit_joint_names = list(group.get("jointNames") or [])
if explicit_joint_names:
joints = robot.get("joints")
if not isinstance(joints, dict):
return []
return [
str(name)
for name in explicit_joint_names
if isinstance(joints.get(str(name)), dict)
and str(joints[str(name)].get("type") or "").strip() != "fixed"
and not bool(joints[str(name)].get("mimic"))
]
names: list[str] = []
for chain in group.get("chains") or []:
if not isinstance(chain, dict):
continue
_append_unique(
names,
_joint_names_for_chain(
robot,
base_link=str(chain.get("baseLink") or "").strip(),
tip_link=str(chain.get("tipLink") or "").strip(),
),
)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups") or []:
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
_append_unique(
names,
_joint_names_for_group(
subgroup,
robot=robot,
groups_by_name=groups_by_name,
visited=visited,
),
)
return names
def _positive_float(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be positive") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be positive")
return numeric
def _positive_int(value: Any, *, label: str, default: int) -> int:
if value is None:
value = default
try:
numeric = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a positive integer") from exc
if numeric <= 0:
raise MotionProtocolError(f"{label} must be a positive integer")
return numeric
def _scaling(value: Any, *, label: str, default: float) -> float:
if value is None:
value = default
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be between 0 and 1") from exc
if numeric <= 0 or numeric > 1:
raise MotionProtocolError(f"{label} must be between 0 and 1")
return numeric
def _boolean(value: Any, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
def _string(value: Any, *, label: str, default: str = "") -> str:
normalized = str(value if value is not None else default).strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _optional_object(value: Any, *, label: str) -> dict[str, Any]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
return value
def _validate_srdf_inventory(metadata: dict[str, Any], *, robot: dict[str, Any]) -> dict[str, Any]:
if str(metadata.get("robotName") or "").strip() != str(robot.get("name") or "").strip():
raise MotionProtocolError("SRDF robotName must match the linked URDF robot name")
link_names = robot.get("links")
joint_payload = robot.get("joints")
if not isinstance(link_names, set) or not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
joint_names = set(joint_payload)
groups = _validate_planning_groups(metadata.get("planningGroups"), link_names=link_names, joint_names=joint_names)
group_by_name = {group["name"]: group for group in groups}
end_effectors = _validate_end_effectors(
metadata.get("endEffectors"),
link_names=link_names,
groups=group_by_name,
robot=robot,
)
group_states = _validate_group_states(metadata.get("groupStates"), groups=group_by_name, robot=robot)
disabled_pairs = _validate_disabled_pairs(metadata.get("disabledCollisionPairs"), link_names=link_names)
return {
**metadata,
"planningGroups": groups,
"endEffectors": end_effectors,
"groupStates": group_states,
"disabledCollisionPairs": disabled_pairs,
}
def _group_tip_link(group: Any) -> str:
if isinstance(group, dict):
link_names = group.get("linkNames")
if isinstance(link_names, list) and link_names:
return str(link_names[-1])
chains = group.get("chains")
if isinstance(chains, list) and chains:
chain = chains[-1]
if isinstance(chain, dict):
return str(chain.get("tipLink") or "")
return ""
link_names = getattr(group, "link_names", ())
if link_names:
return str(link_names[-1])
chains = getattr(group, "chains", ())
if chains:
return str(getattr(chains[-1], "tip_link", "") or "")
return ""
def _end_effector_link(end_effector: Any, groups_by_name: dict[str, Any]) -> str:
group_name = str(getattr(end_effector, "group", "") or "")
group = groups_by_name.get(group_name)
if group is not None:
group_tip = _group_tip_link(group)
if group_tip:
return group_tip
return ""
def _srdf_inventory_from_source(srdf_source: SrdfSource, *, srdf_path: Path, urdf_path: Path) -> dict[str, Any]:
groups_by_name = {group.name: group for group in srdf_source.planning_groups}
return {
"urdf": str(urdf_path),
"srdf": str(srdf_path),
"robotName": srdf_source.robot_name,
"planningGroups": [
{
"name": group.name,
"jointNames": list(group.joint_names),
"linkNames": list(group.link_names),
"chains": [
{"baseLink": chain.base_link, "tipLink": chain.tip_link}
for chain in group.chains
],
"subgroups": list(group.subgroups),
}
for group in srdf_source.planning_groups
],
"endEffectors": [
{
"name": end_effector.name,
"link": _end_effector_link(end_effector, groups_by_name) or end_effector.parent_link,
"parentLink": end_effector.parent_link,
"group": end_effector.group,
"parentGroup": end_effector.parent_group,
}
for end_effector in srdf_source.end_effectors
],
"groupStates": [
{
"name": group_state.name,
"group": group_state.group,
"jointValuesByName": group_state.joint_values_by_name,
"jointValuesByNameRad": group_state.joint_values_by_name,
}
for group_state in srdf_source.group_states
],
"disabledCollisionPairs": [
{
"link1": pair.link1,
"link2": pair.link2,
"reason": pair.reason,
"source": pair.source,
}
for pair in srdf_source.disabled_collision_pairs
],
}
def _validate_planning_groups(value: Any, *, link_names: set[str], joint_names: set[str]) -> list[dict[str, Any]]:
if not isinstance(value, list) or not value:
raise MotionProtocolError("SRDF planningGroups must be a non-empty array")
groups: list[dict[str, Any]] = []
seen = set()
for raw_group in value:
if not _plain_object(raw_group):
raise MotionProtocolError("SRDF planning group must be an object")
name = _string(raw_group.get("name"), label="SRDF planning group name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF planning group {name}")
seen.add(name)
group_joint_names = _validate_names(raw_group.get("jointNames"), allowed=joint_names, label=f"SRDF planning group {name} jointNames")
group_link_names = _validate_names(raw_group.get("linkNames"), allowed=link_names, label=f"SRDF planning group {name} linkNames")
chains = raw_group.get("chains") if isinstance(raw_group.get("chains"), list) else []
normalized_chains: list[dict[str, str]] = []
for raw_chain in chains:
if not _plain_object(raw_chain):
raise MotionProtocolError(f"SRDF planning group {name} chains must contain objects")
base_link = _string(raw_chain.get("baseLink"), label=f"SRDF planning group {name} chain baseLink")
tip_link = _string(raw_chain.get("tipLink"), label=f"SRDF planning group {name} chain tipLink")
if base_link not in link_names or tip_link not in link_names:
raise MotionProtocolError(f"SRDF planning group {name} chain references missing URDF link")
normalized_chains.append({"baseLink": base_link, "tipLink": tip_link})
subgroups = [str(group).strip() for group in raw_group.get("subgroups", []) if str(group).strip()]
groups.append({
"name": name,
"jointNames": group_joint_names,
"linkNames": group_link_names,
"chains": normalized_chains,
"subgroups": subgroups,
})
group_names = {group["name"] for group in groups}
for group in groups:
for subgroup in group["subgroups"]:
if subgroup not in group_names:
raise MotionProtocolError(f"SRDF planning group {group['name']} references missing subgroup {subgroup}")
return groups
def _validate_names(value: Any, *, allowed: set[str], label: str) -> list[str]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError(f"{label} must be an array")
names: list[str] = []
seen = set()
for raw_name in value:
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty names")
if name in seen:
raise MotionProtocolError(f"{label} includes duplicate {name}")
if name not in allowed:
raise MotionProtocolError(f"{label} references missing name {name}")
seen.add(name)
names.append(name)
return names
def _link_names_for_group(
group: dict[str, Any],
*,
robot: dict[str, Any],
groups_by_name: dict[str, dict[str, Any]],
visited: set[str] | None = None,
) -> set[str]:
links = set(str(link) for link in group.get("linkNames", []) if str(link))
joints = robot.get("joints")
if not isinstance(joints, dict):
return links
def add_joint_links(joint_name: str) -> None:
joint = joints.get(joint_name)
if not isinstance(joint, dict):
return
child = str(joint.get("child") or "").strip()
if child:
links.add(child)
for joint_name in group.get("jointNames", []):
add_joint_links(str(joint_name))
for chain in group.get("chains", []):
if not isinstance(chain, dict):
continue
base_link = str(chain.get("baseLink") or "").strip()
tip_link = str(chain.get("tipLink") or "").strip()
if tip_link:
links.add(tip_link)
for joint_name in _all_joint_names_for_chain(robot, base_link=base_link, tip_link=tip_link):
add_joint_links(joint_name)
if visited is None:
visited = set()
group_name = str(group.get("name") or "").strip()
if group_name:
visited.add(group_name)
for subgroup_name in group.get("subgroups", []):
subgroup_key = str(subgroup_name or "").strip()
if not subgroup_key or subgroup_key in visited:
continue
subgroup = groups_by_name.get(subgroup_key)
if subgroup is not None:
links.update(_link_names_for_group(subgroup, robot=robot, groups_by_name=groups_by_name, visited=visited))
links.discard("")
return links
def _joint_adjacent_to_any_link(robot: dict[str, Any], parent_link: str, child_links: set[str]) -> bool:
joints = robot.get("joints")
if not isinstance(joints, dict):
return False
for joint in joints.values():
if not isinstance(joint, dict):
continue
parent = str(joint.get("parent") or "").strip()
child = str(joint.get("child") or "").strip()
if (parent == parent_link and child in child_links) or (child == parent_link and parent in child_links):
return True
return False
def _validate_end_effectors(
value: Any,
*,
link_names: set[str],
groups: dict[str, dict[str, Any]],
robot: dict[str, Any],
) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF endEffectors must be an array")
end_effectors: list[dict[str, Any]] = []
seen = set()
for raw_end_effector in value:
if not _plain_object(raw_end_effector):
raise MotionProtocolError("SRDF end effector must be an object")
name = _string(raw_end_effector.get("name"), label="SRDF end effector name")
if name in seen:
raise MotionProtocolError(f"Duplicate SRDF end effector {name}")
seen.add(name)
parent_link = _string(raw_end_effector.get("parentLink"), label=f"SRDF end effector {name} parentLink")
if parent_link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentLink {parent_link}")
group_name = _string(raw_end_effector.get("group"), label=f"SRDF end effector {name} group")
if group_name not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing group {group_name}")
parent_group = str(raw_end_effector.get("parentGroup") or "").strip()
if parent_group and parent_group not in groups:
raise MotionProtocolError(f"SRDF end effector {name} references missing parentGroup {parent_group}")
link = str(raw_end_effector.get("link") or _group_tip_link(groups[group_name]) or parent_link).strip()
if link not in link_names:
raise MotionProtocolError(f"SRDF end effector {name} references missing link {link}")
end_effector_links = _link_names_for_group(groups[group_name], robot=robot, groups_by_name=groups)
if parent_group:
parent_group_links = _link_names_for_group(groups[parent_group], robot=robot, groups_by_name=groups)
overlap = sorted(end_effector_links & parent_group_links)
if overlap:
raise MotionProtocolError(f"SRDF end effector {name} group shares link(s) with parentGroup: {overlap}")
if parent_link not in parent_group_links:
raise MotionProtocolError(
f"SRDF end effector {name} parentLink {parent_link} is not in parentGroup {parent_group}"
)
if end_effector_links and parent_link not in end_effector_links and not _joint_adjacent_to_any_link(
robot,
parent_link,
end_effector_links,
):
raise MotionProtocolError(f"SRDF end effector {name} parentLink is not adjacent to its group")
end_effectors.append({
"name": name,
"link": link,
"parentLink": parent_link,
"group": group_name,
"parentGroup": parent_group,
})
return end_effectors
def _validate_group_state_joint_value(state_name: str, joint_name: str, value: float, joint: dict[str, Any]) -> None:
joint_type = str(joint.get("type") or "").strip()
if joint_type == "fixed":
raise MotionProtocolError(f"SRDF group state {state_name} cannot set fixed joint {joint_name}")
if bool(joint.get("mimic")):
raise MotionProtocolError(f"SRDF group state {state_name} cannot set mimic joint {joint_name}")
if joint_type == "continuous":
return
lower = joint.get("lower")
upper = joint.get("upper")
if isinstance(lower, float) and value < lower:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is below its URDF lower limit")
if isinstance(upper, float) and value > upper:
raise MotionProtocolError(f"SRDF group state {state_name} joint {joint_name} is above its URDF upper limit")
def _validate_group_states(value: Any, *, groups: dict[str, dict[str, Any]], robot: dict[str, Any]) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF groupStates must be an array")
states: list[dict[str, Any]] = []
seen_keys: set[str] = set()
joint_payload = robot.get("joints")
if not isinstance(joint_payload, dict):
raise MotionProtocolError("URDF robot inventory is invalid")
for raw_state in value:
if not _plain_object(raw_state):
raise MotionProtocolError("SRDF group state must be an object")
name = _string(raw_state.get("name"), label="SRDF group state name")
group = _string(raw_state.get("group"), label=f"SRDF group state {name} group")
state_key = f"{group}/{name}"
if state_key in seen_keys:
raise MotionProtocolError(f"Duplicate SRDF group state {state_key}")
seen_keys.add(state_key)
if group not in groups:
raise MotionProtocolError(f"SRDF group state {name} references missing group {group}")
group_joint_names = set(_joint_names_for_group(groups[group], robot=robot, groups_by_name=groups))
joint_values = raw_state.get("jointValuesByName")
legacy_joint_values = raw_state.get("jointValuesByNameRad")
if joint_values is None and legacy_joint_values is not None:
joint_values = legacy_joint_values
if not _plain_object(joint_values):
raise MotionProtocolError(f"SRDF group state {name} jointValuesByName must be an object")
parsed_values: dict[str, float] = {}
for raw_joint_name, raw_value in joint_values.items():
joint_name = str(raw_joint_name or "").strip()
joint = joint_payload.get(joint_name)
if not isinstance(joint, dict):
raise MotionProtocolError(f"SRDF group state {name} references missing joint {joint_name}")
if joint_name not in group_joint_names:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} is not in group {group}")
try:
parsed_value = float(raw_value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be numeric") from exc
if not isfinite(parsed_value):
raise MotionProtocolError(f"SRDF group state {name} joint {joint_name} must be finite")
_validate_group_state_joint_value(name, joint_name, parsed_value, joint)
parsed_values[joint_name] = parsed_value
states.append({"name": name, "group": group, "jointValuesByName": parsed_values, "jointValuesByNameRad": parsed_values})
return states
def _validate_disabled_pairs(value: Any, *, link_names: set[str]) -> list[dict[str, str]]:
if value is None:
return []
if not isinstance(value, list):
raise MotionProtocolError("SRDF disabledCollisionPairs must be an array")
pairs: list[dict[str, str]] = []
seen: set[str] = set()
for raw_pair in value:
if not _plain_object(raw_pair):
raise MotionProtocolError("SRDF disabled collision pair must be an object")
link1 = _string(raw_pair.get("link1"), label="SRDF disabled collision link1")
link2 = _string(raw_pair.get("link2"), label="SRDF disabled collision link2")
if link1 == link2:
raise MotionProtocolError("SRDF disabled collision pair cannot repeat the same link")
if link1 not in link_names or link2 not in link_names:
raise MotionProtocolError("SRDF disabled collision pair references missing URDF link")
pair_key = "/".join(sorted((link1, link2)))
if pair_key in seen:
raise MotionProtocolError(f"Duplicate SRDF disabled collision pair {pair_key}")
seen.add(pair_key)
reason = str(raw_pair.get("reason") or "").strip()
if not reason:
raise MotionProtocolError(f"SRDF disabled collision pair {pair_key} requires a reason")
pairs.append({
"link1": link1,
"link2": link2,
"reason": reason,
"source": str(raw_pair.get("source") or "manual").strip(),
})
return pairs
def _moveit_settings(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("moveit2"), label="moveit2")
def _target_payload(payload: Any) -> dict[str, Any]:
if not _plain_object(payload):
return {}
return _optional_object(payload.get("target"), label="target")
def _find_group(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for group in metadata.get("planningGroups", []):
if str(group.get("name") or "") == name:
return group
raise MotionProtocolError(f"Selected planning group {name} is not defined by the SRDF")
def _find_end_effector(metadata: dict[str, Any], name: str) -> dict[str, Any]:
for end_effector in metadata.get("endEffectors", []):
if str(end_effector.get("name") or "") == name:
return end_effector
raise MotionProtocolError(f"Selected end effector {name} is not defined by the SRDF")
def _default_end_effector(metadata: dict[str, Any], planning_group_name: str) -> dict[str, Any] | None:
end_effectors = list(metadata.get("endEffectors", []))
for end_effector in end_effectors:
if str(end_effector.get("parentGroup") or "") == planning_group_name:
return end_effector
return end_effectors[0] if end_effectors else None
def _build_command(
*,
command_name: str,
payload: Any,
metadata: dict[str, Any],
robot: dict[str, Any],
) -> dict[str, Any]:
moveit2 = _moveit_settings(payload)
target = _target_payload(payload)
default_group = metadata["planningGroups"][0]
planning_group_name = _string(
moveit2.get("planningGroup"),
label="moveit2.planningGroup",
default=str(default_group.get("name") or ""),
)
planning_group = _find_group(metadata, planning_group_name)
default_end_effector = _default_end_effector(metadata, planning_group_name)
selected_end_effector_name = str(moveit2.get("endEffector") or target.get("endEffector") or "").strip()
selected_end_effector = _find_end_effector(metadata, selected_end_effector_name) if selected_end_effector_name else default_end_effector
if selected_end_effector is None:
raise MotionProtocolError("SRDF must define an end effector for MoveIt2 pose requests")
robot_links = robot.get("links")
if not isinstance(robot_links, set):
raise MotionProtocolError("URDF robot inventory is invalid")
target_frame = _string(
moveit2.get("targetFrame") or target.get("frame"),
label="moveit2.targetFrame",
default=next(iter(sorted(robot_links)), ""),
)
if target_frame not in robot_links:
raise MotionProtocolError(f"moveit2.targetFrame references missing URDF link {target_frame}")
ik = _optional_object(moveit2.get("ik"), label="moveit2.ik")
planning = _optional_object(moveit2.get("planning"), label="moveit2.planning")
target_has_orientation = "quat_xyzw" in target or "rpy" in target
position_only_ik = _boolean(ik.get("positionOnly"), default=not target_has_orientation)
if not target_has_orientation and not position_only_ik:
raise MotionProtocolError("target orientation is required when moveit2.ik.positionOnly is false")
groups_by_name = {
str(group.get("name") or ""): group
for group in metadata.get("planningGroups", [])
if isinstance(group, dict) and str(group.get("name") or "")
}
joint_names = _joint_names_for_group(planning_group, robot=robot, groups_by_name=groups_by_name)
target_link = _string(
moveit2.get("targetLink") or target.get("targetLink") or target.get("link"),
label="moveit2.targetLink",
default=str(selected_end_effector.get("link") or ""),
)
if target_link not in robot_links:
raise MotionProtocolError(f"moveit2.targetLink references missing URDF link {target_link}")
command = {
"planningGroup": planning_group_name,
"jointNames": joint_names,
"endEffectors": [
{
"name": selected_end_effector["name"],
"link": target_link,
"inferredLink": selected_end_effector["link"],
"frame": target_frame,
"planningGroup": planning_group_name,
"jointNames": joint_names,
"positionTolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
}
],
"ik": {
"timeout": _positive_float(ik.get("timeout"), label="moveit2.ik.timeout", default=DEFAULT_IK_TIMEOUT),
"attempts": _positive_int(ik.get("attempts"), label="moveit2.ik.attempts", default=DEFAULT_IK_ATTEMPTS),
"positionOnly": position_only_ik,
"tolerance": _positive_float(
ik.get("tolerance", moveit2.get("positionTolerance")),
label="moveit2.ik.tolerance",
default=DEFAULT_POSITION_TOLERANCE,
),
},
"planner": {
"pipeline": _string(planning.get("pipeline"), label="moveit2.planning.pipeline", default=DEFAULT_PLANNING_PIPELINE),
"plannerId": _string(planning.get("plannerId"), label="moveit2.planning.plannerId", default=DEFAULT_PLANNER_ID),
"planningTime": _positive_float(planning.get("planningTime"), label="moveit2.planning.planningTime", default=DEFAULT_PLANNING_TIME),
"maxVelocityScalingFactor": _scaling(
planning.get("maxVelocityScalingFactor"),
label="moveit2.planning.maxVelocityScalingFactor",
default=1.0,
),
"maxAccelerationScalingFactor": _scaling(
planning.get("maxAccelerationScalingFactor"),
label="moveit2.planning.maxAccelerationScalingFactor",
default=1.0,
),
},
}
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name}")
return command
def build_moveit2_context(
*,
repo_root: str | Path,
dir: Any = DEFAULT_CAD_DIRECTORY,
file: Any,
type: Any,
payload: Any = None,
) -> dict[str, Any]:
command_name = str(type or "").strip()
if command_name not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {command_name or '(missing)'}")
resolved_repo_root = Path(repo_root).resolve()
cad_dir = normalize_cad_directory(dir)
file_ref = normalize_file_ref(file)
cad_root = (resolved_repo_root / cad_dir).resolve()
if not _path_is_inside(cad_root, resolved_repo_root):
raise MotionProtocolError(f"dir must stay inside the repository: {cad_dir}")
file_ref = _file_ref_relative_to_cad_dir(file_ref, cad_dir=cad_dir, cad_root=cad_root)
srdf_path = (cad_root / file_ref).resolve()
if not _path_is_inside(srdf_path, cad_root):
root_label = cad_dir or "the repository"
raise MotionProtocolError(f"file must stay inside {root_label}: {file_ref}")
if not srdf_path.is_file():
raise MotionProtocolError(f"SRDF file does not exist: {file_ref}")
try:
srdf_source = read_srdf_source(srdf_path)
except Exception as exc:
raise MotionProtocolError(str(exc)) from exc
urdf_path = _resolve_srdf_urdf_path(srdf_source, srdf_path=srdf_path, repo_root=resolved_repo_root)
robot = _urdf_robot(urdf_path)
inventory_payload = _srdf_inventory_from_source(srdf_source, srdf_path=srdf_path, urdf_path=urdf_path)
srdf_inventory = _validate_srdf_inventory(inventory_payload, robot=robot)
command = _build_command(command_name=command_name, payload=payload, metadata=srdf_inventory, robot=robot)
version_paths = [urdf_path, srdf_path]
return {
"repoRoot": str(resolved_repo_root),
"dir": cad_dir,
"file": file_ref,
"urdfPath": str(urdf_path),
"srdfPath": str(srdf_path),
"modelAssetHash": _combined_version(version_paths),
"commandName": command_name,
"command": command,
"srdf": srdf_inventory,
}
@@ -0,0 +1,29 @@
from __future__ import annotations
from functools import lru_cache
from typing import Any
from moveit2_server.protocol import MotionProtocolError, MotionRequest
from moveit2_server.moveit_py import MoveItPyAdapter
@lru_cache(maxsize=1)
def _adapter() -> MoveItPyAdapter:
return MoveItPyAdapter()
def dispatch(request: MotionRequest) -> dict[str, Any]:
adapter = _adapter()
if request.type == "srdf.solvePose":
result = adapter.solve_pose(request)
elif request.type == "srdf.planToPose":
result = adapter.plan_to_pose(request)
else:
raise MotionProtocolError(f"Unsupported request type {request.type}")
if not isinstance(result, dict):
raise MotionProtocolError("MoveIt2 adapter result must be an object")
if result.get("ok") is False:
raise RuntimeError(str(result.get("message") or "MoveIt2 request failed"))
result.pop("ok", None)
return result
@@ -0,0 +1,61 @@
from __future__ import annotations
import argparse
import json
import time
from typing import Any
def _json_list(value: str, label: str) -> list[Any]:
payload = json.loads(value)
if not isinstance(payload, list):
raise ValueError(f"{label} must be a JSON array")
return payload
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="python -m moveit2_server.joint_state_seed")
parser.add_argument("--names", required=True, help="JSON array of joint names")
parser.add_argument("--positions", required=True, help="JSON array of joint positions in ROS units")
parser.add_argument("--duration", type=float, default=8.0)
parser.add_argument("--rate", type=float, default=30.0)
args = parser.parse_args(argv)
names = [str(name) for name in _json_list(args.names, "names")]
positions = [float(position) for position in _json_list(args.positions, "positions")]
if len(names) != len(positions):
raise ValueError("names and positions must have the same length")
if args.duration <= 0 or args.rate <= 0:
raise ValueError("duration and rate must be positive")
import rclpy
from sensor_msgs.msg import JointState
rclpy.init(args=None)
node = rclpy.create_node("moveit2_server_joint_state_seed")
publisher = node.create_publisher(JointState, "/joint_states", 10)
deadline = time.monotonic() + args.duration
period = 1.0 / args.rate
try:
while time.monotonic() < deadline and rclpy.ok():
message = JointState()
message.header.stamp = node.get_clock().now().to_msg()
message.name = names
message.position = positions
publisher.publish(message)
rclpy.spin_once(node, timeout_sec=0.0)
time.sleep(period)
finally:
try:
node.destroy_node()
except Exception:
pass
try:
rclpy.shutdown()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,589 @@
from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
import subprocess
import sys
from typing import Any
import xml.etree.ElementTree as ET
from moveit2_server.protocol import (
MotionProtocolError,
normalize_joint_values,
normalize_motion_target,
)
def _deg_to_rad(value: float) -> float:
return (float(value) * math.pi) / 180.0
def _rad_to_deg(value: float) -> float:
return (float(value) * 180.0) / math.pi
def _solve_command(request: Any) -> dict[str, Any]:
return request.command
def _target_end_effector_name(request: Any) -> str:
target = request.payload.get("target") if isinstance(request.payload, dict) else None
if not isinstance(target, dict):
return ""
return str(target.get("endEffector") or "").strip()
def _selected_end_effector(request: Any) -> dict[str, Any]:
command = _solve_command(request)
end_effectors = command.get("endEffectors", [])
target_name = _target_end_effector_name(request)
if target_name:
for end_effector in end_effectors:
if str(end_effector.get("name", "")).strip() == target_name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {target_name}")
for end_effector in end_effectors:
return end_effector
raise MotionProtocolError("moveit_py command endEffectors must be non-empty")
def _joint_names(request: Any) -> list[str]:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
joint_names = [
str(name).strip()
for name in end_effector.get("jointNames", command.get("jointNames", []))
if str(name).strip()
]
if not joint_names:
raise MotionProtocolError("moveit_py command jointNames must be non-empty")
return joint_names
def _end_effector(request: Any, name: str) -> dict[str, Any]:
end_effector = _selected_end_effector(request)
if str(end_effector.get("name", "")).strip() == name:
return end_effector
raise MotionProtocolError(f"Unknown end effector {name}")
def _planning_group(request: Any) -> str:
command = _solve_command(request)
end_effector = _selected_end_effector(request)
planning_group = str(
end_effector.get("planningGroup")
or command.get("planningGroup")
or request.command.get("planningGroup")
or ""
).strip()
if not planning_group:
raise MotionProtocolError("moveit_py command planningGroup must be non-empty")
return planning_group
def _robot_description_for_moveit(urdf_path: Path) -> str:
"""Return URDF XML with relative mesh paths rewritten as file URIs."""
try:
tree = ET.parse(urdf_path)
except ET.ParseError:
return urdf_path.read_text(encoding="utf-8")
root = tree.getroot()
changed = False
for mesh in root.findall(".//mesh"):
filename = str(mesh.get("filename") or "").strip()
if not filename or "://" in filename or filename.startswith("package:"):
continue
path = Path(filename)
if not path.is_absolute():
path = urdf_path.parent / path
mesh.set("filename", path.resolve().as_uri())
changed = True
if not changed:
return urdf_path.read_text(encoding="utf-8")
return ET.tostring(root, encoding="unicode")
def _active_urdf_joints(urdf_path: Path) -> list[tuple[str, str]]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return []
joints: list[tuple[str, str]] = []
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name and joint_type != "fixed":
joints.append((name, joint_type))
return joints
def _urdf_joint_types(urdf_path: Path) -> dict[str, str]:
try:
root = ET.parse(urdf_path).getroot()
except (OSError, ET.ParseError):
return {}
joint_types: dict[str, str] = {}
for joint in root.findall("joint"):
name = str(joint.get("name") or "").strip()
joint_type = str(joint.get("type") or "").strip()
if name:
joint_types[name] = joint_type
return joint_types
def _joint_types_for_request(request: Any) -> dict[str, str]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _urdf_joint_types(urdf_path)
if joint_types:
return joint_types
command = _solve_command(request)
fallback_names = [str(name).strip() for name in command.get("jointNames", []) if str(name).strip()]
if not fallback_names:
fallback_names = _joint_names(request)
return {name: "revolute" for name in fallback_names}
def _native_start_joint_values(request: Any, joint_types: dict[str, str] | None = None) -> dict[str, float]:
joint_types = joint_types or _joint_types_for_request(request)
payload = request.payload if isinstance(request.payload, dict) else {}
if "startJointValuesByName" in payload:
return normalize_joint_values(payload.get("startJointValuesByName"), label="startJointValuesByName")
legacy_values = normalize_joint_values(payload.get("startJointValuesByNameDeg"), label="startJointValuesByNameDeg")
return {
name: float(value) if str(joint_types.get(name) or "").strip() == "prismatic" else _deg_to_rad(value)
for name, value in legacy_values.items()
}
def _native_to_legacy_joint_value(value: float, joint_type: str) -> float:
return float(value) if str(joint_type or "").strip() == "prismatic" else _rad_to_deg(value)
def _native_to_legacy_joint_values(values: dict[str, float], joint_types: dict[str, str]) -> dict[str, float]:
return {
name: _native_to_legacy_joint_value(value, str(joint_types.get(name) or "revolute"))
for name, value in values.items()
}
def _moveit2_groups(request: Any) -> list[dict[str, Any]]:
srdf = request.context.get("srdf", {}) if isinstance(request.context, dict) else {}
groups = srdf.get("planningGroups", []) if isinstance(srdf, dict) else []
return [group for group in groups if isinstance(group, dict)]
def _supports_kdl_kinematics(group: dict[str, Any]) -> bool:
if group.get("subgroups"):
return False
return bool(group.get("jointNames") or group.get("chains"))
def _kinematics_config(request: Any) -> dict[str, Any]:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return {
str(group.get("name")): {
"kinematics_solver": "kdl_kinematics_plugin/KDLKinematicsPlugin",
"kinematics_solver_search_resolution": 0.005,
"kinematics_solver_timeout": float(ik.get("timeout") or 0.05),
"position_only_ik": bool(ik.get("positionOnly", True)),
}
for group in _moveit2_groups(request)
if str(group.get("name") or "").strip() and _supports_kdl_kinematics(group)
}
def _planning_pipeline_config(request: Any) -> dict[str, Any]:
planner = request.command.get("planner", {}) if isinstance(request.command, dict) else {}
pipeline = str(planner.get("pipeline") or "ompl")
planner_id = str(planner.get("plannerId") or "RRTConnectkConfigDefault")
planning_time = float(planner.get("planningTime") or 1.0)
def request_params() -> dict[str, Any]:
return {
"planning_attempts": int(request.command.get("ik", {}).get("attempts") or 1),
"planning_pipeline": pipeline,
"planner_id": planner_id,
"planning_time": planning_time,
"max_velocity_scaling_factor": float(planner.get("maxVelocityScalingFactor") or 1.0),
"max_acceleration_scaling_factor": float(planner.get("maxAccelerationScalingFactor") or 1.0),
}
return {
"planning_pipelines": {
"pipeline_names": [pipeline],
},
pipeline: {
"planning_plugin": "ompl_interface/OMPLPlanner",
"planning_plugins": ["ompl_interface/OMPLPlanner"],
"request_adapters": [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
],
"start_state_max_bounds_error": 0.1,
},
"plan_request_params": request_params(),
"ompl_rrtc": {
"plan_request_params": request_params(),
},
}
def _joint_state_seed(request: Any) -> tuple[list[str], list[float]]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
joint_types = _joint_types_for_request(request)
joint_values = _native_start_joint_values(request, joint_types)
joints = _active_urdf_joints(urdf_path)
if not joints:
joints = [(name, "revolute") for name in _joint_names(request)]
names: list[str] = []
positions: list[float] = []
for name, joint_type in joints:
names.append(name)
del joint_type
positions.append(float(joint_values.get(name, 0.0)))
return names, positions
class _JointStateSeeder:
def __init__(self, request: Any, *, duration_sec: float = 8.0, rate_hz: float = 30.0) -> None:
self._request = request
self._duration_sec = duration_sec
self._rate_hz = rate_hz
self._process: subprocess.Popen[bytes] | None = None
def start(self) -> None:
names, positions = _joint_state_seed(self._request)
if not names:
return
self._process = subprocess.Popen(
[
sys.executable,
"-m",
"moveit2_server.joint_state_seed",
"--names",
json.dumps(names),
"--positions",
json.dumps(positions),
"--duration",
str(self._duration_sec),
"--rate",
str(self._rate_hz),
],
stdout=subprocess.DEVNULL,
)
def stop(self) -> None:
if not self._process or self._process.poll() is not None:
return
self._process.terminate()
try:
self._process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=1.0)
class MoveItPyAdapter:
"""MoveIt 2 adapter used by the local ROS 2 MoveIt2 server."""
def __init__(self) -> None:
self._moveit_by_key: dict[str, Any] = {}
def _config_dict(self, request: Any) -> dict[str, Any]:
urdf_path = Path(str(request.context.get("urdfPath") or ""))
srdf_path = Path(str(request.context.get("srdfPath") or ""))
if not urdf_path.is_file():
raise RuntimeError(f"URDF file does not exist: {urdf_path}")
config: dict[str, Any] = {
"robot_description": _robot_description_for_moveit(urdf_path),
"robot_description_kinematics": _kinematics_config(request),
}
if srdf_path.is_file():
config["robot_description_semantic"] = srdf_path.read_text(encoding="utf-8")
config.update(_planning_pipeline_config(request))
config["planning_scene_monitor_options"] = {
"name": "planning_scene_monitor",
"robot_description": "robot_description",
"joint_state_topic": "/joint_states",
"attached_collision_object_topic": "/moveit_cpp/planning_scene_monitor",
"publish_planning_scene_topic": "/moveit_cpp/publish_planning_scene",
"monitored_planning_scene_topic": "/moveit_cpp/monitored_planning_scene",
"wait_for_initial_state_timeout": 5.0,
}
ompl_config = config.setdefault("ompl", {})
if isinstance(ompl_config, dict):
ompl_config.setdefault("planning_plugin", "ompl_interface/OMPLPlanner")
ompl_config.setdefault("planning_plugins", ["ompl_interface/OMPLPlanner"])
ompl_config.setdefault("request_adapters", [
"default_planning_request_adapters/ResolveConstraintFrames",
"default_planning_request_adapters/ValidateWorkspaceBounds",
"default_planning_request_adapters/CheckStartStateBounds",
"default_planning_request_adapters/CheckStartStateCollision",
])
ompl_config.setdefault("start_state_max_bounds_error", 0.1)
planning_scene_monitor_options = config.setdefault("planning_scene_monitor_options", {})
if isinstance(planning_scene_monitor_options, dict):
current_timeout = float(planning_scene_monitor_options.get("wait_for_initial_state_timeout") or 0.0)
planning_scene_monitor_options["wait_for_initial_state_timeout"] = max(current_timeout, 5.0)
return config
def _moveit(self, request: Any) -> Any:
try:
import rclpy
from moveit.planning import MoveItPy
except ImportError as exc:
raise RuntimeError(
"moveit_py adapter requires a ROS 2 environment with rclpy and moveit_py. "
f"Current interpreter: {sys.executable}. Start moveit2_server from a RoboStack/ROS shell."
) from exc
cache_key = str(request.context.get("modelAssetHash") or request.context.get("urdfPath") or "")
if cache_key in self._moveit_by_key:
return self._moveit_by_key[cache_key]
if not rclpy.ok():
rclpy.init(args=None)
node_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()[:10] if cache_key else "default"
config = self._config_dict(request)
joint_state_seeder = _JointStateSeeder(request)
joint_state_seeder.start()
try:
try:
moveit = MoveItPy(
node_name=f"moveit2_server_{node_hash}",
config_dict=config,
)
except TypeError:
# Older bindings do not expose config_dict; they require equivalent
# ROS parameters to be supplied by the launch environment.
moveit = MoveItPy(node_name=f"moveit2_server_{node_hash}")
finally:
joint_state_seeder.stop()
self._moveit_by_key[cache_key] = moveit
return moveit
def _planning_component(self, request: Any, planning_group: str) -> Any:
return self._moveit(request).get_planning_component(planning_group)
def _robot_state(self, request: Any) -> Any:
try:
from moveit.core.robot_state import RobotState
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import RobotState for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
robot_state = RobotState(self._moveit(request).get_robot_model())
joint_values = _native_start_joint_values(request)
joint_names = _joint_names(request)
positions = [float(joint_values.get(name, 0.0)) for name in joint_names]
planning_group = _planning_group(request)
if hasattr(robot_state, "set_joint_group_positions"):
robot_state.set_joint_group_positions(planning_group, positions)
elif hasattr(robot_state, "set_joint_positions"):
robot_state.set_joint_positions(joint_names, positions)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint setter")
return robot_state
def _pose_stamped(self, request: Any) -> tuple[Any, dict[str, Any]]:
try:
from geometry_msgs.msg import PoseStamped
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import geometry_msgs.msg.PoseStamped for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
target = normalize_motion_target(request.payload)
end_effector = _end_effector(request, target["endEffector"])
pose = PoseStamped()
pose.header.frame_id = target["frame"]
pose.pose.position.x = target["xyz"][0]
pose.pose.position.y = target["xyz"][1]
pose.pose.position.z = target["xyz"][2]
quat_xyzw = target.get("quat_xyzw") or (0.0, 0.0, 0.0, 1.0)
pose.pose.orientation.x = quat_xyzw[0]
pose.pose.orientation.y = quat_xyzw[1]
pose.pose.orientation.z = quat_xyzw[2]
pose.pose.orientation.w = quat_xyzw[3]
return pose, end_effector
def _position_only_ik(self, request: Any) -> bool:
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
return bool(ik.get("positionOnly", True))
def _position_goal_constraints(self, target_pose: Any, end_effector: dict[str, Any]) -> list[Any]:
try:
from geometry_msgs.msg import Pose
from moveit_msgs.msg import Constraints, PositionConstraint
from shape_msgs.msg import SolidPrimitive
except ImportError as exc:
raise RuntimeError(
f"moveit_py adapter could not import MoveIt position constraint messages for {sys.executable}. "
"Start moveit2_server from a RoboStack/ROS shell."
) from exc
sphere = SolidPrimitive()
sphere.type = SolidPrimitive.SPHERE
sphere.dimensions = [float(end_effector.get("positionTolerance") or 0.002)]
center = Pose()
center.orientation.w = 1.0
center.position.x = target_pose.pose.position.x
center.position.y = target_pose.pose.position.y
center.position.z = target_pose.pose.position.z
position_constraint = PositionConstraint()
position_constraint.header.frame_id = target_pose.header.frame_id
position_constraint.link_name = str(end_effector.get("link") or "")
position_constraint.constraint_region.primitives.append(sphere)
position_constraint.constraint_region.primitive_poses.append(center)
position_constraint.weight = 1.0
constraints = Constraints()
constraints.name = f"{position_constraint.link_name}_position_goal"
constraints.position_constraints.append(position_constraint)
return [constraints]
def _joint_values_from_state(self, robot_state: Any, request: Any) -> dict[str, float]:
joint_names = _joint_names(request)
planning_group = _planning_group(request)
if hasattr(robot_state, "get_joint_group_positions"):
values = robot_state.get_joint_group_positions(planning_group)
elif hasattr(robot_state, "get_joint_positions"):
values = robot_state.get_joint_positions(joint_names)
else:
raise RuntimeError("moveit_py RobotState does not expose a supported joint getter")
return {
joint_name: float(values[index])
for index, joint_name in enumerate(joint_names)
if index < len(values)
}
def solve_pose(self, request: Any) -> dict[str, Any]:
robot_state = self._robot_state(request)
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
if not hasattr(robot_state, "set_from_ik"):
raise RuntimeError("moveit_py RobotState does not expose set_from_ik")
ik = request.command.get("ik", {}) if isinstance(request.command, dict) else {}
timeout = float(ik.get("timeout") or 0.05)
attempts = int(ik.get("attempts") or 1)
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout, attempts)
except TypeError:
try:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"), timeout)
except TypeError:
ok = robot_state.set_from_ik(planning_group, target_pose.pose, end_effector.get("link"))
if not ok:
return {
"ok": False,
"message": "MoveIt did not find a pose solution",
}
joint_values = self._joint_values_from_state(robot_state, request)
joint_types = _joint_types_for_request(request)
return {
"jointValuesByName": joint_values,
"jointValuesByNameDeg": _native_to_legacy_joint_values(joint_values, joint_types),
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
def _serialize_trajectory(self, trajectory: Any, joint_names: list[str], request: Any) -> dict[str, Any]:
if hasattr(trajectory, "get_robot_trajectory_msg"):
trajectory = trajectory.get_robot_trajectory_msg()
joint_trajectory = getattr(trajectory, "joint_trajectory", trajectory)
source_joint_names = list(getattr(joint_trajectory, "joint_names", []) or joint_names)
joint_types = _joint_types_for_request(request)
points = []
for point in getattr(joint_trajectory, "points", []) or []:
duration = getattr(point, "time_from_start", None)
seconds = float(getattr(duration, "sec", 0)) + (float(getattr(duration, "nanosec", 0)) / 1_000_000_000)
positions_by_name = {
name: float(point.positions[index])
for index, name in enumerate(source_joint_names)
if index < len(getattr(point, "positions", []))
}
native_positions = [positions_by_name.get(name, 0.0) for name in joint_names]
legacy_positions = [
_native_to_legacy_joint_value(positions_by_name.get(name, 0.0), str(joint_types.get(name) or "revolute"))
for name in joint_names
]
points.append({
"timeFromStartSec": seconds,
"positions": native_positions,
"positionsDeg": legacy_positions,
})
if len(points) > 1 and all(point["timeFromStartSec"] <= 0.0 for point in points):
total_joint_delta = 0.0
for previous, current in zip(points, points[1:], strict=False):
total_joint_delta += sum(
abs(current["positionsDeg"][index] - previous["positionsDeg"][index])
for index in range(min(len(previous["positionsDeg"]), len(current["positionsDeg"])))
)
total_duration = min(max(total_joint_delta / 240.0, 0.75), 4.0)
step_duration = total_duration / float(len(points) - 1)
for index, point in enumerate(points):
point["timeFromStartSec"] = index * step_duration
return {
"jointNames": joint_names,
"points": points,
}
def plan_to_pose(self, request: Any) -> dict[str, Any]:
target_pose, end_effector = self._pose_stamped(request)
planning_group = _planning_group(request)
planning_component = self._planning_component(request, planning_group)
start_state = self._robot_state(request)
planning_component.set_start_state(robot_state=start_state)
if self._position_only_ik(request):
try:
planning_component.set_goal_state(
motion_plan_constraints=self._position_goal_constraints(target_pose, end_effector),
)
except TypeError:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
else:
planning_component.set_goal_state(
pose_stamped_msg=target_pose,
pose_link=end_effector.get("link"),
)
plan_result = planning_component.plan()
if not plan_result:
return {
"ok": False,
"message": "MoveIt planning did not find a trajectory",
}
joint_names = _joint_names(request)
trajectory = self._serialize_trajectory(plan_result.trajectory, joint_names, request)
final_joint_values: dict[str, float] = {}
final_joint_values_legacy: dict[str, float] = {}
if trajectory["points"]:
final_joint_values = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positions"],
strict=False,
))
final_joint_values_legacy = dict(zip(
trajectory["jointNames"],
trajectory["points"][-1]["positionsDeg"],
strict=False,
))
return {
"jointValuesByName": final_joint_values,
"jointValuesByNameDeg": final_joint_values_legacy,
"trajectory": trajectory,
"positionOnly": self._position_only_ik(request),
"residual": {"position": 0.0},
}
@@ -0,0 +1,222 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import re
from typing import Any
SUPPORTED_REQUEST_TYPES = {"srdf.solvePose", "srdf.planToPose"}
SUPPORTED_PROTOCOL_VERSION = 1
_ABSOLUTE_PATH_RE = re.compile(r"(?<![\w.])/(?:[^\s'\"<>:,;(){}[\]]+/)+[^\s'\"<>:,;(){}[\]]+")
class MotionProtocolError(ValueError):
"""Raised when a MoveIt2 server request is malformed."""
def _plain_object(value: Any) -> bool:
return isinstance(value, dict)
def _string(value: Any, label: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise MotionProtocolError(f"{label} is required")
return normalized
def _number(value: Any, label: str) -> float:
try:
numeric = float(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError(f"{label} must be a finite number") from exc
if numeric != numeric or numeric in (float("inf"), float("-inf")):
raise MotionProtocolError(f"{label} must be a finite number")
return numeric
def normalize_xyz(value: Any, label: str = "target.xyz") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def normalize_quat_xyzw(value: Any, label: str = "target.quat_xyzw") -> tuple[float, float, float, float]:
if not isinstance(value, list) or len(value) != 4:
raise MotionProtocolError(f"{label} must be a 4-number array")
quat = (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
_number(value[3], f"{label}[3]"),
)
norm = math.sqrt(sum(component * component for component in quat))
if norm <= 1e-12:
raise MotionProtocolError(f"{label} must not be zero length")
return tuple(component / norm for component in quat)
def normalize_rpy(value: Any, label: str = "target.rpy") -> tuple[float, float, float]:
if not isinstance(value, list) or len(value) != 3:
raise MotionProtocolError(f"{label} must be a 3-number array")
return (
_number(value[0], f"{label}[0]"),
_number(value[1], f"{label}[1]"),
_number(value[2], f"{label}[2]"),
)
def _quat_xyzw_from_rpy(roll: float, pitch: float, yaw: float) -> tuple[float, float, float, float]:
cy = math.cos(yaw * 0.5)
sy = math.sin(yaw * 0.5)
cp = math.cos(pitch * 0.5)
sp = math.sin(pitch * 0.5)
cr = math.cos(roll * 0.5)
sr = math.sin(roll * 0.5)
return normalize_quat_xyzw([
(sr * cp * cy) - (cr * sp * sy),
(cr * sp * cy) + (sr * cp * sy),
(cr * cp * sy) - (sr * sp * cy),
(cr * cp * cy) + (sr * sp * sy),
])
def normalize_joint_values(value: Any, label: str = "startJointValuesByNameDeg") -> dict[str, float]:
if value is None:
return {}
if not _plain_object(value):
raise MotionProtocolError(f"{label} must be an object")
normalized: dict[str, float] = {}
for raw_name, raw_value in value.items():
name = str(raw_name or "").strip()
if not name:
raise MotionProtocolError(f"{label} cannot include empty joint names")
normalized[name] = _number(raw_value, f"{label}.{name}")
return normalized
def normalize_protocol_version(value: Any) -> int:
if value is None:
return SUPPORTED_PROTOCOL_VERSION
try:
version = int(value)
except (TypeError, ValueError) as exc:
raise MotionProtocolError("protocolVersion must be an integer") from exc
if version != SUPPORTED_PROTOCOL_VERSION:
raise MotionProtocolError(f"Unsupported protocolVersion {version}")
return version
@dataclass(frozen=True)
class WireMessage:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
@dataclass(frozen=True)
class MotionRequest:
id: str
type: str
protocol_version: int
payload: dict[str, Any]
context: dict[str, Any]
command: dict[str, Any]
def normalize_wire_message(message: Any) -> WireMessage:
if not _plain_object(message):
raise MotionProtocolError("MoveIt2 request must be an object")
request_id = _string(message.get("id"), "id")
request_type = _string(message.get("type"), "type")
protocol_version = normalize_protocol_version(message.get("protocolVersion"))
if request_type not in SUPPORTED_REQUEST_TYPES:
raise MotionProtocolError(f"Unsupported request type {request_type}")
payload = message.get("payload")
if not _plain_object(payload):
raise MotionProtocolError("payload must be an object")
return WireMessage(
id=request_id,
type=request_type,
protocol_version=protocol_version,
payload=payload,
)
def normalize_request(message: Any, *, context: dict[str, Any]) -> MotionRequest:
wire = normalize_wire_message(message)
if not _plain_object(context):
raise MotionProtocolError("request context must be an object")
command = context.get("command")
if not _plain_object(command):
raise MotionProtocolError("request context command must be an object")
return MotionRequest(
id=wire.id,
type=wire.type,
protocol_version=wire.protocol_version,
payload=wire.payload,
context=context,
command=command,
)
def normalize_motion_target(payload: dict[str, Any]) -> dict[str, Any]:
target = payload.get("target")
if not _plain_object(target):
raise MotionProtocolError("target must be an object")
has_quat = "quat_xyzw" in target
has_rpy = "rpy" in target
if has_quat and has_rpy:
raise MotionProtocolError("target must include exactly one of quat_xyzw or rpy")
quat_xyzw: tuple[float, float, float, float] | None = None
orientation_mode = "position_only"
if has_quat:
quat_xyzw = normalize_quat_xyzw(target.get("quat_xyzw"))
orientation_mode = "quat_xyzw"
elif has_rpy:
quat_xyzw = _quat_xyzw_from_rpy(*normalize_rpy(target.get("rpy")))
orientation_mode = "rpy"
return {
"endEffector": _string(target.get("endEffector"), "target.endEffector"),
"frame": _string(target.get("frame"), "target.frame"),
"xyz": normalize_xyz(target.get("xyz")),
"quat_xyzw": quat_xyzw,
"orientationMode": orientation_mode,
}
def success_response(request_id: str, result: dict[str, Any]) -> dict[str, Any]:
return {
"id": request_id,
"ok": True,
"result": result,
}
def _sanitize_error_message(message: str) -> str:
def replace(match: re.Match[str]) -> str:
path = match.group(0)
basename = path.rstrip("/").rsplit("/", 1)[-1]
return f".../{basename}" if basename else "<path>"
return _ABSOLUTE_PATH_RE.sub(replace, message)
def error_response(request_id: str, error: BaseException, *, debug: bool = False) -> dict[str, Any]:
message = str(error)
if not debug:
message = _sanitize_error_message(message)
return {
"id": request_id,
"ok": False,
"error": {
"code": error.__class__.__name__,
"message": message,
},
}
@@ -0,0 +1,108 @@
from __future__ import annotations
import argparse
import asyncio
import importlib
import json
import os
from pathlib import Path
from typing import Any
from moveit2_server.context import DEFAULT_CAD_DIRECTORY, build_moveit2_context
from moveit2_server.dispatcher import dispatch
from moveit2_server.protocol import error_response, normalize_request, normalize_wire_message, success_response
def _json_dumps(message: dict[str, Any]) -> str:
return json.dumps(message, separators=(",", ":"))
async def handle_message(raw_message: str, *, repo_root: Path) -> str:
request_id = ""
debug_errors = os.environ.get("MOVEIT2_SERVER_DEBUG_ERRORS", "").strip().lower() in {"1", "true", "yes", "on"}
try:
message = json.loads(raw_message)
wire = normalize_wire_message(message)
request_id = wire.id
payload_dir = wire.payload.get("dir", DEFAULT_CAD_DIRECTORY)
context = build_moveit2_context(
repo_root=repo_root,
dir=payload_dir,
file=wire.payload.get("file"),
type=wire.type,
payload=wire.payload,
)
request = normalize_request(message, context=context)
return _json_dumps(success_response(request.id, dispatch(request)))
except Exception as exc:
return _json_dumps(error_response(request_id, exc, debug=debug_errors))
async def handle_client(websocket: Any, path: str | None = None, *, repo_root: Path) -> None:
async for raw_message in websocket:
await websocket.send(await handle_message(str(raw_message), repo_root=repo_root))
async def run_server(*, repo_root: Path, host: str, port: int) -> None:
try:
from websockets.asyncio.server import serve
except ImportError:
from websockets import serve
async with serve(lambda websocket, path=None: handle_client(websocket, path, repo_root=repo_root), host, port):
print(f"moveit2_server listening on ws://{host}:{port}/ws", flush=True)
await asyncio.Future()
def _check_import(module_name: str, attribute_path: str | None = None) -> str:
module = importlib.import_module(module_name)
current: Any = module
if attribute_path:
for attribute in attribute_path.split("."):
current = getattr(current, attribute)
return str(getattr(module, "__file__", "built-in"))
def check_environment(*, repo_root: Path) -> dict[str, Any]:
checks = {
"repoRoot": str(repo_root),
"rmwImplementation": os.environ.get("RMW_IMPLEMENTATION", ""),
"imports": {
"websockets": _check_import("websockets"),
"rclpy": _check_import("rclpy"),
"geometry_msgs.msg.PoseStamped": _check_import("geometry_msgs.msg", "PoseStamped"),
"moveit.planning.MoveItPy": _check_import("moveit.planning", "MoveItPy"),
},
}
return checks
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="moveit2_server")
parser.add_argument(
"--repo-root",
default=str(Path.cwd()),
help="Repository root that contains the CAD scan directory.",
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--check",
action="store_true",
help="Verify required Python, ROS 2, MoveIt, and websocket imports, then exit.",
)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_arg_parser().parse_args(argv)
repo_root = Path(args.repo_root).resolve()
if args.check:
print(json.dumps(check_environment(repo_root=repo_root), indent=2, sort_keys=True))
return 0
asyncio.run(run_server(repo_root=repo_root, host=args.host, port=args.port))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,261 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from pathlib import Path
from typing import Literal
import xml.etree.ElementTree as ET
SRDF_SUFFIX = ".srdf"
SRDF_METADATA_NAMESPACE = "https://text-to-cad.dev/srdf"
LEGACY_EXPLORER_NAMESPACE = "https://text-to-cad.dev/explorer"
class SrdfSourceError(ValueError):
pass
@dataclass(frozen=True)
class SrdfChain:
base_link: str
tip_link: str
@dataclass(frozen=True)
class SrdfPlanningGroup:
name: str
joint_names: tuple[str, ...]
link_names: tuple[str, ...]
chains: tuple[SrdfChain, ...]
subgroups: tuple[str, ...]
@dataclass(frozen=True)
class SrdfEndEffector:
name: str
parent_link: str
group: str
parent_group: str
@dataclass(frozen=True)
class SrdfGroupState:
name: str
group: str
joint_values_by_name: dict[str, float]
@property
def joint_values_by_name_rad(self) -> dict[str, float]:
return self.joint_values_by_name
@dataclass(frozen=True)
class SrdfDisabledCollisionPair:
link1: str
link2: str
reason: str
source: Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"] = "manual"
@dataclass(frozen=True)
class SrdfSource:
file_ref: str
source_path: Path
robot_name: str
urdf_ref: str
planning_groups: tuple[SrdfPlanningGroup, ...]
end_effectors: tuple[SrdfEndEffector, ...]
group_states: tuple[SrdfGroupState, ...]
disabled_collision_pairs: tuple[SrdfDisabledCollisionPair, ...]
def read_srdf_source(srdf_path: Path) -> SrdfSource:
resolved_path = srdf_path.resolve()
if resolved_path.suffix.lower() != SRDF_SUFFIX:
raise SrdfSourceError(f"{resolved_path} is not an SRDF source file")
try:
root = ET.fromstring(resolved_path.read_text(encoding="utf-8"))
except (OSError, ET.ParseError) as exc:
raise SrdfSourceError(f"{_relative_to_repo(resolved_path)} could not be parsed as SRDF XML") from exc
return parse_srdf_root(root, source_path=resolved_path)
def parse_srdf_root(root: ET.Element, *, source_path: Path) -> SrdfSource:
if root.tag != "robot":
raise SrdfSourceError(f"{_relative_to_repo(source_path)} root element must be <robot>")
robot_name = str(root.attrib.get("name") or "").strip()
if not robot_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} robot name is required")
planning_groups: list[SrdfPlanningGroup] = []
group_names: list[str] = []
for group_element in root.findall("group"):
name = str(group_element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group name is required")
group_names.append(name)
planning_groups.append(
SrdfPlanningGroup(
name=name,
joint_names=_child_names(group_element, "joint"),
link_names=_child_names(group_element, "link"),
chains=tuple(
SrdfChain(
base_link=str(chain.attrib.get("base_link") or "").strip(),
tip_link=str(chain.attrib.get("tip_link") or "").strip(),
)
for chain in group_element.findall("chain")
),
subgroups=_child_names(group_element, "group"),
)
)
_raise_on_duplicates(group_names, source_path=source_path, label="group")
end_effectors: list[SrdfEndEffector] = []
end_effector_names: list[str] = []
for element in root.findall("end_effector"):
name = str(element.attrib.get("name") or "").strip()
if not name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} end_effector name is required")
end_effector_names.append(name)
end_effectors.append(
SrdfEndEffector(
name=name,
parent_link=str(element.attrib.get("parent_link") or "").strip(),
group=str(element.attrib.get("group") or "").strip(),
parent_group=str(element.attrib.get("parent_group") or "").strip(),
)
)
_raise_on_duplicates(end_effector_names, source_path=source_path, label="end_effector")
group_states: list[SrdfGroupState] = []
group_state_keys: list[str] = []
for element in root.findall("group_state"):
name = str(element.attrib.get("name") or "").strip()
group = str(element.attrib.get("group") or "").strip()
if not name or not group:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state requires name and group")
group_state_keys.append(f"{group}/{name}")
joint_values: dict[str, float] = {}
joint_names: list[str] = []
for joint_element in element.findall("joint"):
joint_name = str(joint_element.attrib.get("name") or "").strip()
if not joint_name:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} group_state joint name is required")
joint_names.append(joint_name)
try:
joint_value = float(joint_element.attrib.get("value"))
except (TypeError, ValueError) as exc:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be numeric"
) from exc
if not isfinite(joint_value):
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} group_state {name!r} joint {joint_name!r} value must be finite"
)
joint_values[joint_name] = joint_value
_raise_on_duplicates(joint_names, source_path=source_path, label=f"group_state {name!r} joint")
group_states.append(SrdfGroupState(name=name, group=group, joint_values_by_name=joint_values))
_raise_on_duplicates(group_state_keys, source_path=source_path, label="group_state")
disabled_pairs: list[SrdfDisabledCollisionPair] = []
pair_keys: list[str] = []
for element in root.findall("disable_collisions"):
link1 = str(element.attrib.get("link1") or "").strip()
link2 = str(element.attrib.get("link2") or "").strip()
if not link1 or not link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions requires link1 and link2")
if link1 == link2:
raise SrdfSourceError(f"{_relative_to_repo(source_path)} disable_collisions cannot repeat the same link")
reason = str(element.attrib.get("reason") or "").strip()
if not reason:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} disable_collisions for {link1!r}/{link2!r} requires a reason"
)
pair_key = "/".join(sorted((link1, link2)))
pair_keys.append(pair_key)
disabled_pairs.append(
SrdfDisabledCollisionPair(
link1=link1,
link2=link2,
reason=reason,
source=_disabled_collision_source(reason),
)
)
_raise_on_duplicates(pair_keys, source_path=source_path, label="disable_collisions")
return SrdfSource(
file_ref=_relative_to_repo(source_path.resolve()),
source_path=source_path.resolve(),
robot_name=robot_name,
urdf_ref=_linked_urdf_ref(root),
planning_groups=tuple(planning_groups),
end_effectors=tuple(end_effectors),
group_states=tuple(group_states),
disabled_collision_pairs=tuple(disabled_pairs),
)
def _local_name(tag: str) -> str:
return str(tag or "").rsplit("}", 1)[-1].split(":", 1)[-1]
def _linked_urdf_ref(root: ET.Element) -> str:
for child in list(root):
if _local_name(child.tag) != "urdf":
continue
tag = str(child.tag or "")
if (
tag.startswith(f"{{{SRDF_METADATA_NAMESPACE}}}")
or tag.startswith(f"{{{LEGACY_EXPLORER_NAMESPACE}}}")
or tag.startswith("tcad:")
or tag.startswith("explorer:")
):
return str(child.attrib.get("path") or "").strip()
return ""
def _child_names(element: ET.Element, tag: str) -> tuple[str, ...]:
names: list[str] = []
for child in element.findall(tag):
name = str(child.attrib.get("name") or "").strip()
if name:
names.append(name)
return tuple(names)
def _raise_on_duplicates(names: list[str], *, source_path: Path, label: str) -> None:
seen = set()
duplicates = []
for name in names:
if name in seen:
duplicates.append(name)
seen.add(name)
if duplicates:
raise SrdfSourceError(
f"{_relative_to_repo(source_path)} has duplicate {label} name(s): {sorted(set(duplicates))!r}"
)
def _disabled_collision_source(
reason: str,
) -> Literal["adjacent", "sampled", "manual", "setup_assistant", "assumed"]:
normalized = str(reason or "").strip().lower()
if "adjacent" in normalized:
return "adjacent"
if any(token in normalized for token in ("never", "always", "sample", "default")):
return "sampled"
if "setup" in normalized or "assistant" in normalized:
return "setup_assistant"
if "assum" in normalized:
return "assumed"
return "manual"
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(Path.cwd().resolve()).as_posix()
except ValueError:
return resolved.as_posix()
@@ -0,0 +1,21 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "moveit2_server"
version = "0.3.9"
description = "Local MoveIt2 websocket server for SRDF solve and planning requests."
requires-python = ">=3.12"
dependencies = [
"websockets>=12",
]
[project.scripts]
moveit2_server = "moveit2_server.server:main"
[tool.setuptools]
packages = ["moveit2_server"]
[tool.setuptools.package-dir]
moveit2_server = "moveit2_server"
+19
View File
@@ -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
View File
@@ -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}"
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "cad-viewer",
"private": true,
"type": "module",
"version": "0.3.9",
"scripts": {
"dev": "vite dev",
"agent:start": "node scripts/start-agent-viewer.mjs",
"serve": "node src/server/server.mjs",
"upload:blob": "node scripts/upload-catalog-to-blob.mjs",
"start": "node src/server/server.mjs",
"moveit2:setup": "moveit2_server/setup.sh",
"moveit2:check": "moveit2_server/check-moveit2-server.sh",
"moveit2:serve": "moveit2_server/run-moveit2-server.sh",
"build": "vite build",
"runtime:bundle": "../scripts/bundle/bundle-skill.sh cad-viewer",
"runtime:check": "../scripts/bundle/bundle-skill.sh cad-viewer --check",
"test": "node scripts/run-tests.mjs"
},
"dependencies": {
"@vercel/blob": "^2.4.0",
"cadjs": "file:./packages/cadjs",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"implicitjs": "file:./packages/implicitjs",
"lucide-react": "^1.8.0",
"radix-ui": "^1.4.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^3.5.0",
"three": "0.160.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.2",
"@vitejs/plugin-react": "^4.7.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"vite": "^7.3.2"
}
}
+1
View File
@@ -0,0 +1 @@
../../packages/cadjs
+1
View File
@@ -0,0 +1 @@
../../packages/cadpy
+1
View File
@@ -0,0 +1 @@
../../packages/implicitjs
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {}
}
};
+1
View File
@@ -0,0 +1 @@
--editable ./packages/cadpy
+47
View File
@@ -0,0 +1,47 @@
#!/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 defaultTestRoots = [
path.join(packageRoot, "src"),
path.join(packageRoot, "scripts"),
];
function collectTests(dir, tests = []) {
if (!fs.existsSync(dir)) {
return 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 : defaultTestRoots.flatMap((root) => collectTests(root)))
.sort();
if (!tests.length) {
console.error("No CAD Viewer tests found.");
process.exit(1);
}
const result = spawnSync(process.execPath, [
"--test",
"--experimental-default-type=module",
...tests,
], {
cwd: packageRoot,
env: process.env,
stdio: "inherit",
});
process.exit(result.status ?? 1);
@@ -0,0 +1,778 @@
#!/usr/bin/env node
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEFAULT_VIEWER_PORT,
VIEWER_SERVER_API_VERSION,
VIEWER_SERVER_APP_ID,
} from "../src/server/viewerServerInfo.mjs";
import {
readViewerServerRegistry,
} from "../src/server/viewerServerRegistry.mjs";
const scriptPath = fileURLToPath(import.meta.url);
const defaultPackageRoot = path.resolve(path.dirname(scriptPath), "..");
const startModeFlag = "--viewer-start-mode";
const defaultRootDirFlag = "--dir";
const startModes = new Set(["auto", "dev", "serve"]);
const defaultAgentHost = "127.0.0.1";
const defaultPortScanLimit = 64;
const probeTimeoutMs = 350;
const activationTimeoutMs = 30_000;
const directoryActivationFeature = "directory-activation";
function requiredValue(argv, index, flag) {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`${flag} requires a value`);
}
return value;
}
function normalizeStartMode(value, flag = startModeFlag) {
const mode = String(value || "").trim();
if (!startModes.has(mode)) {
throw new Error(`${flag} must be one of: auto, dev, serve.`);
}
return mode;
}
function parseAgentPort(value, flag = "--port") {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
throw new Error(`${flag} must be a TCP port from 1 to 65535`);
}
return parsed;
}
function parsePositiveInteger(value, flag) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive integer`);
}
return parsed;
}
export function normalizeAgentDirectory(value, { fsImpl = fs } = {}) {
const rawDir = String(value || "").trim();
if (!rawDir) {
throw new Error("agent:start requires --dir <absolute-directory>");
}
if (!path.isAbsolute(rawDir)) {
throw new Error("agent:start --dir must be an absolute path");
}
const resolvedDir = path.resolve(rawDir);
let stats = null;
try {
stats = fsImpl.statSync(resolvedDir);
} catch {
throw new Error(`agent:start --dir directory not found: ${resolvedDir}`);
}
if (!stats?.isDirectory?.()) {
throw new Error(`agent:start --dir is not a directory: ${resolvedDir}`);
}
return resolvedDir;
}
export function replaceForwardedDefaultRootDir(argv = [], rootDir) {
const normalizedRootDir = String(rootDir || "").trim();
const nextArgs = [];
let replaced = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith(`${defaultRootDirFlag}=`)) {
nextArgs.push(`${defaultRootDirFlag}=${normalizedRootDir}`);
replaced = true;
continue;
}
if (arg === defaultRootDirFlag) {
nextArgs.push(arg, normalizedRootDir);
replaced = true;
index += 1;
continue;
}
nextArgs.push(arg);
}
if (!replaced) {
nextArgs.push(defaultRootDirFlag, normalizedRootDir);
}
return nextArgs;
}
export function parseAgentStartArgs(argv = [], { fsImpl = fs } = {}) {
const options = {
startMode: "auto",
forwardedArgs: [],
directory: "",
portScanLimit: defaultPortScanLimit,
jsonResult: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith(`${startModeFlag}=`)) {
options.startMode = normalizeStartMode(arg.slice(startModeFlag.length + 1));
continue;
}
if (arg === startModeFlag) {
options.startMode = normalizeStartMode(requiredValue(argv, index, arg));
index += 1;
continue;
}
if (arg.startsWith("--port-scan-limit=")) {
options.portScanLimit = parsePositiveInteger(arg.slice("--port-scan-limit=".length), "--port-scan-limit");
continue;
}
if (arg === "--port-scan-limit") {
options.portScanLimit = parsePositiveInteger(requiredValue(argv, index, arg), arg);
index += 1;
continue;
}
if (arg.startsWith("--shutdown-after=") || arg === "--shutdown-after") {
throw new Error("agent:start does not support --shutdown-after");
}
if (arg === "--json") {
options.jsonResult = true;
continue;
}
options.forwardedArgs.push(arg);
}
options.directory = normalizeAgentDirectory(forwardedDefaultRootDir(options.forwardedArgs), { fsImpl });
options.forwardedArgs = replaceForwardedDefaultRootDir(options.forwardedArgs, options.directory);
return options;
}
export function forwardedDefaultRootDir(argv = []) {
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith(`${defaultRootDirFlag}=`)) {
return arg.slice(defaultRootDirFlag.length + 1).trim();
}
if (arg === defaultRootDirFlag) {
return requiredValue(argv, index, arg).trim();
}
}
return "";
}
export function stripDefaultRootDirArgs(argv = []) {
const stripped = [];
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith(`${defaultRootDirFlag}=`)) {
continue;
}
if (arg === defaultRootDirFlag) {
index += 1;
continue;
}
stripped.push(arg);
}
return stripped;
}
export function forwardedServerTarget(argv = []) {
const target = {
host: defaultAgentHost,
port: DEFAULT_VIEWER_PORT,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith("--host=")) {
target.host = arg.slice("--host=".length).trim() || defaultAgentHost;
continue;
}
if (arg === "--host") {
target.host = requiredValue(argv, index, arg).trim() || defaultAgentHost;
index += 1;
continue;
}
if (arg.startsWith("--port=")) {
target.port = parseAgentPort(arg.slice("--port=".length), "--port");
continue;
}
if (arg === "--port") {
target.port = parseAgentPort(requiredValue(argv, index, arg), arg);
index += 1;
}
}
return target;
}
export function replaceForwardedPort(argv = [], port = DEFAULT_VIEWER_PORT) {
const normalizedPort = parseAgentPort(port, "--port");
const nextArgs = [];
let replaced = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg.startsWith("--port=")) {
nextArgs.push(`--port=${normalizedPort}`);
replaced = true;
continue;
}
if (arg === "--port") {
nextArgs.push(arg, String(normalizedPort));
replaced = true;
index += 1;
continue;
}
nextArgs.push(arg);
}
if (!replaced) {
nextArgs.push("--port", String(normalizedPort));
}
return nextArgs;
}
export function isSymlinkPath(filePath) {
if (!filePath) {
return false;
}
try {
return fs.lstatSync(filePath).isSymbolicLink();
} catch {
return false;
}
}
export function selectAgentStartMode({
requestedMode = "auto",
npmConfigPrefix = "",
npmPackageJson = "",
} = {}) {
const mode = normalizeStartMode(requestedMode);
if (mode !== "auto") {
return mode;
}
const packagePrefix = npmConfigPrefix
|| (npmPackageJson ? path.dirname(npmPackageJson) : "")
|| process.env.npm_config_prefix
|| (process.env.npm_package_json ? path.dirname(process.env.npm_package_json) : "");
return isSymlinkPath(packagePrefix) ? "dev" : "serve";
}
function safeRealpath(filePath) {
const resolvedPath = path.resolve(String(filePath || ""));
try {
return fs.realpathSync(resolvedPath);
} catch {
return resolvedPath;
}
}
function gitOutput(args, cwd) {
try {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "";
}
}
function resolveGitDir(rawGitDir, cwd) {
if (!rawGitDir) {
return "";
}
return safeRealpath(path.isAbsolute(rawGitDir)
? rawGitDir
: path.resolve(cwd, rawGitDir));
}
function normalizeGitBranch(branch) {
const normalizedBranch = String(branch || "").trim();
return normalizedBranch && normalizedBranch !== "HEAD" ? normalizedBranch : "detached";
}
export function buildAgentViewerGit({
env = process.env,
cwd = process.cwd(),
} = {}) {
const resolvedCwd = safeRealpath(env.INIT_CWD || cwd);
const gitDir = resolveGitDir(gitOutput(["rev-parse", "--git-dir"], resolvedCwd), resolvedCwd);
if (!gitDir) {
return "";
}
const gitBranch = gitOutput(["symbolic-ref", "--quiet", "--short", "HEAD"], resolvedCwd)
|| gitOutput(["rev-parse", "--abbrev-ref", "HEAD"], resolvedCwd);
return `${gitDir}#${normalizeGitBranch(gitBranch)}`;
}
function envWithGit(env, git) {
return {
...env,
VIEWER_GIT: String(git || ""),
};
}
function envWithAgentStartMode(env, mode) {
return {
...env,
VIEWER_AGENT_START_MODE: normalizeStartMode(mode),
};
}
function envWithGitAndMode(env, git, mode) {
return envWithAgentStartMode(envWithGit(env, git), mode);
}
function envWithGitModeAndDefaultDir(env, git, mode, defaultDir = "") {
const nextEnv = envWithGitAndMode(env, git, mode);
const normalizedDir = String(defaultDir || "").trim();
if (normalizedDir) {
nextEnv.VIEWER_DEFAULT_DIR = normalizedDir;
}
return nextEnv;
}
export function readAgentViewerPackageVersion(packageRoot = defaultPackageRoot, { fsImpl = fs } = {}) {
try {
const packageJson = JSON.parse(fsImpl.readFileSync(path.join(path.resolve(packageRoot), "package.json"), "utf8"));
return String(packageJson.version || "").trim();
} catch {
return "";
}
}
export function buildAgentStartCommand({
mode,
packageRoot = defaultPackageRoot,
forwardedArgs = [],
env = process.env,
nodePath = process.execPath,
git = buildAgentViewerGit({ env }),
} = {}) {
const resolvedPackageRoot = path.resolve(packageRoot);
if (mode === "dev") {
const nextEnv = envWithGitModeAndDefaultDir(env, git, mode, forwardedDefaultRootDir(forwardedArgs));
return {
command: nodePath,
args: [
path.join(resolvedPackageRoot, "node_modules", "vite", "bin", "vite.js"),
"dev",
...stripDefaultRootDirArgs(forwardedArgs),
],
cwd: resolvedPackageRoot,
env: nextEnv,
mode,
};
}
return {
command: nodePath,
args: [
path.join(resolvedPackageRoot, "src", "server", "server.mjs"),
...forwardedArgs,
],
cwd: resolvedPackageRoot,
env: envWithGitAndMode(env, git, mode),
mode,
};
}
export function resolveAgentStartCommand({
argv = process.argv.slice(2),
env = process.env,
packageRoot = defaultPackageRoot,
nodePath = process.execPath,
git = buildAgentViewerGit({ env }),
} = {}) {
const parsed = parseAgentStartArgs(argv);
const mode = selectAgentStartMode({
requestedMode: parsed.startMode,
npmConfigPrefix: env.npm_config_prefix,
npmPackageJson: env.npm_package_json,
});
return buildAgentStartCommand({
mode,
packageRoot,
forwardedArgs: parsed.forwardedArgs,
env,
nodePath,
git,
});
}
function normalizeBaseUrl(host, port) {
return `http://${host}:${port}`;
}
export function agentViewerUrl(baseUrl, directory) {
const url = new URL("/", String(baseUrl || "").endsWith("/") ? baseUrl : `${baseUrl}/`);
url.searchParams.set("dir", normalizeAgentDirectory(directory));
return url.toString();
}
function normalizeReuseMode(mode) {
const normalizedMode = String(mode || "").trim();
if (normalizedMode === "dev") {
return "dev";
}
if (normalizedMode === "serve" || normalizedMode === "dist" || normalizedMode === "bundle") {
return "serve";
}
return "";
}
function normalizeReuseContext(contextOrGit = {}) {
if (typeof contextOrGit === "string") {
return {
git: contextOrGit,
mode: "dev",
viewerVersion: "",
};
}
return {
git: String(contextOrGit?.git || ""),
mode: normalizeReuseMode(contextOrGit?.mode),
viewerVersion: String(contextOrGit?.viewerVersion || "").trim(),
};
}
function serverInfoMode(serverInfo) {
return normalizeReuseMode(serverInfo?.serverMode || serverInfo?.runtimeMode || serverInfo?.mode);
}
function serverInfoRequiresGitMatch(serverInfo, context) {
const currentMode = normalizeReuseMode(context?.mode);
const serverMode = serverInfoMode(serverInfo);
return !currentMode || !serverMode || currentMode === "dev" || serverMode === "dev";
}
function serverInfoGitAllowsReuse(serverInfo, context) {
if (!serverInfoRequiresGitMatch(serverInfo, context)) {
return true;
}
const currentGit = String(context?.git || "");
const serverGit = String(serverInfo?.git || "");
return !currentGit || !serverGit || currentGit === serverGit;
}
function serverInfoVersionAllowsReuse(serverInfo, context) {
if (serverInfoRequiresGitMatch(serverInfo, context)) {
return true;
}
const currentVersion = String(context?.viewerVersion || "").trim();
const serverVersion = String(serverInfo?.viewerVersion || "").trim();
return Boolean(currentVersion && serverVersion && currentVersion === serverVersion);
}
function serverInfoHasFeature(serverInfo, feature) {
const features = Array.isArray(serverInfo?.serverFeatures) ? serverInfo.serverFeatures : [];
return features.includes(feature);
}
export function isReusableAgentViewerServer(serverInfo, contextOrGit = {}) {
const context = normalizeReuseContext(contextOrGit);
return Boolean(
serverInfo &&
serverInfo.app === VIEWER_SERVER_APP_ID &&
Number(serverInfo.serverApiVersion || 0) >= VIEWER_SERVER_API_VERSION &&
serverInfo.dynamicRoot === true &&
serverInfoHasFeature(serverInfo, directoryActivationFeature) &&
serverInfoGitAllowsReuse(serverInfo, context) &&
serverInfoVersionAllowsReuse(serverInfo, context)
);
}
async function fetchWithTimeout(fetchImpl, url, optionsOrTimeoutMs = {}, timeoutMs = probeTimeoutMs) {
const requestOptions = typeof optionsOrTimeoutMs === "number" ? {} : { ...optionsOrTimeoutMs };
const effectiveTimeoutMs = typeof optionsOrTimeoutMs === "number" ? optionsOrTimeoutMs : timeoutMs;
const controller = typeof AbortController === "function" ? new AbortController() : null;
const timeout = controller
? setTimeout(() => controller.abort(), effectiveTimeoutMs)
: null;
try {
return await fetchImpl(url, controller ? { ...requestOptions, signal: controller.signal } : requestOptions);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
function probeBlockedByPermissions(error) {
const code = String(error?.cause?.code || error?.code || "");
return code === "EPERM" || code === "EACCES";
}
export async function probeAgentViewerPort({
host = defaultAgentHost,
port = DEFAULT_VIEWER_PORT,
fetchImpl = globalThis.fetch,
timeoutMs = probeTimeoutMs,
} = {}) {
if (typeof fetchImpl !== "function") {
return { status: "unknown", port, error: "fetch is unavailable" };
}
const baseUrl = normalizeBaseUrl(host, port);
try {
const response = await fetchWithTimeout(fetchImpl, `${baseUrl}/__cad/server`, timeoutMs);
if (!response?.ok) {
return { status: "occupied", port, baseUrl };
}
try {
const serverInfo = await response.json();
if (serverInfo?.app === VIEWER_SERVER_APP_ID) {
return { status: "viewer", port, baseUrl, serverInfo };
}
} catch {
// Non-JSON responses mean another process is using this port.
}
return { status: "occupied", port, baseUrl };
} catch (error) {
if (error?.name === "AbortError") {
return { status: "occupied", port, baseUrl, error };
}
if (probeBlockedByPermissions(error)) {
return { status: "blocked", port, baseUrl, error };
}
return { status: "closed", port, baseUrl, error };
}
}
async function responseSnippet(response) {
try {
const text = await response.text();
return String(text || "").trim().slice(0, 500);
} catch {
return "";
}
}
export async function activateAgentViewerDirectory({
baseUrl,
directory,
fetchImpl = globalThis.fetch,
timeoutMs = activationTimeoutMs,
} = {}) {
if (typeof fetchImpl !== "function") {
throw new Error("fetch is unavailable; cannot activate CAD Viewer directory");
}
const normalizedDirectory = normalizeAgentDirectory(directory);
const activationUrl = new URL("/__cad/directory/activate", String(baseUrl || "").endsWith("/") ? baseUrl : `${baseUrl}/`);
activationUrl.searchParams.set("dir", normalizedDirectory);
let response = null;
try {
response = await fetchWithTimeout(fetchImpl, String(activationUrl), {
method: "POST",
}, timeoutMs);
} catch (error) {
if (probeBlockedByPermissions(error)) {
throw new Error(`CAD Viewer directory activation was blocked for ${baseUrl}; rerun agent:start with local network permission.`);
}
throw error;
}
if (!response?.ok) {
const detail = response ? await responseSnippet(response) : "";
const status = response ? `${response.status || "unknown"} ${response.statusText || ""}`.trim() : "unknown";
throw new Error(`Failed to activate CAD Viewer directory ${normalizedDirectory}: ${status}${detail ? ` - ${detail}` : ""}`);
}
let payload = null;
try {
payload = await response.json();
} catch {
throw new Error(`CAD Viewer directory activation did not return JSON for ${normalizedDirectory}`);
}
if (!payload?.ok) {
throw new Error(`CAD Viewer directory activation failed for ${normalizedDirectory}`);
}
return {
directory: normalizedDirectory,
viewerUrl: agentViewerUrl(baseUrl, normalizedDirectory),
activeDirectory: payload.directory || null,
serverInfo: payload.server || null,
};
}
function registryHost(serverInfo, fallbackHost) {
try {
const url = new URL(String(serverInfo?.url || ""));
return url.hostname || fallbackHost;
} catch {
return fallbackHost;
}
}
export async function resolveAgentViewerPort({
forwardedArgs = [],
git = "",
mode = "dev",
viewerVersion = "",
registryServers = readViewerServerRegistry(),
probePort = probeAgentViewerPort,
portScanLimit = defaultPortScanLimit,
} = {}) {
const target = forwardedServerTarget(forwardedArgs);
const reuseContext = { git, mode, viewerVersion };
const reusableRegistryServers = registryServers
.filter((serverInfo) => isReusableAgentViewerServer(serverInfo, reuseContext))
.sort((left, right) => Number(left.port) - Number(right.port));
for (const serverInfo of reusableRegistryServers) {
const host = registryHost(serverInfo, target.host);
const probe = await probePort({ host, port: serverInfo.port });
if (probe.status === "blocked") {
throw new Error(`CAD Viewer port probe was blocked for ${probe.baseUrl}; rerun agent:start with local network permission.`);
}
if (probe.status === "viewer" && isReusableAgentViewerServer(probe.serverInfo, reuseContext)) {
return {
action: "reuse",
host,
port: Number(serverInfo.port),
baseUrl: probe.baseUrl,
serverInfo: probe.serverInfo,
};
}
}
for (let offset = 0; offset < portScanLimit; offset += 1) {
const port = target.port + offset;
if (port > 65535) {
break;
}
const probe = await probePort({ host: target.host, port });
if (probe.status === "blocked") {
throw new Error(`CAD Viewer port probe was blocked for ${probe.baseUrl}; rerun agent:start with local network permission.`);
}
if (probe.status === "viewer" && isReusableAgentViewerServer(probe.serverInfo, reuseContext)) {
return {
action: "reuse",
host: target.host,
port,
baseUrl: probe.baseUrl,
serverInfo: probe.serverInfo,
};
}
if (probe.status === "closed") {
return {
action: "start",
host: target.host,
port,
baseUrl: probe.baseUrl,
};
}
}
throw new Error(`No reusable or free CAD Viewer port found from ${target.port} through ${Math.min(target.port + portScanLimit - 1, 65535)}.`);
}
export async function resolveAgentStartLaunch({
argv = process.argv.slice(2),
env = process.env,
packageRoot = defaultPackageRoot,
nodePath = process.execPath,
probePort = probeAgentViewerPort,
registryServers = readViewerServerRegistry(),
} = {}) {
const parsed = parseAgentStartArgs(argv);
const git = buildAgentViewerGit({ env });
const mode = selectAgentStartMode({
requestedMode: parsed.startMode,
npmConfigPrefix: env.npm_config_prefix,
npmPackageJson: env.npm_package_json,
});
const viewerVersion = readAgentViewerPackageVersion(packageRoot);
const portResolution = await resolveAgentViewerPort({
forwardedArgs: parsed.forwardedArgs,
git,
mode,
viewerVersion,
registryServers,
probePort,
portScanLimit: parsed.portScanLimit,
});
if (portResolution.action === "reuse") {
return {
...portResolution,
git,
directory: parsed.directory,
viewerUrl: agentViewerUrl(portResolution.baseUrl, parsed.directory),
jsonResult: parsed.jsonResult,
};
}
return {
action: "start",
host: portResolution.host,
port: portResolution.port,
baseUrl: portResolution.baseUrl,
git,
directory: parsed.directory,
viewerUrl: agentViewerUrl(portResolution.baseUrl, parsed.directory),
jsonResult: parsed.jsonResult,
command: buildAgentStartCommand({
mode,
packageRoot,
forwardedArgs: replaceForwardedPort(parsed.forwardedArgs, portResolution.port),
env,
nodePath,
git,
}),
};
}
export async function runAgentStart(options = {}) {
const launch = await resolveAgentStartLaunch(options);
if (launch.action === "reuse") {
await activateAgentViewerDirectory({
baseUrl: launch.baseUrl,
directory: launch.directory,
fetchImpl: options.fetchImpl,
});
console.log(`CAD Viewer already running at ${launch.viewerUrl}`);
console.log(`CAD Viewer URL: ${launch.viewerUrl}`);
console.log(`CAD Viewer git: ${launch.git || "none"}`);
if (launch.jsonResult) {
console.log(JSON.stringify({ url: launch.viewerUrl, port: launch.port, action: "reuse" }));
}
return null;
}
const command = launch.command;
console.log(`Starting CAD Viewer ${command.mode} server at ${launch.viewerUrl}`);
console.log(`CAD Viewer URL: ${launch.viewerUrl}`);
console.log(`CAD Viewer git: ${launch.git || "none"}`);
if (launch.jsonResult) {
console.log(JSON.stringify({ url: launch.viewerUrl, port: launch.port, action: "start" }));
}
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env: command.env,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
child.on("error", (error) => {
console.error(`Failed to start CAD Viewer ${command.mode} server: ${error.message}`);
process.exit(1);
});
return child;
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === scriptPath;
if (isMain) {
try {
await runAgentStart();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
@@ -0,0 +1,548 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
activateAgentViewerDirectory,
agentViewerUrl,
buildAgentViewerGit,
buildAgentStartCommand,
forwardedDefaultRootDir,
forwardedServerTarget,
normalizeAgentDirectory,
isReusableAgentViewerServer,
parseAgentStartArgs,
probeAgentViewerPort,
replaceForwardedPort,
resolveAgentStartLaunch,
resolveAgentStartCommand,
resolveAgentViewerPort,
selectAgentStartMode,
stripDefaultRootDirArgs,
} from "./start-agent-viewer.mjs";
test("parseAgentStartArgs consumes launcher mode and preserves server flags", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
assert.deepEqual(
parseAgentStartArgs([
"--viewer-start-mode=dev",
"--host",
"127.0.0.1",
"--dir",
directory,
"--port=4178",
]),
{
startMode: "dev",
forwardedArgs: [
"--host",
"127.0.0.1",
"--dir",
directory,
"--port=4178",
],
directory,
portScanLimit: 64,
jsonResult: false,
}
);
});
test("parseAgentStartArgs --json sets jsonResult and is not forwarded to the server", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const result = parseAgentStartArgs(["--dir", directory, "--host", "127.0.0.1", "--json"]);
assert.equal(result.jsonResult, true);
assert.ok(!result.forwardedArgs.includes("--json"), "--json must not be forwarded to the server");
});
test("forwardedDefaultRootDir reads and strips default directory flags", () => {
assert.equal(forwardedDefaultRootDir(["--host", "127.0.0.1", "--dir=/project/models"]), "/project/models");
assert.equal(forwardedDefaultRootDir(["--dir", "models", "--port", "4178"]), "models");
assert.deepEqual(
stripDefaultRootDirArgs(["--host", "127.0.0.1", "--dir", "/project/models", "--port=4178"]),
["--host", "127.0.0.1", "--port=4178"]
);
});
test("parseAgentStartArgs requires agent:start --dir to be an absolute existing directory", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const filePath = path.join(directory, "not-a-dir");
await fs.writeFile(filePath, "");
assert.equal(normalizeAgentDirectory(directory), directory);
assert.throws(() => parseAgentStartArgs(["--host", "127.0.0.1"]), /requires --dir/);
assert.throws(() => parseAgentStartArgs(["--dir", "models"]), /absolute path/);
assert.throws(() => parseAgentStartArgs(["--dir", path.join(directory, "missing")]), /directory not found/);
assert.throws(() => parseAgentStartArgs(["--dir", filePath]), /is not a directory/);
});
test("parseAgentStartArgs rejects invalid launcher modes", () => {
assert.throws(
() => parseAgentStartArgs(["--viewer-start-mode", "test"]),
/must be one of/
);
});
test("parseAgentStartArgs rejects shutdown lifetime flags", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
assert.throws(
() => parseAgentStartArgs(["--dir", directory, "--shutdown-after", "12h"]),
/does not support --shutdown-after/
);
assert.throws(
() => parseAgentStartArgs(["--dir", directory, "--shutdown-after=12h"]),
/does not support --shutdown-after/
);
});
test("forwardedServerTarget reads host and port flags", () => {
assert.deepEqual(
forwardedServerTarget(["--host=0.0.0.0", "--port", "4200"]),
{
host: "0.0.0.0",
port: 4200,
}
);
});
test("replaceForwardedPort updates or appends the selected port", () => {
assert.deepEqual(
replaceForwardedPort(["--host", "127.0.0.1", "--port=4178"], 4180),
["--host", "127.0.0.1", "--port=4180"]
);
assert.deepEqual(
replaceForwardedPort(["--host", "127.0.0.1"], 4181),
["--host", "127.0.0.1", "--port", "4181"]
);
});
test("selectAgentStartMode uses dev mode for symlinked npm prefixes", async (t) => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-"));
t.after(() => fs.rm(tmpDir, { recursive: true, force: true }));
const realViewer = path.join(tmpDir, "viewer");
const linkedViewer = path.join(tmpDir, "skills", "cad-viewer", "scripts", "viewer");
await fs.mkdir(realViewer, { recursive: true });
await fs.mkdir(path.dirname(linkedViewer), { recursive: true });
await fs.symlink(realViewer, linkedViewer, "dir");
assert.equal(selectAgentStartMode({ npmConfigPrefix: linkedViewer }), "dev");
assert.equal(selectAgentStartMode({ npmPackageJson: path.join(linkedViewer, "package.json") }), "dev");
assert.equal(selectAgentStartMode({ npmConfigPrefix: realViewer }), "serve");
assert.equal(selectAgentStartMode({ requestedMode: "serve", npmConfigPrefix: linkedViewer }), "serve");
});
test("buildAgentViewerGit returns a worktree and branch value when git exists", async (t) => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-"));
t.after(() => fs.rm(tmpDir, { recursive: true, force: true }));
const packageRoot = path.join(tmpDir, "viewer");
await fs.mkdir(packageRoot, { recursive: true });
execFileSync("git", ["init"], { cwd: tmpDir, stdio: "ignore" });
execFileSync("git", ["checkout", "-b", "test-branch"], { cwd: tmpDir, stdio: "ignore" });
const git = buildAgentViewerGit({ env: {}, cwd: packageRoot });
assert.match(git, /#test-branch$/);
assert.match(git, /\/\.git#test-branch$/);
});
test("buildAgentViewerGit is empty outside git", async (t) => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-"));
t.after(() => fs.rm(tmpDir, { recursive: true, force: true }));
assert.equal(buildAgentViewerGit({ env: {}, cwd: tmpDir }), "");
});
test("buildAgentStartCommand prepares dev mode without a server lifetime", () => {
const command = buildAgentStartCommand({
mode: "dev",
packageRoot: "/project/viewer",
forwardedArgs: ["--host", "127.0.0.1", "--dir", "/project/models", "--port", "4178"],
env: {},
nodePath: "/node",
git: "git-a",
});
assert.equal(command.command, "/node");
assert.deepEqual(command.args, [
"/project/viewer/node_modules/vite/bin/vite.js",
"dev",
"--host",
"127.0.0.1",
"--port",
"4178",
]);
assert.equal(command.env.VIEWER_SERVER_LIFETIME_MS, undefined);
assert.equal(command.env.VIEWER_DEFAULT_DIR, "/project/models");
assert.equal(command.env.VIEWER_GIT, "git-a");
assert.equal(command.env.VIEWER_AGENT_START_MODE, "dev");
});
test("resolveAgentStartCommand keeps server-only flags on the production server path", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const command = resolveAgentStartCommand({
argv: ["--viewer-start-mode", "serve", "--dir", directory],
env: {},
packageRoot: "/project/viewer",
nodePath: "/node",
});
assert.equal(command.mode, "serve");
assert.equal(command.env.VIEWER_AGENT_START_MODE, "serve");
assert.deepEqual(command.args, [
"/project/viewer/src/server/server.mjs",
"--dir",
directory,
]);
});
test("isReusableAgentViewerServer uses git only when both sides report it", () => {
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-a",
}, "git-a"),
true
);
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-b",
}, "git-a"),
false
);
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
}, "git-a"),
true
);
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
git: "git-a",
}, "git-a"),
false
);
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-a",
activeDirectories: [{
dir: "/project/models",
rootPath: "/project/models",
}],
}, ""),
true
);
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-a",
directoryRoot: "/project",
activeDirectories: [{
dir: "/project/models",
rootPath: "/project/models",
}],
}, "git-a"),
true
);
assert.equal(
isReusableAgentViewerServer({
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-a",
activeDirectories: [{
dir: "/project/skill",
rootPath: "/project/skill",
}],
}, "git-a"),
true
);
});
test("isReusableAgentViewerServer ignores git for matching dist bundle versions", () => {
const distServer = {
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
serverMode: "serve",
viewerVersion: "0.3.2",
git: "git-b",
};
assert.equal(
isReusableAgentViewerServer(distServer, {
mode: "serve",
viewerVersion: "0.3.2",
git: "git-a",
}),
true
);
assert.equal(
isReusableAgentViewerServer(distServer, {
mode: "serve",
viewerVersion: "0.3.3",
git: "git-a",
}),
false
);
assert.equal(
isReusableAgentViewerServer({
...distServer,
serverMode: "dev",
}, {
mode: "serve",
viewerVersion: "0.3.2",
git: "git-a",
}),
false
);
assert.equal(
isReusableAgentViewerServer({
...distServer,
serverMode: "serve",
}, {
mode: "dev",
viewerVersion: "0.3.2",
git: "git-a",
}),
false
);
});
test("agentViewerUrl includes the selected absolute directory", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const url = agentViewerUrl("http://127.0.0.1:4178", directory);
assert.equal(new URL(url).searchParams.get("dir"), directory);
});
test("activateAgentViewerDirectory posts the requested dir to the activation endpoint", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const requests = [];
const result = await activateAgentViewerDirectory({
baseUrl: "http://127.0.0.1:4178",
directory,
fetchImpl: async (url, options) => {
requests.push({ url: String(url), method: options?.method || "GET" });
return {
ok: true,
json: async () => ({
ok: true,
directory: {
dir: directory,
rootPath: directory,
rootName: path.basename(directory),
},
server: {
app: "cad-viewer",
serverFeatures: ["directory-activation"],
},
}),
};
},
});
assert.equal(result.directory, directory);
assert.equal(new URL(result.viewerUrl).searchParams.get("dir"), directory);
assert.deepEqual(result.activeDirectory, {
dir: directory,
rootPath: directory,
rootName: path.basename(directory),
});
assert.equal(requests[0].method, "POST");
assert.equal(new URL(requests[0].url).pathname, "/__cad/directory/activate");
assert.equal(new URL(requests[0].url).searchParams.get("dir"), directory);
});
test("resolveAgentViewerPort reuses matching registry servers before free lower ports", async () => {
const probes = [];
const result = await resolveAgentViewerPort({
forwardedArgs: ["--host", "127.0.0.1", "--port", "4178"],
git: "git-a",
registryServers: [{
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-a",
port: 5173,
url: "http://127.0.0.1:5173",
}],
probePort: async ({ host, port }) => {
probes.push(`${host}:${port}`);
return {
status: "viewer",
port,
baseUrl: `http://${host}:${port}`,
serverInfo: {
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-a",
},
};
},
});
assert.equal(result.action, "reuse");
assert.equal(result.port, 5173);
assert.deepEqual(probes, ["127.0.0.1:5173"]);
});
test("resolveAgentViewerPort reuses matching-version dist servers across git branches", async () => {
const probes = [];
const result = await resolveAgentViewerPort({
forwardedArgs: ["--host", "127.0.0.1", "--port", "4178"],
git: "git-a",
mode: "serve",
viewerVersion: "0.3.2",
registryServers: [{
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
serverMode: "serve",
viewerVersion: "0.3.2",
git: "git-b",
port: 5173,
url: "http://127.0.0.1:5173",
}],
probePort: async ({ host, port }) => {
probes.push(`${host}:${port}`);
return {
status: "viewer",
port,
baseUrl: `http://${host}:${port}`,
serverInfo: {
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
serverMode: "serve",
viewerVersion: "0.3.2",
git: "git-b",
},
};
},
});
assert.equal(result.action, "reuse");
assert.equal(result.port, 5173);
assert.deepEqual(probes, ["127.0.0.1:5173"]);
});
test("resolveAgentViewerPort skips other viewers and starts on the first closed port", async () => {
const probes = [];
const result = await resolveAgentViewerPort({
forwardedArgs: ["--port", "4178"],
git: "git-a",
registryServers: [],
portScanLimit: 3,
probePort: async ({ host, port }) => {
probes.push(port);
if (port === 4178) {
return {
status: "viewer",
port,
baseUrl: `http://${host}:${port}`,
serverInfo: {
app: "cad-viewer",
serverApiVersion: 2,
dynamicRoot: true,
serverFeatures: ["directory-activation"],
git: "git-b",
},
};
}
return {
status: "closed",
port,
baseUrl: `http://${host}:${port}`,
};
},
});
assert.equal(result.action, "start");
assert.equal(result.port, 4179);
assert.deepEqual(probes, [4178, 4179]);
});
test("probeAgentViewerPort reports permission-blocked local probes", async () => {
const error = new TypeError("fetch failed");
error.cause = { code: "EPERM" };
const result = await probeAgentViewerPort({
port: 4178,
fetchImpl: async () => {
throw error;
},
});
assert.equal(result.status, "blocked");
assert.equal(result.port, 4178);
});
test("resolveAgentStartLaunch starts the selected free port", async (t) => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "cad-viewer-agent-start-directory-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const result = await resolveAgentStartLaunch({
argv: ["--viewer-start-mode", "serve", "--host", "127.0.0.1", "--dir", directory, "--port", "4178"],
env: {},
packageRoot: "/project/viewer",
nodePath: "/node",
registryServers: [],
probePort: async ({ host, port }) => ({
status: port === 4178 ? "occupied" : "closed",
port,
baseUrl: `http://${host}:${port}`,
}),
});
assert.equal(result.action, "start");
assert.equal(result.port, 4179);
assert.deepEqual(result.command.args, [
"/project/viewer/src/server/server.mjs",
"--host",
"127.0.0.1",
"--dir",
directory,
"--port",
"4179",
]);
assert.equal(new URL(result.viewerUrl).searchParams.get("dir"), directory);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,476 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
carryForwardRemoteArtifactStatuses,
createIgnoreMatcher,
DEFAULT_UPLOAD_EXCLUDE_PATTERNS,
parseIgnorePatterns,
parseUploadArgs,
rewriteCatalogForBlob,
uploadCatalogDirectoryToVercelBlob,
} from "./upload-catalog-to-blob.mjs";
function makeTempRepo() {
return fs.mkdtempSync(path.join(os.tmpdir(), "cad-viewer-blob-upload-"));
}
function writeFile(filePath, content = "") {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content);
}
function shortHash(value) {
return crypto.createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function fullHash(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
function gitLfsPointer({ oid, size }) {
return [
"version https://git-lfs.github.com/spec/v1",
`oid sha256:${oid}`,
`size ${size}`,
"",
].join("\n");
}
test("upload ignore patterns support comments, directory patterns, globs, and negation", () => {
const patterns = parseIgnorePatterns(`
# comment
/mechbench/
*.tmp
!keep.tmp
`);
const ignored = createIgnoreMatcher(patterns);
assert.equal(ignored({ relativePath: "mechbench", isDirectory: true }), true);
assert.equal(ignored({ relativePath: "mechbench/part.step" }), true);
assert.equal(ignored({ relativePath: "nested/file.tmp" }), true);
assert.equal(ignored({ relativePath: "keep.tmp" }), false);
assert.equal(ignored({ relativePath: "parts/keep.step" }), false);
});
test("parseUploadArgs accepts a directory and repeated ignore options", () => {
assert.deepEqual(
parseUploadArgs([
"models",
"--ignore-file",
".vieweruploadignore",
"--exclude",
"/mechbench/",
"--exclude",
"/mechbench2/",
"--concurrency",
"2",
"--skip-existing",
"--fetch-missing-lfs",
], {}),
{
directory: "models",
ignoreFiles: [".vieweruploadignore"],
excludePatterns: ["/mechbench/", "/mechbench2/"],
concurrency: 2,
skipExisting: true,
fetchMissingLfs: true,
}
);
});
test("parseUploadArgs rejects removed root-dir and workspace-root flags", () => {
assert.throws(
() => parseUploadArgs(["--root-dir", "/repo/models"], {}),
/--root-dir has been removed/
);
assert.throws(
() => parseUploadArgs(["--root-dir=/repo/models"], {}),
/--root-dir has been removed/
);
assert.throws(
() => parseUploadArgs(["--workspace-root", "/repo"], {}),
/Unknown option: --workspace-root/
);
assert.throws(
() => parseUploadArgs([], { VIEWER_LOCAL_ROOT_DIR: "/repo/models" }),
/VIEWER_LOCAL_ROOT_DIR.*removed/
);
assert.throws(
() => parseUploadArgs([], { VIEWER_LOCAL_WORKSPACE_ROOT: "/repo" }),
/VIEWER_LOCAL_ROOT_DIR.*removed/
);
});
test("rewriteCatalogForBlob annotates STEP assets without publishing Python source refs", () => {
const repoRoot = makeTempRepo();
const rootPath = path.join(repoRoot, "models");
writeFile(path.join(rootPath, "parts/bracket.step"), "ISO-10303-21;\nEND-ISO-10303-21;\n");
writeFile(path.join(rootPath, "parts/bracket.py"), "def gen_step():\n return None\n");
const uploads = new Map([
["parts/bracket.step", {
fileRef: "parts/bracket.step",
filePath: path.join(rootPath, "parts/bracket.step"),
url: "https://blob.test/models2/parts/bracket.step",
hash: "step-hash",
bytes: 12,
}],
["parts/bracket.py", {
fileRef: "parts/bracket.py",
filePath: path.join(rootPath, "parts/bracket.py"),
url: "https://blob.test/models2/parts/bracket.py",
hash: "py-hash",
bytes: 34,
}],
]);
const catalog = rewriteCatalogForBlob({
schemaVersion: 4,
entries: [
{
file: "parts/bracket.step",
kind: "part",
sourceKind: "python",
source: {
file: "models/parts/bracket.py",
sourcePath: "models/parts/bracket.py",
sourceHash: "source-hash",
},
sourceStatus: {
sourceKind: "python",
sourcePath: "models/parts/bracket.py",
},
artifact: {
ok: false,
error: "missing_source_path",
sourceKind: "python",
stepPath: "parts/bracket.step",
glbPath: "parts/.bracket.step.glb",
message: "GLB STEP_topology is missing required sourcePath identity: parts/.bracket.step.glb.",
},
},
],
}, {
uploads,
repoRoot,
rootPath,
});
assert.deepEqual(catalog.entries[0].step, {
file: "parts/bracket.step",
url: "https://blob.test/models2/parts/bracket.step",
hash: "step-hash",
bytes: 12,
});
assert.equal(catalog.entries[0].sourceKind, "python");
assert.equal(catalog.entries[0].source, undefined);
assert.equal(catalog.entries[0].sourceStatus, undefined);
assert.equal(catalog.entries[0].artifact, undefined);
assert.equal(JSON.stringify(catalog).includes(".py"), false);
});
test("rewriteCatalogForBlob keeps non-source-path STEP artifact warnings", () => {
const repoRoot = makeTempRepo();
const rootPath = path.join(repoRoot, "models");
const catalog = rewriteCatalogForBlob({
schemaVersion: 4,
entries: [
{
file: "parts/bracket.step",
kind: "part",
sourceKind: "python",
artifact: {
ok: false,
error: "missing_edge_topology",
sourceKind: "python",
stepPath: "parts/bracket.step",
glbPath: "parts/.bracket.step.glb",
message: "STEP topology validation requires readable STEP_topology edgeView in the GLB.",
},
},
],
}, {
uploads: new Map(),
repoRoot,
rootPath,
});
assert.equal(catalog.entries[0].artifact.error, "missing_edge_topology");
});
test("uploadCatalogDirectoryToVercelBlob applies default catalog exclusions", async () => {
const repoRoot = makeTempRepo();
writeFile(path.join(repoRoot, "models/keep.stl"), "solid keep\nendsolid keep\n");
writeFile(path.join(repoRoot, "models/part.step"), "ISO-10303-21;\nEND-ISO-10303-21;\n");
const stepModuleBody = "export default { manifest: { schemaVersion: 1 } };\n";
writeFile(path.join(repoRoot, "models/.part.step.js"), stepModuleBody);
writeFile(path.join(repoRoot, "models/mechbench/skipped.stl"), "solid skip\nendsolid skip\n");
writeFile(path.join(repoRoot, "models/mechbench2/skipped.stl"), "solid skip\nendsolid skip\n");
writeFile(path.join(repoRoot, "models/7dof_arm/skipped.step"), "ISO-10303-21;\nEND-ISO-10303-21;\n");
writeFile(path.join(repoRoot, "models/source.py"), "def gen_step():\n return None\n");
const putCalls = [];
const result = await uploadCatalogDirectoryToVercelBlob({
directory: "models",
env: {
VIEWER_ASSET_BACKEND: "vercel-blob",
VIEWER_VERCEL_BLOB_PREFIX: "models2",
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
},
cwd: repoRoot,
client: {
put: async (pathname, body, options) => {
putCalls.push({ pathname, body, options });
return { pathname, url: `https://blob.test/${pathname}` };
},
},
logger: { log() {} },
});
assert.deepEqual(putCalls.map((call) => call.pathname).sort(), [
`models2/.part.step.${shortHash(stepModuleBody)}.js`,
"models2/catalog.json",
"models2/keep.stl",
"models2/part.step",
]);
assert.equal(
putCalls.find((call) => call.pathname === `models2/.part.step.${shortHash(stepModuleBody)}.js`).options.contentType,
"text/javascript; charset=utf-8",
);
assert.equal(putCalls.find((call) => call.pathname === "models2/keep.stl").options.contentType, "model/stl");
assert.equal(result.uploadedFiles, 3);
assert.equal(result.catalogEntries, 2);
assert.equal(result.rootDir, "");
assert.equal(result.rootPath, path.join(repoRoot, "models"));
const catalogUpload = putCalls.find((call) => call.pathname === "models2/catalog.json");
assert.equal(catalogUpload.options.cacheControlMaxAge, 60);
assert.equal(putCalls.find((call) => call.pathname === "models2/keep.stl").options.cacheControlMaxAge, undefined);
const uploadedCatalog = JSON.parse(catalogUpload.body);
assert.deepEqual(uploadedCatalog.entries.map((entry) => entry.file), ["keep.stl", "part.step"]);
assert.equal(uploadedCatalog.entries[0].url, "https://blob.test/models2/keep.stl");
assert.equal(uploadedCatalog.entries[1].step.url, "https://blob.test/models2/part.step");
assert.equal(
uploadedCatalog.entries[1].moduleUrl,
`https://blob.test/models2/.part.step.${shortHash(stepModuleBody)}.js`
);
assert.deepEqual(result.ignoredPatterns.slice(0, DEFAULT_UPLOAD_EXCLUDE_PATTERNS.length), DEFAULT_UPLOAD_EXCLUDE_PATTERNS);
});
test("uploadCatalogDirectoryToVercelBlob skips assets already present in the remote catalog", async () => {
const repoRoot = makeTempRepo();
const existingContent = "solid existing\nendsolid existing\n";
const existingHash = fullHash(existingContent);
const existingBytes = Buffer.byteLength(existingContent);
writeFile(path.join(repoRoot, "models/existing.stl"), gitLfsPointer({
oid: existingHash,
size: existingBytes,
}));
writeFile(path.join(repoRoot, "models/new.stl"), "solid new\nendsolid new\n");
const remoteCatalog = {
schemaVersion: 4,
entries: [{
file: "existing.stl",
kind: "mesh",
url: "https://blob.test/models2/existing.stl",
hash: existingHash,
bytes: existingBytes,
}],
};
const putCalls = [];
const getCalls = [];
const result = await uploadCatalogDirectoryToVercelBlob({
directory: "models",
skipExisting: true,
env: {
VIEWER_ASSET_BACKEND: "vercel-blob",
VIEWER_VERCEL_BLOB_PREFIX: "models2",
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
},
cwd: repoRoot,
client: {
get: async (pathname, options) => {
getCalls.push({ pathname, options });
return {
stream: new Response(JSON.stringify(remoteCatalog)).body,
};
},
put: async (pathname, body, options) => {
putCalls.push({ pathname, body, options });
return { pathname, url: `https://blob.test/${pathname}` };
},
},
logger: { log() {} },
});
assert.deepEqual(getCalls, [{
pathname: "models2/catalog.json",
options: {
access: "public",
token: "test-token",
},
}]);
assert.deepEqual(putCalls.map((call) => call.pathname).sort(), [
"models2/catalog.json",
"models2/new.stl",
]);
assert.equal(result.uploadedFiles, 1);
assert.equal(result.skippedFiles, 1);
const uploadedCatalog = JSON.parse(putCalls.find((call) => call.pathname === "models2/catalog.json").body);
assert.equal(putCalls.find((call) => call.pathname === "models2/catalog.json").options.cacheControlMaxAge, 60);
const existingEntry = uploadedCatalog.entries.find((entry) => entry.file === "existing.stl");
assert.equal(existingEntry.url, "https://blob.test/models2/existing.stl");
assert.equal(existingEntry.hash, existingHash);
assert.equal(existingEntry.bytes, existingBytes);
});
test("uploadCatalogDirectoryToVercelBlob rejects missing Git LFS pointers without explicit fetching", async () => {
const repoRoot = makeTempRepo();
writeFile(path.join(repoRoot, "models/missing.stl"), gitLfsPointer({
oid: "a".repeat(64),
size: 12345,
}));
await assert.rejects(
() => uploadCatalogDirectoryToVercelBlob({
directory: "models",
env: {
VIEWER_ASSET_BACKEND: "vercel-blob",
VIEWER_VERCEL_BLOB_PREFIX: "models2",
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
},
cwd: repoRoot,
client: {
put: async () => {
throw new Error("LFS pointer should not be uploaded");
},
},
logger: { log() {} },
}),
/missing\.stl is a Git LFS pointer/
);
});
test("uploadCatalogDirectoryToVercelBlob honors positional directory from npm caller cwd", async () => {
const repoRoot = makeTempRepo();
writeFile(path.join(repoRoot, "models/keep.stl"), "solid keep\nendsolid keep\n");
writeFile(path.join(repoRoot, "viewer/package.json"), "{}\n");
const putCalls = [];
const result = await uploadCatalogDirectoryToVercelBlob({
directory: "models",
env: {
INIT_CWD: repoRoot,
VIEWER_ASSET_BACKEND: "vercel-blob",
VIEWER_VERCEL_BLOB_PREFIX: "models2",
VIEWER_VERCEL_BLOB_READ_WRITE_TOKEN: "test-token",
},
cwd: path.join(repoRoot, "viewer"),
client: {
put: async (pathname, body, options) => {
putCalls.push({ pathname, body, options });
return { pathname, url: `https://blob.test/${pathname}` };
},
},
logger: { log() {} },
});
assert.deepEqual(putCalls.map((call) => call.pathname).sort(), [
"models2/catalog.json",
"models2/keep.stl",
]);
assert.equal(result.catalogEntries, 1);
assert.equal(result.rootDir, "");
assert.equal(result.rootPath, path.join(repoRoot, "models"));
});
test("carryForwardRemoteArtifactStatuses reuses remote status for unchanged LFS pointer artifacts", () => {
const rootPath = makeTempRepo();
const glbHash = fullHash("real glb bytes");
writeFile(path.join(rootPath, ".part.step.glb"), gitLfsPointer({ oid: glbHash, size: 1234 }));
writeFile(path.join(rootPath, ".real.step.glb"), "actual binary content");
const pointerWarning = {
ok: false,
error: "missing_step_topology",
glbPath: ".part.step.glb",
message: "STEP topology validation requires readable STEP_topology indexView in the GLB: .part.step.glb.",
};
const realFileWarning = {
ok: false,
error: "missing_step_topology",
glbPath: ".real.step.glb",
message: "STEP topology validation requires readable STEP_topology indexView in the GLB: .real.step.glb.",
};
const catalog = {
schemaVersion: 4,
entries: [
{ file: "part.step", hash: glbHash, artifact: pointerWarning },
{ file: "real.step", hash: fullHash("actual binary content"), artifact: realFileWarning },
{ file: "changed.step", hash: "different-local-hash", artifact: { ...pointerWarning } },
],
};
const existingCatalog = {
schemaVersion: 4,
entries: [
{ file: "part.step", hash: glbHash },
{ file: "real.step", hash: fullHash("actual binary content") },
{ file: "changed.step", hash: "remote-hash" },
],
};
const result = carryForwardRemoteArtifactStatuses(catalog, { existingCatalog, rootPath });
assert.equal(result.entries.find((entry) => entry.file === "part.step").artifact, undefined);
assert.deepEqual(
result.entries.find((entry) => entry.file === "real.step").artifact,
realFileWarning,
);
assert.deepEqual(
result.entries.find((entry) => entry.file === "changed.step").artifact,
pointerWarning,
);
});
test("carryForwardRemoteArtifactStatuses copies remote warnings instead of pointer-derived ones", () => {
const rootPath = makeTempRepo();
const glbHash = fullHash("glb with legit warning");
writeFile(path.join(rootPath, ".warned.step.glb"), gitLfsPointer({ oid: glbHash, size: 99 }));
const remoteWarning = {
ok: false,
error: "unsupported_step_topology",
glbPath: ".warned.step.glb",
message: "STEP topology validation requires STEP_topology schemaVersion 2 in the GLB.",
};
const result = carryForwardRemoteArtifactStatuses(
{
schemaVersion: 4,
entries: [{
file: "warned.step",
hash: glbHash,
artifact: {
ok: false,
error: "missing_step_topology",
glbPath: ".warned.step.glb",
message: "unreadable pointer",
},
}],
},
{
existingCatalog: {
schemaVersion: 4,
entries: [{ file: "warned.step", hash: glbHash, artifact: remoteWarning }],
},
rootPath,
},
);
assert.deepEqual(result.entries[0].artifact, remoteWarning);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,747 @@
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from "react";
import { copyImageBlobToClipboard } from "@/ui/clipboard";
import { triggerBlobDownload } from "@/ui/download";
import { resolveThemeFillColor } from "cadjs/lib/themeSettings";
import ViewPlaneControl from "./viewer/ViewPlaneControl";
const MIN_VIEW_SIZE = 1;
const VIEW_PADDING_RATIO = 0.08;
const MAX_ZOOM_IN = 20;
const MAX_ZOOM_OUT = 12;
const WHEEL_ZOOM_FACTOR = 1.12;
const VIEWBOX_EPSILON = 1e-6;
const POINT_KEY_SCALE = 1000;
const GEOMETRY_EPSILON = 1e-3;
const ANGLE_EPSILON = 1e-9;
const ARC_CHORD_TOLERANCE_MM = 0.35;
const MIN_ARC_SEGMENTS = 10;
const MAX_ARC_SEGMENTS = 160;
const DXF_2D_VIEW_PLANE_ORIENTATION = Object.freeze({
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1]
});
const DXF_2D_VIEW_PLANE_FACES = Object.freeze([
{ id: "x+", title: "Fit 2D view", direction: [1, 0, 0] },
{ id: "x-", title: "Fit 2D view", direction: [-1, 0, 0] },
{ id: "y+", title: "Fit 2D view", direction: [0, 1, 0] },
{ id: "y-", title: "Fit 2D view", direction: [0, -1, 0] }
]);
const DXF_2D_VIEW_PLANE_MESH = Object.freeze({ format: "dxf-2d" });
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 normalizePoint(point) {
if (!Array.isArray(point) || point.length < 2) {
return [0, 0];
}
return [toFiniteNumber(point[0]), toFiniteNumber(point[1])];
}
function pointsEqual(a, b, epsilon = GEOMETRY_EPSILON) {
return Math.abs(a[0] - b[0]) <= epsilon && Math.abs(a[1] - b[1]) <= epsilon;
}
function pointKey(point) {
return `${Math.round(point[0] * POINT_KEY_SCALE)}:${Math.round(point[1] * POINT_KEY_SCALE)}`;
}
function reversePoints(points) {
return [...points].reverse();
}
function removeDuplicateClosure(points) {
if (points.length > 1 && pointsEqual(points[0], points[points.length - 1])) {
return points.slice(0, -1);
}
return points;
}
function removeConsecutiveDuplicates(points) {
const deduped = [];
for (const point of points) {
if (deduped.length && pointsEqual(deduped[deduped.length - 1], point)) {
continue;
}
deduped.push(point);
}
return removeDuplicateClosure(deduped);
}
function normalizeAngle(angleDeg) {
const value = toFiniteNumber(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 = (toFiniteNumber(angleDeg) * Math.PI) / 180;
return [
center[0] + radius * Math.cos(radians),
center[1] + radius * Math.sin(radians)
];
}
function arcExtremaPoints(arc) {
const center = normalizePoint(arc?.center);
const radius = Math.max(toFiniteNumber(arc?.radius), 0);
const startAngleDeg = toFiniteNumber(arc?.startAngleDeg);
const sweepAngleDeg = toFiniteNumber(arc?.sweepAngleDeg);
const endAngleDeg = startAngleDeg + sweepAngleDeg;
const points = [
pointOnCircle(center, radius, startAngleDeg),
pointOnCircle(center, radius, endAngleDeg)
];
for (const candidateAngle of [0, 90, 180, 270]) {
if (angleInSweep(candidateAngle, startAngleDeg, sweepAngleDeg)) {
points.push(pointOnCircle(center, radius, candidateAngle));
}
}
return points;
}
function boundsFromPoints(points) {
if (!points.length) {
return null;
}
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 expandBounds(current, nextBounds) {
if (!nextBounds) {
return current;
}
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 sampleCountForSweep(radius, sweepRadians) {
const safeRadius = Math.max(Math.abs(radius), 0.01);
const chordRatio = clamp(1 - (ARC_CHORD_TOLERANCE_MM / safeRadius), -1, 1);
const maxSegmentAngle = chordRatio <= -1
? Math.PI / 8
: clamp(2 * Math.acos(chordRatio), Math.PI / 64, Math.PI / 10);
return clamp(
Math.ceil(Math.max(Math.abs(sweepRadians), Math.PI / 36) / maxSegmentAngle),
MIN_ARC_SEGMENTS,
MAX_ARC_SEGMENTS
);
}
function sampleArcPoints(center, radius, startAngleDeg, sweepAngleDeg) {
const startAngleRad = (toFiniteNumber(startAngleDeg) * Math.PI) / 180;
const sweepAngleRad = (toFiniteNumber(sweepAngleDeg) * Math.PI) / 180;
const segments = sampleCountForSweep(radius, sweepAngleRad);
const points = [];
for (let index = 0; index <= segments; index += 1) {
const t = index / segments;
const angle = startAngleRad + sweepAngleRad * t;
points.push([
center[0] + radius * Math.cos(angle),
center[1] + radius * Math.sin(angle)
]);
}
return points;
}
function sampleCirclePoints(center, radius) {
return removeDuplicateClosure(sampleArcPoints(center, radius, 0, 360));
}
function readGeometryRecords(dxfData) {
const geometry = dxfData?.geometry || {};
const lines = Array.isArray(geometry.lines) ? geometry.lines : [];
const arcs = Array.isArray(geometry.arcs) ? geometry.arcs : [];
const circles = Array.isArray(geometry.circles) ? geometry.circles : [];
const cutPrimitives = [];
const cutCircleLoops = [];
let rawBounds = null;
for (const line of lines) {
const start = normalizePoint(line?.start);
const end = normalizePoint(line?.end);
rawBounds = expandBounds(rawBounds, boundsFromPoints([start, end]));
if (String(line?.kind || "").trim().toLowerCase() === "bend" || pointsEqual(start, end)) {
continue;
}
cutPrimitives.push({ points: [start, end] });
}
for (const arc of arcs) {
const center = normalizePoint(arc?.center);
const radius = Math.max(toFiniteNumber(arc?.radius), 0);
if (radius <= 0) {
continue;
}
rawBounds = expandBounds(rawBounds, boundsFromPoints(arcExtremaPoints(arc)));
const points = sampleArcPoints(
center,
radius,
toFiniteNumber(arc?.startAngleDeg),
toFiniteNumber(arc?.sweepAngleDeg)
);
if (String(arc?.kind || "").trim().toLowerCase() === "bend") {
continue;
}
cutPrimitives.push({ points });
}
for (const circle of circles) {
const center = normalizePoint(circle?.center);
const radius = Math.max(toFiniteNumber(circle?.radius), 0);
if (radius <= 0) {
continue;
}
rawBounds = expandBounds(rawBounds, {
minX: center[0] - radius,
minY: center[1] - radius,
maxX: center[0] + radius,
maxY: center[1] + radius
});
if (String(circle?.kind || "").trim().toLowerCase() === "bend") {
continue;
}
cutCircleLoops.push(sampleCirclePoints(center, radius));
}
return {
cutPrimitives,
cutCircleLoops,
rawBounds
};
}
function buildCutLoops(dxfData) {
const { cutPrimitives, cutCircleLoops, rawBounds } = readGeometryRecords(dxfData);
const loops = [];
if (cutPrimitives.length) {
const adjacency = new Map();
const visited = new Set();
const addAdjacency = (key, value) => {
const existing = adjacency.get(key);
if (existing) {
existing.push(value);
return;
}
adjacency.set(key, [value]);
};
cutPrimitives.forEach((primitive, index) => {
const startKey = pointKey(primitive.points[0]);
const endKey = pointKey(primitive.points[primitive.points.length - 1]);
addAdjacency(startKey, { index, reverse: false });
addAdjacency(endKey, { index, reverse: true });
});
for (let primitiveIndex = 0; primitiveIndex < cutPrimitives.length; primitiveIndex += 1) {
if (visited.has(primitiveIndex)) {
continue;
}
visited.add(primitiveIndex);
let loopPoints = [...cutPrimitives[primitiveIndex].points];
const startKey = pointKey(loopPoints[0]);
let currentKey = pointKey(loopPoints[loopPoints.length - 1]);
let guard = 0;
while (currentKey !== startKey) {
const nextOptions = (adjacency.get(currentKey) || []).filter(({ index }) => !visited.has(index));
if (!nextOptions.length) {
return { loops: [], rawBounds };
}
const nextOption = nextOptions[0];
visited.add(nextOption.index);
const primitivePoints = cutPrimitives[nextOption.index].points;
const orientedPoints = nextOption.reverse ? reversePoints(primitivePoints) : primitivePoints;
loopPoints = loopPoints.concat(orientedPoints.slice(1));
currentKey = pointKey(orientedPoints[orientedPoints.length - 1]);
guard += 1;
if (guard > cutPrimitives.length + 4) {
return { loops: [], rawBounds };
}
}
loopPoints = removeConsecutiveDuplicates(loopPoints);
if (loopPoints.length >= 3) {
loops.push(loopPoints);
}
}
}
for (const circleLoop of cutCircleLoops) {
if (circleLoop.length >= 3) {
loops.push(circleLoop);
}
}
return { loops, rawBounds };
}
function formatSvgNumber(value) {
const rounded = Math.round(toFiniteNumber(value) * 1_000_000) / 1_000_000;
return Math.abs(rounded) < ANGLE_EPSILON ? "0" : String(rounded);
}
function screenPoint(point, bounds) {
return [
point[0] - bounds.minX,
bounds.maxY - point[1]
];
}
function buildDxfMaterialFillPathData(dxfData) {
const { loops, rawBounds } = buildCutLoops(dxfData);
if (!rawBounds || !loops.length) {
return "";
}
return loops
.map((loop) => {
const screenLoop = loop.map((point) => screenPoint(point, rawBounds));
if (screenLoop.length < 3) {
return "";
}
const [first, ...rest] = screenLoop;
return [
`M ${formatSvgNumber(first[0])} ${formatSvgNumber(first[1])}`,
...rest.map((point) => `L ${formatSvgNumber(point[0])} ${formatSvgNumber(point[1])}`),
"Z"
].join(" ");
})
.filter(Boolean)
.join(" ");
}
function readCssVar(name, fallback) {
if (typeof window === "undefined") {
return fallback;
}
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function readPalette() {
return {
background: readCssVar("--ui-app-bg", "#f3f4f6"),
cutStroke: readCssVar("--ui-text", "#111827"),
bendStroke: readCssVar("--ui-accent", "#b45309")
};
}
function buildFittedViewBox(bounds, viewport) {
const width = Math.max(Number(bounds?.width) || 0, MIN_VIEW_SIZE);
const height = Math.max(Number(bounds?.height) || 0, MIN_VIEW_SIZE);
const viewportWidth = Math.max(Number(viewport?.width) || 0, 1);
const viewportHeight = Math.max(Number(viewport?.height) || 0, 1);
const paddedWidth = width * (1 + VIEW_PADDING_RATIO * 2);
const paddedHeight = height * (1 + VIEW_PADDING_RATIO * 2);
const contentAspect = paddedWidth / Math.max(paddedHeight, MIN_VIEW_SIZE);
const viewportAspect = viewportWidth / viewportHeight;
let viewWidth = paddedWidth;
let viewHeight = paddedHeight;
if (contentAspect > viewportAspect) {
viewHeight = viewWidth / viewportAspect;
} else {
viewWidth = viewHeight * viewportAspect;
}
return {
x: (width - viewWidth) / 2,
y: (height - viewHeight) / 2,
width: Math.max(viewWidth, MIN_VIEW_SIZE),
height: Math.max(viewHeight, MIN_VIEW_SIZE)
};
}
function clampViewBox(nextViewBox, baseViewBox) {
const minWidth = Math.max(baseViewBox.width / MAX_ZOOM_IN, MIN_VIEW_SIZE);
const minHeight = Math.max(baseViewBox.height / MAX_ZOOM_IN, MIN_VIEW_SIZE);
const maxWidth = Math.max(baseViewBox.width * MAX_ZOOM_OUT, MIN_VIEW_SIZE);
const maxHeight = Math.max(baseViewBox.height * MAX_ZOOM_OUT, MIN_VIEW_SIZE);
return {
x: Number(nextViewBox.x) || 0,
y: Number(nextViewBox.y) || 0,
width: Math.min(Math.max(Number(nextViewBox.width) || baseViewBox.width, minWidth), maxWidth),
height: Math.min(Math.max(Number(nextViewBox.height) || baseViewBox.height, minHeight), maxHeight)
};
}
function areViewBoxesEqual(a, b) {
return (
Math.abs((Number(a?.x) || 0) - (Number(b?.x) || 0)) < VIEWBOX_EPSILON &&
Math.abs((Number(a?.y) || 0) - (Number(b?.y) || 0)) < VIEWBOX_EPSILON &&
Math.abs((Number(a?.width) || 0) - (Number(b?.width) || 0)) < VIEWBOX_EPSILON &&
Math.abs((Number(a?.height) || 0) - (Number(b?.height) || 0)) < VIEWBOX_EPSILON
);
}
function canvasToBlob(canvas) {
return new Promise((resolve, reject) => {
if (typeof canvas.toBlob === "function") {
canvas.toBlob((value) => {
if (value) {
resolve(value);
return;
}
reject(new Error("Failed to encode screenshot"));
}, "image/png");
return;
}
try {
const dataUrl = canvas.toDataURL("image/png");
const base64 = dataUrl.split(",")[1] || "";
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
resolve(new Blob([bytes], { type: "image/png" }));
} catch (error) {
reject(error instanceof Error ? error : new Error("Failed to encode screenshot"));
}
});
}
async function buildSvgScreenshotBlob(svgElement, { width, height, backgroundColor }) {
const serializer = new XMLSerializer();
const svgMarkup = serializer.serializeToString(svgElement);
const blob = new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" });
const objectUrl = URL.createObjectURL(blob);
try {
const image = await new Promise((resolve, reject) => {
const nextImage = new Image();
nextImage.onload = () => resolve(nextImage);
nextImage.onerror = () => reject(new Error("Failed to render DXF screenshot"));
nextImage.src = objectUrl;
});
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Failed to create screenshot canvas");
}
context.fillStyle = backgroundColor;
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
return await canvasToBlob(canvas);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
const DxfViewer = forwardRef(function DxfViewer({
dxfData,
modelKey,
themeSettings = null,
showViewPlane = true,
viewPlaneOffsetRight = 16,
viewPlaneOffsetBottom = "1rem",
viewPlaneHeader = null,
onViewerAlertChange
}, ref) {
const containerRef = useRef(null);
const svgRef = useRef(null);
const dragStateRef = useRef(null);
const previousModelKeyRef = useRef(modelKey);
const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
const [viewBox, setViewBox] = useState({ x: 0, y: 0, width: 1, height: 1 });
const palette = readPalette();
const materialFill = resolveThemeFillColor(themeSettings?.materials || {}, 0);
const boundsWidth = Math.max(Number(dxfData?.bounds?.width) || 0, MIN_VIEW_SIZE);
const boundsHeight = Math.max(Number(dxfData?.bounds?.height) || 0, MIN_VIEW_SIZE);
const fillPathData = useMemo(() => {
try {
return buildDxfMaterialFillPathData(dxfData);
} catch {
return "";
}
}, [dxfData]);
const fittedViewBox = useMemo(
() => buildFittedViewBox(
{ width: boundsWidth, height: boundsHeight },
viewportSize
),
[boundsHeight, boundsWidth, viewportSize.height, viewportSize.width]
);
useEffect(() => {
onViewerAlertChange?.(null);
}, [modelKey, onViewerAlertChange]);
useEffect(() => {
const element = containerRef.current;
if (!element || typeof ResizeObserver !== "function") {
return undefined;
}
const observer = new ResizeObserver((entries) => {
const nextEntry = entries[0];
if (!nextEntry) {
return;
}
setViewportSize((current) => {
const nextWidth = nextEntry.contentRect.width;
const nextHeight = nextEntry.contentRect.height;
if (current.width === nextWidth && current.height === nextHeight) {
return current;
}
return {
width: nextWidth,
height: nextHeight
};
});
});
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const modelChanged = previousModelKeyRef.current !== modelKey;
previousModelKeyRef.current = modelKey;
setViewBox((current) => {
if (!modelChanged && areViewBoxesEqual(current, fittedViewBox)) {
return current;
}
return fittedViewBox;
});
}, [
fittedViewBox.height,
fittedViewBox.width,
fittedViewBox.x,
fittedViewBox.y,
modelKey
]);
useEffect(() => {
const clearDrag = () => {
dragStateRef.current = null;
};
window.addEventListener("pointerup", clearDrag);
window.addEventListener("pointercancel", clearDrag);
return () => {
window.removeEventListener("pointerup", clearDrag);
window.removeEventListener("pointercancel", clearDrag);
};
}, []);
useImperativeHandle(ref, () => ({
async captureScreenshot({ filename = "cad-screenshot.png", mode = "download" } = {}) {
const container = containerRef.current;
const svgElement = svgRef.current;
if (!container || !svgElement) {
throw new Error("CAD Viewer not ready");
}
const rect = container.getBoundingClientRect();
const pixelRatio = Math.max(window.devicePixelRatio || 1, 1);
const width = Math.max(1, Math.round(rect.width * pixelRatio));
const height = Math.max(1, Math.round(rect.height * pixelRatio));
const blobPromise = buildSvgScreenshotBlob(svgElement, {
width,
height,
backgroundColor: palette.background
});
if (mode === "clipboard") {
return await copyImageBlobToClipboard(blobPromise);
}
const blob = await blobPromise;
return triggerBlobDownload(blob, { filename });
}
}), [palette.background]);
const handlePointerDown = (event) => {
if (event.button !== 0) {
return;
}
event.currentTarget.setPointerCapture?.(event.pointerId);
dragStateRef.current = {
clientX: event.clientX,
clientY: event.clientY
};
};
const handlePointerMove = (event) => {
const dragState = dragStateRef.current;
const rect = containerRef.current?.getBoundingClientRect();
if (!dragState || !rect || !rect.width || !rect.height) {
return;
}
const deltaX = event.clientX - dragState.clientX;
const deltaY = event.clientY - dragState.clientY;
dragStateRef.current = {
clientX: event.clientX,
clientY: event.clientY
};
setViewBox((current) => ({
...current,
x: current.x - (deltaX / rect.width) * current.width,
y: current.y - (deltaY / rect.height) * current.height
}));
};
const handleWheel = (event) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || !rect.width || !rect.height) {
return;
}
event.preventDefault();
const cursorX = (event.clientX - rect.left) / rect.width;
const cursorY = (event.clientY - rect.top) / rect.height;
const scale = event.deltaY > 0 ? WHEEL_ZOOM_FACTOR : 1 / WHEEL_ZOOM_FACTOR;
setViewBox((current) => {
const nextWidth = current.width * scale;
const nextHeight = current.height * scale;
const anchorX = current.x + current.width * cursorX;
const anchorY = current.y + current.height * cursorY;
return clampViewBox(
{
x: anchorX - nextWidth * cursorX,
y: anchorY - nextHeight * cursorY,
width: nextWidth,
height: nextHeight
},
fittedViewBox
);
});
};
return (
<div
ref={containerRef}
className="absolute inset-0 touch-none select-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onDoubleClick={() => {
setViewBox(fittedViewBox);
}}
onWheel={handleWheel}
>
<svg
ref={svgRef}
className="h-full w-full"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
xmlns="http://www.w3.org/2000/svg"
>
<rect
x={viewBox.x}
y={viewBox.y}
width={viewBox.width}
height={viewBox.height}
fill={palette.background}
/>
{fillPathData ? (
<path
d={fillPathData}
fill={materialFill}
fillOpacity="0.96"
fillRule="evenodd"
stroke="none"
/>
) : null}
{Array.isArray(dxfData?.paths) ? dxfData.paths.map((path, index) => {
const isBendPath = path?.kind === "bend";
return (
<path
key={`${path?.layer || "path"}:${index}`}
d={String(path?.d || "")}
fill="none"
stroke={isBendPath ? palette.bendStroke : palette.cutStroke}
strokeDasharray={isBendPath ? "8 6" : undefined}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={isBendPath ? 1.5 : 1.75}
vectorEffect="non-scaling-stroke"
/>
);
}) : null}
{Array.isArray(dxfData?.circles) ? dxfData.circles.map((circle, index) => {
const isBendCircle = circle?.kind === "bend";
return (
<circle
key={`${circle?.layer || "circle"}:${index}`}
cx={Number(circle?.cx) || 0}
cy={Number(circle?.cy) || 0}
r={Math.max(Number(circle?.r) || 0, 0)}
fill="none"
stroke={isBendCircle ? palette.bendStroke : palette.cutStroke}
strokeDasharray={isBendCircle ? "8 6" : undefined}
strokeWidth={isBendCircle ? 1.5 : 1.75}
vectorEffect="non-scaling-stroke"
/>
);
}) : null}
</svg>
<ViewPlaneControl
showViewPlane={showViewPlane}
previewMode={false}
isLoading={false}
meshData={dxfData ? DXF_2D_VIEW_PLANE_MESH : null}
viewPlaneOffsetRight={viewPlaneOffsetRight}
viewPlaneOffsetBottom={viewPlaneOffsetBottom}
activeViewPlaneFace="z+"
viewPlaneFaces={DXF_2D_VIEW_PLANE_FACES}
viewPlaneOrientation={DXF_2D_VIEW_PLANE_ORIENTATION}
viewerTheme={null}
variant="2d"
viewPlaneHeader={viewPlaneHeader}
activateViewPlaneFace={() => {
setViewBox(fittedViewBox);
}}
activateDefaultViewPlane={() => {
setViewBox(fittedViewBox);
}}
/>
</div>
);
});
export default DxfViewer;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Accordion({
...props
}) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b border-[var(--ui-border)] last:border-b-0", className)}
{...props} />
);
}
function AccordionTrigger({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group flex min-h-9 flex-1 items-center justify-between gap-3 px-2.5 py-1.5 text-left text-[13px] font-normal text-[var(--ui-text)] outline-none transition-colors hover:bg-[var(--ui-panel-muted)] focus-visible:bg-[var(--ui-panel-muted)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--ui-accent-ring)] [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="size-4 shrink-0 text-[var(--ui-text-faint)]" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm"
{...props}
>
<div className={cn("pb-0", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -0,0 +1,148 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
const AlertDialogTrigger = React.forwardRef(function AlertDialogTrigger({
...props
}, ref) {
return <AlertDialogPrimitive.Trigger ref={ref} data-slot="alert-dialog-trigger" {...props} />;
});
function AlertDialogPortal({
...props
}) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
const AlertDialogOverlay = React.forwardRef(function AlertDialogOverlay({
className,
...props
}, ref) {
return (
<AlertDialogPrimitive.Overlay
ref={ref}
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props} />
);
});
const AlertDialogContent = React.forwardRef(function AlertDialogContent({
className,
...props
}, ref) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
data-slot="alert-dialog-content"
className={cn(
"cad-glass-popover fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-md border p-6 text-foreground shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props} />
</AlertDialogPortal>
);
});
function AlertDialogHeader({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} />
);
}
function AlertDialogFooter({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props} />
);
}
const AlertDialogTitle = React.forwardRef(function AlertDialogTitle({
className,
...props
}, ref) {
return (
<AlertDialogPrimitive.Title
ref={ref}
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props} />
);
});
const AlertDialogDescription = React.forwardRef(function AlertDialogDescription({
className,
...props
}, ref) {
return (
<AlertDialogPrimitive.Description
ref={ref}
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
});
const AlertDialogAction = React.forwardRef(function AlertDialogAction({
className,
...props
}, ref) {
return (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props} />
);
});
const AlertDialogCancel = React.forwardRef(function AlertDialogCancel({
className,
...props
}, ref) {
return (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} />
);
});
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
@@ -0,0 +1,62 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/ui/utils"
const alertVariants = cva(
"relative grid w-full gap-1 rounded-md border px-4 py-3 text-sm shadow-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
warning:
"border-chart-5/45 bg-chart-5/10 text-foreground [&>svg]:text-chart-5",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
);
}
function AlertTitle({
className,
...props
}) {
return (
<div
data-slot="alert-title"
className={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...props} />
);
}
function AlertDescription({
className,
...props
}) {
return (
<div
data-slot="alert-description"
className={cn("col-start-2 grid justify-items-start gap-1 text-muted-foreground text-sm [&_p]:leading-relaxed", className)}
{...props} />
);
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,42 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/ui/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-colors [&>svg]:size-3",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-white dark:bg-destructive/60",
"destructive-outline":
"border-destructive/45 bg-destructive/10 text-destructive dark:border-red-400/40 dark:text-red-300",
outline: "text-foreground",
warning: "border-amber-500/45 bg-amber-500/10 text-amber-800 dark:text-amber-300",
"warning-outline":
"border-amber-500/45 bg-amber-500/10 text-amber-800 dark:text-amber-300",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (
<span
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props} />
);
}
export { Badge, badgeVariants }
@@ -0,0 +1,117 @@
import * as React from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui";
import { cn } from "@/ui/utils";
function Breadcrumb({
...props
}) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({
className,
...props
}) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props} />
);
}
function BreadcrumbItem({
className,
...props
}) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props} />
);
}
function BreadcrumbLink({
asChild,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props} />
);
}
function BreadcrumbPage({
className,
...props
}) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
);
}
function BreadcrumbSeparator({
children,
className,
...props
}) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
children,
className,
...props
}) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}>
{children ?? (
<>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</>
)}
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};
@@ -0,0 +1,61 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/ui/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}, ref) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
ref={ref}
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
})
export { Button, buttonVariants }
@@ -0,0 +1,22 @@
import * as React from "react"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
const CollapsibleTrigger = React.forwardRef(function CollapsibleTrigger({
...props
}, ref) {
return (<CollapsiblePrimitive.CollapsibleTrigger ref={ref} data-slot="collapsible-trigger" {...props} />);
});
const CollapsibleContent = React.forwardRef(function CollapsibleContent({
...props
}, ref) {
return (<CollapsiblePrimitive.CollapsibleContent ref={ref} data-slot="collapsible-content" {...props} />);
});
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,505 @@
import * as React from "react"
import { Pipette } from "lucide-react"
import { cn } from "@/ui/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
const FORMAT_OPTIONS = ["hex", "rgb", "hsl"]
function clamp(value, min, max) {
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) {
return min
}
return Math.min(Math.max(numericValue, min), max)
}
function normalizeHexColor(value, fallback = "#ffffff") {
const rawValue = String(value || "").trim()
if (!HEX_COLOR_PATTERN.test(rawValue)) {
return fallback
}
if (rawValue.length === 4) {
return `#${rawValue[1]}${rawValue[1]}${rawValue[2]}${rawValue[2]}${rawValue[3]}${rawValue[3]}`.toLowerCase()
}
return rawValue.toLowerCase()
}
function componentToHex(value) {
return Math.round(clamp(value, 0, 255)).toString(16).padStart(2, "0")
}
function rgbToHex({ r, g, b }) {
return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`.toLowerCase()
}
function hexToRgb(value) {
const hex = normalizeHexColor(value)
return {
r: parseInt(hex.slice(1, 3), 16),
g: parseInt(hex.slice(3, 5), 16),
b: parseInt(hex.slice(5, 7), 16)
}
}
function rgbToHsv({ r, g, b }) {
const red = clamp(r, 0, 255) / 255
const green = clamp(g, 0, 255) / 255
const blue = clamp(b, 0, 255) / 255
const max = Math.max(red, green, blue)
const min = Math.min(red, green, blue)
const delta = max - min
let h = 0
if (delta !== 0) {
if (max === red) {
h = ((green - blue) / delta) % 6
} else if (max === green) {
h = (blue - red) / delta + 2
} else {
h = (red - green) / delta + 4
}
h *= 60
if (h < 0) {
h += 360
}
}
return {
h,
s: max === 0 ? 0 : (delta / max) * 100,
v: max * 100
}
}
function hsvToRgb({ h, s, v }) {
const hue = ((Number(h) % 360) + 360) % 360
const saturation = clamp(s, 0, 100) / 100
const value = clamp(v, 0, 100) / 100
const chroma = value * saturation
const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1))
const match = value - chroma
let red = 0
let green = 0
let blue = 0
if (hue < 60) {
red = chroma
green = secondary
} else if (hue < 120) {
red = secondary
green = chroma
} else if (hue < 180) {
green = chroma
blue = secondary
} else if (hue < 240) {
green = secondary
blue = chroma
} else if (hue < 300) {
red = secondary
blue = chroma
} else {
red = chroma
blue = secondary
}
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255
}
}
function rgbToHsl({ r, g, b }) {
const red = clamp(r, 0, 255) / 255
const green = clamp(g, 0, 255) / 255
const blue = clamp(b, 0, 255) / 255
const max = Math.max(red, green, blue)
const min = Math.min(red, green, blue)
const delta = max - min
let h = 0
let s = 0
const l = (max + min) / 2
if (delta !== 0) {
s = delta / (1 - Math.abs((2 * l) - 1))
if (max === red) {
h = ((green - blue) / delta) % 6
} else if (max === green) {
h = (blue - red) / delta + 2
} else {
h = (red - green) / delta + 4
}
h *= 60
if (h < 0) {
h += 360
}
}
return {
h,
s: s * 100,
l: l * 100
}
}
function hslToRgb({ h, s, l }) {
const hue = ((Number(h) % 360) + 360) % 360
const saturation = clamp(s, 0, 100) / 100
const lightness = clamp(l, 0, 100) / 100
const chroma = (1 - Math.abs((2 * lightness) - 1)) * saturation
const secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1))
const match = lightness - (chroma / 2)
let red = 0
let green = 0
let blue = 0
if (hue < 60) {
red = chroma
green = secondary
} else if (hue < 120) {
red = secondary
green = chroma
} else if (hue < 180) {
green = chroma
blue = secondary
} else if (hue < 240) {
green = secondary
blue = chroma
} else if (hue < 300) {
red = secondary
blue = chroma
} else {
red = chroma
blue = secondary
}
return {
r: (red + match) * 255,
g: (green + match) * 255,
b: (blue + match) * 255
}
}
function roundedColorNumbers(hex) {
const rgb = hexToRgb(hex)
const hsl = rgbToHsl(rgb)
return {
rgb,
hsl: {
h: Math.round(hsl.h),
s: Math.round(hsl.s),
l: Math.round(hsl.l)
},
hsv: rgbToHsv(rgb)
}
}
function ColorNumberInput({ label, value, min, max, onChange }) {
return (
<label className="min-w-0 space-y-1">
<span className="block text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{label}
</span>
<Input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => onChange(clamp(event.currentTarget.value, min, max))}
className="h-7 px-1.5 text-[11px]"
/>
</label>
)
}
function ColorPicker({
value = "#ffffff",
onChange,
opacity = 1,
onOpacityChange,
className,
swatchClassName,
valueClassName,
popoverAlign = "start",
showValue = true,
showOpacity = false,
disabled = false,
...props
}) {
const normalizedValue = normalizeHexColor(value)
const normalizedOpacity = Number.isFinite(Number(opacity)) ? clamp(Number(opacity), 0, 1) : 1
const [format, setFormat] = React.useState("hex")
const [hexDraft, setHexDraft] = React.useState(normalizedValue)
const [canUseEyeDropper, setCanUseEyeDropper] = React.useState(false)
const svPlaneRef = React.useRef(null)
const hueTrackRef = React.useRef(null)
React.useEffect(() => {
setHexDraft(normalizedValue)
}, [normalizedValue])
React.useEffect(() => {
setCanUseEyeDropper(typeof window !== "undefined" && typeof window.EyeDropper === "function")
}, [])
const colorNumbers = React.useMemo(() => roundedColorNumbers(normalizedValue), [normalizedValue])
const hue = colorNumbers.hsv.h
const svColor = `hsl(${hue} 100% 50%)`
const commitHex = React.useCallback((nextHex) => {
const normalizedNextHex = normalizeHexColor(nextHex, normalizedValue)
setHexDraft(normalizedNextHex)
if (normalizedNextHex !== normalizedValue) {
onChange?.(normalizedNextHex)
}
}, [normalizedValue, onChange])
const updateFromSvPointer = React.useCallback((event) => {
const rect = svPlaneRef.current?.getBoundingClientRect()
if (!rect || rect.width <= 0 || rect.height <= 0) {
return
}
const nextS = clamp(((event.clientX - rect.left) / rect.width) * 100, 0, 100)
const nextV = clamp((1 - ((event.clientY - rect.top) / rect.height)) * 100, 0, 100)
commitHex(rgbToHex(hsvToRgb({ h: hue, s: nextS, v: nextV })))
}, [commitHex, hue])
const updateFromHuePointer = React.useCallback((event) => {
const rect = hueTrackRef.current?.getBoundingClientRect()
if (!rect || rect.width <= 0) {
return
}
const nextHue = clamp(((event.clientX - rect.left) / rect.width) * 360, 0, 360)
commitHex(rgbToHex(hsvToRgb({
h: nextHue,
s: colorNumbers.hsv.s,
v: colorNumbers.hsv.v
})))
}, [colorNumbers.hsv.s, colorNumbers.hsv.v, commitHex])
const handleSvPointerDown = (event) => {
svPlaneRef.current?.setPointerCapture(event.pointerId)
updateFromSvPointer(event)
}
const handleHuePointerDown = (event) => {
hueTrackRef.current?.setPointerCapture(event.pointerId)
updateFromHuePointer(event)
}
const handleHexCommit = (nextValue) => {
if (!HEX_COLOR_PATTERN.test(String(nextValue || "").trim())) {
setHexDraft(normalizedValue)
return
}
commitHex(nextValue)
}
const setRgbChannel = (channel, nextValue) => {
commitHex(rgbToHex({
...colorNumbers.rgb,
[channel]: nextValue
}))
}
const setHslChannel = (channel, nextValue) => {
commitHex(rgbToHex(hslToRgb({
...colorNumbers.hsl,
[channel]: nextValue
})))
}
const pickFromScreen = async () => {
if (!canUseEyeDropper) {
return
}
try {
const result = await new window.EyeDropper().open()
if (result?.sRGBHex) {
commitHex(result.sRGBHex)
}
} catch {
// The browser throws when the user cancels the eyedropper; no UI state changes are needed.
}
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
data-slot="color-picker-trigger"
variant="outline"
size="sm"
disabled={disabled}
className={cn("h-7 w-auto max-w-full justify-start gap-1.5 px-1.5 text-[11px] font-medium", className)}
{...props}
>
<span
className={cn(
"size-4 shrink-0 rounded bg-clip-border shadow-[0_0_0_1px_rgba(15,23,42,0.24)] dark:shadow-[0_0_0_1px_rgba(255,255,255,0.24)]",
swatchClassName
)}
style={{
backgroundColor: normalizedValue,
opacity: showOpacity ? normalizedOpacity : undefined
}}
aria-hidden="true"
/>
{showValue ? (
<span className={cn("truncate font-mono uppercase leading-none", valueClassName)}>{normalizedValue}</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent align={popoverAlign} className="w-64 space-y-3 p-3">
<div
ref={svPlaneRef}
role="slider"
tabIndex={0}
aria-label="Saturation and brightness"
aria-valuetext={`${Math.round(colorNumbers.hsv.s)}% saturation, ${Math.round(colorNumbers.hsv.v)}% brightness`}
className="relative h-32 touch-none overflow-hidden rounded-md border shadow-inner outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
style={{
backgroundColor: svColor,
backgroundImage: "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)"
}}
onPointerDown={handleSvPointerDown}
onPointerMove={(event) => {
if (svPlaneRef.current?.hasPointerCapture(event.pointerId)) {
updateFromSvPointer(event)
}
}}
>
<span
className="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
style={{
left: `${colorNumbers.hsv.s}%`,
top: `${100 - colorNumbers.hsv.v}%`
}}
aria-hidden="true"
/>
</div>
<div
ref={hueTrackRef}
role="slider"
tabIndex={0}
aria-label="Hue"
aria-valuemin={0}
aria-valuemax={360}
aria-valuenow={Math.round(hue)}
className="relative h-3 touch-none rounded-full border outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
style={{
background: "linear-gradient(90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)"
}}
onPointerDown={handleHuePointerDown}
onPointerMove={(event) => {
if (hueTrackRef.current?.hasPointerCapture(event.pointerId)) {
updateFromHuePointer(event)
}
}}
>
<span
className="absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
style={{ left: `${(hue / 360) * 100}%`, backgroundColor: svColor }}
aria-hidden="true"
/>
</div>
{showOpacity ? (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
<span>Opacity</span>
<span>{Math.round(normalizedOpacity * 100)}%</span>
</div>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
max={100}
step={1}
value={Math.round(normalizedOpacity * 100)}
onChange={(event) => {
onOpacityChange?.(clamp(event.currentTarget.value, 0, 100) / 100)
}}
className="h-7 px-1.5 text-[11px]"
aria-label="Color opacity"
/>
<span className="text-[10px] font-medium text-muted-foreground">%</span>
</div>
</div>
) : null}
<div className="flex items-center gap-1 rounded-md bg-muted p-1">
{FORMAT_OPTIONS.map((option) => (
<Button
key={option}
type="button"
variant={format === option ? "secondary" : "ghost"}
size="xs"
className="h-6 flex-1 px-2 text-[10px] uppercase"
onClick={() => setFormat(option)}
>
{option}
</Button>
))}
</div>
{format === "hex" ? (
<div className="flex gap-2">
<Input
value={hexDraft}
onChange={(event) => setHexDraft(event.currentTarget.value)}
onBlur={(event) => handleHexCommit(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleHexCommit(event.currentTarget.value)
}
}}
className="h-7 px-1.5 text-[11px] font-mono uppercase"
aria-label="Hex color"
/>
{canUseEyeDropper ? (
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={pickFromScreen}
aria-label="Pick color from screen"
title="Pick color from screen"
>
<Pipette className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</Button>
) : null}
</div>
) : null}
{format === "rgb" ? (
<div className="grid grid-cols-3 gap-2">
<ColorNumberInput label="R" value={colorNumbers.rgb.r} min={0} max={255} onChange={(nextValue) => setRgbChannel("r", nextValue)} />
<ColorNumberInput label="G" value={colorNumbers.rgb.g} min={0} max={255} onChange={(nextValue) => setRgbChannel("g", nextValue)} />
<ColorNumberInput label="B" value={colorNumbers.rgb.b} min={0} max={255} onChange={(nextValue) => setRgbChannel("b", nextValue)} />
</div>
) : null}
{format === "hsl" ? (
<div className="grid grid-cols-3 gap-2">
<ColorNumberInput label="H" value={colorNumbers.hsl.h} min={0} max={360} onChange={(nextValue) => setHslChannel("h", nextValue)} />
<ColorNumberInput label="S" value={colorNumbers.hsl.s} min={0} max={100} onChange={(nextValue) => setHslChannel("s", nextValue)} />
<ColorNumberInput label="L" value={colorNumbers.hsl.l} min={0} max={100} onChange={(nextValue) => setHslChannel("l", nextValue)} />
</div>
) : null}
</PopoverContent>
</Popover>
)
}
export { ColorPicker }
@@ -0,0 +1,97 @@
"use client";
import * as React from "react";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cn } from "@/ui/utils";
function ContextMenu({
...props
}) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
const ContextMenuTrigger = React.forwardRef(function ContextMenuTrigger({
...props
}, ref) {
return <ContextMenuPrimitive.Trigger ref={ref} data-slot="context-menu-trigger" {...props} />;
});
const ContextMenuContent = React.forwardRef(function ContextMenuContent({
className,
...props
}, ref) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
data-slot="context-menu-content"
className={cn(
"cad-glass-popover z-50 min-w-44 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
});
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive",
className
)}
{...props}
/>
);
}
function ContextMenuLabel({
className,
inset,
...props
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
};
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
const DialogTrigger = React.forwardRef(function DialogTrigger({
...props
}, ref) {
return <DialogPrimitive.Trigger ref={ref} data-slot="dialog-trigger" {...props} />;
});
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
const DialogClose = React.forwardRef(function DialogClose({
...props
}, ref) {
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
});
const DialogOverlay = React.forwardRef(function DialogOverlay({
className,
...props
}, ref) {
return (
<DialogPrimitive.Overlay
ref={ref}
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props} />
);
});
const DialogContent = React.forwardRef(function DialogContent({
className,
children,
showCloseButton = true,
...props
}, ref) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
data-slot="dialog-content"
className={cn(
"cad-glass-popover fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-md border p-6 text-foreground shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton ? (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute right-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
>
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
) : null}
</DialogPrimitive.Content>
</DialogPortal>
);
});
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} />
);
}
function DialogFooter({
className,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props} />
);
}
const DialogTitle = React.forwardRef(function DialogTitle({
className,
...props
}, ref) {
return (
<DialogPrimitive.Title
ref={ref}
data-slot="dialog-title"
className={cn("text-lg font-semibold", className)}
{...props} />
);
});
const DialogDescription = React.forwardRef(function DialogDescription({
className,
...props
}, ref) {
return (
<DialogPrimitive.Description
ref={ref}
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
});
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,220 @@
"use client";
import * as React from "react";
import { Check, ChevronRight, Circle } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/ui/utils";
function DropdownMenu({
...props
}) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
const DropdownMenuTrigger = React.forwardRef(function DropdownMenuTrigger({
...props
}, ref) {
return <DropdownMenuPrimitive.Trigger ref={ref} data-slot="dropdown-menu-trigger" {...props} />;
});
const DropdownMenuContent = React.forwardRef(function DropdownMenuContent({
className,
sideOffset = 4,
...props
}, ref) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"cad-glass-popover z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
});
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
showChevron = true,
...props
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden transition-colors data-[inset]:pl-8 focus:bg-accent focus:text-accent-foreground",
className
)}
{...props}
>
{children}
{showChevron ? <ChevronRight className="ml-auto size-4" /> : null}
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"cad-glass-popover z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/ui/utils"
function Input({
className,
type,
...props
}) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props} />
);
}
export { Input }
@@ -0,0 +1,41 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Popover({
...props
}) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
const PopoverTrigger = React.forwardRef(function PopoverTrigger({
...props
}, ref) {
return <PopoverPrimitive.Trigger ref={ref} data-slot="popover-trigger" {...props} />
});
const PopoverContent = React.forwardRef(function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}, ref) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"cad-glass-popover z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 text-popover-foreground shadow-md outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
});
export { Popover, PopoverContent, PopoverTrigger }
@@ -0,0 +1,48 @@
"use client"
import { cn } from "@/ui/utils"
function ScrollArea({
className,
viewportClassName,
scrollbars = "vertical",
type: _type,
scrollHideDelay: _scrollHideDelay,
children,
...props
}) {
const scrollbarOrientations = Array.isArray(scrollbars)
? scrollbars
: [scrollbars].filter(Boolean);
const hasHorizontalScrollbar = scrollbarOrientations.includes("horizontal");
const hasVerticalScrollbar = scrollbarOrientations.includes("vertical");
const overflowX = hasHorizontalScrollbar ? "overflow-x-auto" : "overflow-x-hidden";
const overflowY = hasVerticalScrollbar ? "overflow-y-auto" : "overflow-y-hidden";
return (
<div data-slot="scroll-area" className={cn("relative", className)} {...props}>
<div
data-slot="scroll-area-viewport"
className={cn(
"size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1",
overflowX,
overflowY,
viewportClassName
)}>
<div className={cn("min-w-full", hasHorizontalScrollbar ? "w-max" : "w-full")}>
{children}
</div>
</div>
</div>
);
}
function ScrollBar({
className: _className,
orientation: _orientation = "vertical",
..._props
}) {
return null;
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,185 @@
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Select({
...props
}) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
const SelectTrigger = React.forwardRef(function SelectTrigger({
className,
size = "default",
showIcon = true,
children,
...props
}, ref) {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
{showIcon ? (
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
) : null}
</SelectPrimitive.Trigger>
)
})
function SelectContent({
className,
children,
position = "popper",
...props
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"cad-glass-popover relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
icon = null,
...props
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
{icon ? (
<span className="flex size-3.5 shrink-0 items-center justify-center text-muted-foreground" aria-hidden="true">
{icon}
</span>
) : null}
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,25 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props} />
);
}
export { Separator }
@@ -0,0 +1,146 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Sheet({
...props
}) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
const SheetTrigger = React.forwardRef(function SheetTrigger({
...props
}, ref) {
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
});
const SheetClose = React.forwardRef(function SheetClose({
...props
}, ref) {
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
});
function SheetPortal({
...props
}) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
const SheetOverlay = React.forwardRef(function SheetOverlay({
className,
...props
}, ref) {
return (
<SheetPrimitive.Overlay
ref={ref}
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props} />
);
});
const SheetContent = React.forwardRef(function SheetContent({
className,
overlayClassName,
children,
side = "right",
showCloseButton = true,
...props
}, ref) {
return (
<SheetPortal>
<SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content
ref={ref}
data-slot="sheet-content"
className={cn(
"cad-glass-popover fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}>
{children}
{showCloseButton && (
<SheetPrimitive.Close
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
});
function SheetHeader({
className,
...props
}) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} />
);
}
function SheetFooter({
className,
...props
}) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} />
);
}
function SheetTitle({
className,
...props
}) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props} />
);
}
function SheetDescription({
className,
...props
}) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,690 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react"
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/ui/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
defaultMobileOpen = false,
open: openProp,
onOpenChange: setOpenProp,
mobileOpen: openMobileProp,
onMobileOpenChange: setOpenMobileProp,
className,
style,
children,
...props
}) {
const isMobile = useIsMobile()
const [_openMobile, _setOpenMobile] = React.useState(defaultMobileOpen)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const openMobile = openMobileProp ?? _openMobile
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
}, [setOpenProp, open])
const setOpenMobile = React.useCallback((value) => {
const openState = typeof value === "function" ? value(openMobile) : value
if (setOpenMobileProp) {
setOpenMobileProp(openState)
} else {
_setOpenMobile(openState)
}
}, [setOpenMobileProp, openMobile])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style
}
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="cad-glass-surface w-(--sidebar-width) !transition-none p-0 text-sidebar-foreground data-[state=closed]:!animate-none data-[state=closed]:!duration-0 data-[state=open]:!animate-none data-[state=open]:!duration-0 [&>button]:hidden"
overlayClassName="data-[state=closed]:!animate-none data-[state=closed]:!duration-0 data-[state=open]:!animate-none data-[state=open]:!duration-0"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar">
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) shrink-0 bg-transparent",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)} />
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) shrink-0",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
className
)}
{...props}>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="cad-glass-surface flex h-full w-full flex-col border-sidebar-border group-data-[side=left]:border-r group-data-[side=right]:border-l group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm">
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({
className,
...props
}) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props} />
);
}
function SidebarInset({
className,
...props
}) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-transparent",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props} />
);
}
function SidebarInput({
className,
...props
}) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("cad-glass-control h-8 w-full shadow-none", className)}
{...props} />
);
}
function SidebarHeader({
className,
...props
}) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />
);
}
function SidebarFooter({
className,
...props
}) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />
);
}
function SidebarSeparator({
className,
...props
}) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-[calc(100%-1rem)] bg-sidebar-border", className)}
{...props} />
);
}
function SidebarContent({
className,
...props
}) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-hidden",
className
)}
{...props} />
);
}
function SidebarGroup({
className,
...props
}) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} />
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-100 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props} />
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
);
}
function SidebarGroupContent({
className,
...props
}) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props} />
);
}
function SidebarMenu({
className,
...props
}) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} />
);
}
function SidebarMenuItem({
className,
...props
}) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props} />
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} />
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip} />
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props} />
);
}
function SidebarMenuBadge({
className,
...props
}) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>
);
}
function SidebarMenuSub({
className,
...props
}) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
);
}
function SidebarMenuSubItem({
className,
...props
}) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props} />
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
@@ -0,0 +1,15 @@
import { cn } from "@/ui/utils"
function Skeleton({
className,
...props
}) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props} />
);
}
export { Skeleton }
@@ -0,0 +1,61 @@
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}) {
const values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min],
[defaultValue, min, value]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{values.map((_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative z-10 block size-3.5 shrink-0 rounded-full border border-background bg-primary shadow-sm ring-1 ring-foreground/15 transition-[color,box-shadow] outline-none hover:ring-2 hover:ring-ring/25 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Switch({
className,
thumbClassName,
...props
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input shadow-xs transition-all outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
thumbClassName
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
@@ -0,0 +1,64 @@
import * as React from "react"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function Tabs({
className,
...props
}) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/ui/utils"
function Textarea({
className,
...props
}) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props} />
)
}
export { Textarea }
@@ -0,0 +1,52 @@
import { Toast as ToastPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function ToastProvider({
...props
}) {
return <ToastPrimitive.Provider data-slot="toast-provider" {...props} />
}
function Toast({
className,
...props
}) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(
"cad-glass-popover pointer-events-auto rounded-md border border-border px-4 py-2.5 text-xs font-medium text-popover-foreground shadow-lg shadow-black/10 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-right-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2",
className
)}
{...props} />
)
}
function ToastTitle({
...props
}) {
return <ToastPrimitive.Title data-slot="toast-title" {...props} />
}
function ToastViewport({
className,
...props
}) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
"fixed right-4 top-4 z-50 flex max-h-screen w-[min(calc(100vw-2rem),22rem)] flex-col gap-2 outline-none",
className
)}
{...props} />
)
}
export {
Toast,
ToastProvider,
ToastTitle,
ToastViewport
}
@@ -0,0 +1,62 @@
import * as React from "react"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
variant,
size,
...props
}) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props} />
);
}
export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,43 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { Toggle as TogglePrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props} />
);
}
export { Toggle, toggleVariants }
@@ -0,0 +1,56 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/ui/utils"
function TooltipProvider({
delayDuration = 0,
...props
}) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />);
}
function Tooltip({
...props
}) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
const TooltipTrigger = React.forwardRef(function TooltipTrigger({
...props
}, ref) {
return <TooltipPrimitive.Trigger ref={ref} data-slot="tooltip-trigger" {...props} />;
});
const TooltipContent = React.forwardRef(function TooltipContent({
className,
arrowClassName,
sideOffset = 0,
children,
...props
}, ref) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"cad-glass-popover z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md border border-border bg-popover px-3 py-1.5 text-xs text-balance text-popover-foreground shadow-lg shadow-black/10 fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}>
{children}
<TooltipPrimitive.Arrow
className={cn(
"z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-popover fill-popover",
arrowClassName
)} />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
});
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,24 @@
import {
Contrast,
Eye,
EyeOff,
Layers,
Paintbrush,
Spline,
SquareDashed
} from "lucide-react";
import { CAD_DISPLAY_MODE } from "cadjs/lib/displaySettings";
export const DISPLAY_MODE_OPTIONS = Object.freeze([
Object.freeze({ value: CAD_DISPLAY_MODE.SOLID, label: "Solid", title: "Shaded with CAD edges", Icon: Layers }),
Object.freeze({ value: CAD_DISPLAY_MODE.RENDERED, label: "Rendered", title: "Shaded material appearance without edge overlay", Icon: Paintbrush }),
Object.freeze({ value: CAD_DISPLAY_MODE.TRANSPARENT, label: "X-Ray", title: "Transparent solids with visible CAD edges", Icon: Eye }),
Object.freeze({ value: CAD_DISPLAY_MODE.HIDDEN_EDGES, label: "Hidden", title: "Shaded with hidden edges visible", Icon: EyeOff }),
Object.freeze({ value: CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED, label: "Lines", title: "Visible lines with hidden lines removed", Icon: SquareDashed }),
Object.freeze({ value: CAD_DISPLAY_MODE.UNSHADED, label: "Flat", title: "Unshaded flat color", Icon: Contrast }),
Object.freeze({ value: CAD_DISPLAY_MODE.WIREFRAME, label: "Wire", title: "Full wireframe", Icon: Spline })
]);
export function displayModeOptionForValue(value) {
return DISPLAY_MODE_OPTIONS.find((option) => option.value === value) || DISPLAY_MODE_OPTIONS[0];
}
@@ -0,0 +1,142 @@
"use client";
import { Check } from "lucide-react";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "cadjs/lib/perspective";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "../ui/dropdown-menu";
import {
OrthographicProjectionIcon,
PerspectiveProjectionIcon
} from "./ProjectionModeIcons";
import {
DISPLAY_MODE_OPTIONS,
displayModeOptionForValue
} from "./DisplayModeOptions";
const PROJECTION_MODE_OPTIONS = Object.freeze([
Object.freeze({
value: CAMERA_PROJECTION.ORTHOGRAPHIC,
label: "Orthographic projection",
title: "Switch to orthographic projection",
Icon: OrthographicProjectionIcon
}),
Object.freeze({
value: CAMERA_PROJECTION.PERSPECTIVE,
label: "Perspective projection",
title: "Switch to perspective projection",
Icon: PerspectiveProjectionIcon
})
]);
export function projectionOptionForValue(value) {
const normalizedProjection = normalizeCameraProjection(value);
return PROJECTION_MODE_OPTIONS.find((option) => option.value === normalizedProjection) || PROJECTION_MODE_OPTIONS[0];
}
export function projectionMenuLabel(option) {
return String(option?.label || "").replace(/\s+projection$/i, "") || "Perspective";
}
export function DisplayProjectionControl({
displayMode,
onDisplayModeChange,
projection,
onProjectionChange,
open = false,
onOpenChange,
triggerClassName,
iconClassName = "size-3",
contentAlign = "start",
contentSide = "bottom",
contentSideOffset = 6
}) {
const showDisplayModeSection = typeof onDisplayModeChange === "function";
const showProjectionSection = typeof onProjectionChange === "function";
const selectedOption = displayModeOptionForValue(displayMode);
const selectedProjectionOption = projectionOptionForValue(projection);
const SelectedProjectionIcon = selectedProjectionOption.Icon || PerspectiveProjectionIcon;
const label = `Display and projection: ${selectedOption?.label || "Solid"}, ${projectionMenuLabel(selectedProjectionOption)}`;
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={label}
className={triggerClassName}
onPointerDown={(event) => {
event.stopPropagation();
}}
>
<SelectedProjectionIcon className={iconClassName} strokeWidth={2} aria-hidden="true" />
<span className="sr-only">{label}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={contentAlign} side={contentSide} sideOffset={contentSideOffset} className="w-44">
{showProjectionSection ? (
<>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Projection
</DropdownMenuLabel>
{PROJECTION_MODE_OPTIONS.map((option) => {
const Icon = option.Icon;
const selected = selectedProjectionOption.value === option.value;
return (
<DropdownMenuItem
key={option.value}
className="text-xs"
onSelect={() => {
onProjectionChange?.(option.value);
}}
>
<Icon className="size-3 text-muted-foreground" strokeWidth={2} aria-hidden="true" />
{projectionMenuLabel(option)}
{selected ? (
<Check className="ml-auto size-3.5 text-popover-foreground" strokeWidth={2} aria-hidden="true" />
) : (
<span className="ml-auto size-3.5" aria-hidden="true" />
)}
</DropdownMenuItem>
);
})}
</>
) : null}
{showDisplayModeSection && showProjectionSection ? <DropdownMenuSeparator /> : null}
{showDisplayModeSection ? (
<>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Display
</DropdownMenuLabel>
{DISPLAY_MODE_OPTIONS.map((option) => {
const selected = selectedOption.value === option.value;
return (
<DropdownMenuItem
key={option.value}
className="text-xs"
onSelect={() => {
onDisplayModeChange?.(option.value);
}}
>
{option.label}
{selected ? (
<Check className="ml-auto size-3.5 text-popover-foreground" strokeWidth={2} aria-hidden="true" />
) : (
<span className="ml-auto size-3.5" aria-hidden="true" />
)}
</DropdownMenuItem>
);
})}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,39 @@
export function OrthographicProjectionIcon({ className, strokeWidth = 1.45 }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path
d="M12 3.5 20 8v8l-8 4.5L4 16V8l8-4.5Z"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
/>
<path
d="M4 8l8 4.5L20 8M12 12.5v8"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function PerspectiveProjectionIcon({ className, strokeWidth = 1.45 }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path
d="M12 4.6 19.6 6.1l-2 10.1-5.6 4.2-5.6-4.2-2-10.1L12 4.6Z"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
/>
<path
d="M4.4 6.1 12 9.2l7.6-3.1M12 9.2v11.2"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,440 @@
import { useState } from "react";
const ORIENTATION_FALLBACK = Object.freeze({
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1]
});
const DEFAULT_VIEW_PLANE_PALETTE = Object.freeze({
axis: {
x: {
front: [250, 88, 79],
back: [122, 32, 28]
},
y: {
front: [92, 233, 123],
back: [30, 99, 46]
},
z: {
front: [84, 131, 255],
back: [30, 53, 126]
}
},
center: {
fill: [252, 215, 74],
stroke: [255, 235, 153]
}
});
const DEFAULT_VIEW_PLANE_SIZE = "6.71875rem";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeCssLength(value, fallback = "") {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return `${value}px`;
}
const text = String(value || "").trim();
return text || fallback;
}
function toRgb(value) {
const [r = 0, g = 0, b = 0] = Array.isArray(value) ? value : [0, 0, 0];
return [Math.round(clamp(r, 0, 255)), Math.round(clamp(g, 0, 255)), Math.round(clamp(b, 0, 255))];
}
function mixRgb(from, to, amount) {
const ratio = clamp(amount, 0, 1);
const left = toRgb(from);
const right = toRgb(to);
return [
left[0] + (right[0] - left[0]) * ratio,
left[1] + (right[1] - left[1]) * ratio,
left[2] + (right[2] - left[2]) * ratio
];
}
function rgbToCss(value, alpha = 1) {
const [r, g, b] = toRgb(value);
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
}
function normalizeRgbTriplet(value, fallback) {
if (!Array.isArray(value) || value.length < 3) {
return [...fallback];
}
const r = Number(value[0]);
const g = Number(value[1]);
const b = Number(value[2]);
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) {
return [...fallback];
}
return [clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255)];
}
function resolveViewPlanePalette(viewerTheme) {
const themePalette = viewerTheme?.viewPlanePalette || {};
const themeAxisPalette = themePalette?.axis || {};
const axis = {};
for (const axisId of ["x", "y", "z"]) {
const fallback = DEFAULT_VIEW_PLANE_PALETTE.axis[axisId];
const custom = themeAxisPalette?.[axisId] || {};
axis[axisId] = {
front: normalizeRgbTriplet(custom.front, fallback.front),
back: normalizeRgbTriplet(custom.back, fallback.back)
};
}
return {
axis,
center: {
fill: normalizeRgbTriplet(themePalette?.center?.fill, DEFAULT_VIEW_PLANE_PALETTE.center.fill),
stroke: normalizeRgbTriplet(themePalette?.center?.stroke, DEFAULT_VIEW_PLANE_PALETTE.center.stroke)
}
};
}
function normalizeAxis(axis, fallback) {
if (!Array.isArray(axis) || axis.length !== 3) {
return [...fallback];
}
const x = Number(axis[0]);
const y = Number(axis[1]);
const z = Number(axis[2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return [...fallback];
}
const magnitude = Math.hypot(x, y, z);
if (magnitude < 1e-6) {
return [...fallback];
}
return [x / magnitude, y / magnitude, z / magnitude];
}
function normalizeOrientation(orientation) {
return {
x: normalizeAxis(orientation?.x, ORIENTATION_FALLBACK.x),
y: normalizeAxis(orientation?.y, ORIENTATION_FALLBACK.y),
z: normalizeAxis(orientation?.z, ORIENTATION_FALLBACK.z)
};
}
function projectDirection(orientation, direction) {
const [dx = 0, dy = 0, dz = 0] = Array.isArray(direction) ? direction : [0, 0, 0];
return [
orientation.x[0] * dx + orientation.y[0] * dy + orientation.z[0] * dz,
orientation.x[1] * dx + orientation.y[1] * dy + orientation.z[1] * dz,
orientation.x[2] * dx + orientation.y[2] * dy + orientation.z[2] * dz
];
}
function getAxisId(face) {
const id = String(face?.id || "");
return id.startsWith("x") ? "x" : id.startsWith("y") ? "y" : "z";
}
export default function ViewPlaneControl({
showViewPlane,
previewMode,
isLoading,
meshData,
viewPlaneOffsetRight,
viewPlaneOffsetBottom = 16,
activeViewPlaneFace,
viewPlaneFaces,
viewPlaneOrientation,
viewerTheme,
compact = false,
variant = "3d",
viewPlaneSize,
viewPlaneHeader = null,
activateViewPlaneFace,
activateDefaultViewPlane
}) {
const [hoveredNodeId, setHoveredNodeId] = useState("");
if (!showViewPlane || previewMode || isLoading || !meshData) {
return null;
}
const orientation = normalizeOrientation(viewPlaneOrientation);
const palette = resolveViewPlanePalette(viewerTheme);
const faces = Array.isArray(viewPlaneFaces) ? viewPlaneFaces : [];
const projectedNodes = faces
.map((face) => {
const axisId = getAxisId(face);
const axisPalette = palette.axis[axisId] || palette.axis.z;
const [x, y, z] = projectDirection(orientation, face.direction);
const depth = clamp((z + 1) / 2, 0, 1);
const fillColor = mixRgb(axisPalette.back, axisPalette.front, depth);
const edgeColor = mixRgb([10, 16, 28], axisPalette.front, depth * 0.82 + 0.08);
return {
id: face.id,
title: face.title,
x: 50 + x * 28,
y: 50 - y * 28,
z,
depth,
radius: 4.95 + depth * 1.05,
fill: rgbToCss(fillColor),
edge: rgbToCss(edgeColor),
stem: rgbToCss(fillColor, 0.32 + depth * 0.48)
};
})
.sort((left, right) => left.z - right.z);
const backNodes = projectedNodes.filter((node) => node.z < 0);
const frontNodes = projectedNodes.filter((node) => node.z >= 0);
const is2d = variant === "2d";
const customViewPlaneSize = !compact && !is2d
? normalizeCssLength(viewPlaneSize, DEFAULT_VIEW_PLANE_SIZE)
: "";
const viewPlaneSizeClasses = compact || is2d ? "h-20 w-20" : "";
const viewPlaneSizeStyle = customViewPlaneSize
? { width: customViewPlaneSize, height: customViewPlaneSize }
: undefined;
const viewPlaneSurfaceClasses = is2d
? "cad-glass-surface pointer-events-auto relative rounded-md border border-sidebar-border text-sidebar-foreground shadow-sm transition duration-150"
: "pointer-events-auto relative text-sidebar-foreground transition duration-150";
const viewPlaneLabel = is2d ? "2D view selector" : "Perspective selector";
const normalizedBottomOffset = typeof viewPlaneOffsetBottom === "number"
? `${viewPlaneOffsetBottom}px`
: viewPlaneOffsetBottom;
const centerHovered = hoveredNodeId === "__default__";
const renderNode = (node) => {
const active = activeViewPlaneFace === node.id;
const hovered = hoveredNodeId === node.id;
return (
<g
key={node.id}
role="button"
tabIndex={0}
aria-label={node.title}
aria-pressed={active}
className="group cursor-pointer focus:outline-none"
onPointerDown={(event) => {
event.stopPropagation();
}}
onPointerEnter={() => {
setHoveredNodeId(node.id);
}}
onPointerMove={() => {
setHoveredNodeId(node.id);
}}
onPointerLeave={() => {
setHoveredNodeId((current) => (current === node.id ? "" : current));
}}
onMouseEnter={() => {
setHoveredNodeId(node.id);
}}
onMouseMove={() => {
setHoveredNodeId(node.id);
}}
onMouseLeave={() => {
setHoveredNodeId((current) => (current === node.id ? "" : current));
}}
onFocus={() => {
setHoveredNodeId(node.id);
}}
onBlur={() => {
setHoveredNodeId((current) => (current === node.id ? "" : current));
}}
onClick={(event) => {
event.stopPropagation();
activateViewPlaneFace(node.id);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
event.stopPropagation();
activateViewPlaneFace(node.id);
}}
>
<circle
cx={node.x}
cy={node.y}
r={node.radius + 4.4}
className="transition-opacity duration-150"
opacity={hovered ? 1 : 0}
fill={node.fill}
fillOpacity="0.12"
stroke="var(--sidebar-foreground)"
strokeOpacity="0.72"
strokeWidth="1.1"
/>
<circle
cx={node.x}
cy={node.y}
r={node.radius + (active ? 2.1 : 0)}
fill="none"
stroke={active ? "var(--sidebar-foreground)" : "transparent"}
strokeWidth={active ? 1.6 : 0}
/>
<circle
cx={node.x}
cy={node.y}
r={node.radius}
className="transition-transform duration-150 ease-out"
style={{
transform: hovered ? "scale(1.1)" : "scale(1)",
transformBox: "fill-box",
transformOrigin: "center"
}}
fill={node.fill}
stroke={active ? "var(--sidebar-foreground)" : node.edge}
strokeWidth={active ? 1.35 : 1}
/>
</g>
);
};
return (
<div
className="pointer-events-none absolute z-30 flex flex-col items-end gap-1"
style={{ right: `${viewPlaneOffsetRight}px`, bottom: normalizedBottomOffset }}
>
{viewPlaneHeader ? (
<div
className="pointer-events-auto"
onPointerDown={(event) => {
event.stopPropagation();
}}
>
{viewPlaneHeader}
</div>
) : null}
<div
className={`${viewPlaneSurfaceClasses} ${viewPlaneSizeClasses}`}
style={viewPlaneSizeStyle}
onPointerDown={(event) => {
event.stopPropagation();
}}
>
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" aria-label={viewPlaneLabel}>
<defs>
<radialGradient id="view-sphere-shell" cx="34%" cy="28%" r="74%">
<stop offset="0%" stopColor="var(--sidebar)" />
<stop offset="100%" stopColor="var(--sidebar)" />
</radialGradient>
</defs>
{is2d ? (
<>
<rect x="15" y="15" width="70" height="70" rx="8" fill="url(#view-sphere-shell)" stroke="var(--sidebar-border)" strokeWidth="0.75" />
<line x1="22" y1="50" x2="78" y2="50" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 18%, transparent)" strokeWidth="1" strokeLinecap="round" />
<line x1="50" y1="22" x2="50" y2="78" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 18%, transparent)" strokeWidth="1" strokeLinecap="round" />
</>
) : (
<>
<circle cx="50" cy="50" r="44" fill="url(#view-sphere-shell)" stroke="var(--sidebar-border)" strokeWidth="0.75" />
<ellipse cx="50" cy="50" rx="30" ry="11.8" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 12%, transparent)" strokeWidth="0.8" />
<ellipse cx="50" cy="50" rx="14" ry="30" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 12%, transparent)" strokeWidth="0.8" />
</>
)}
{backNodes.map((node) => (
<line
key={`${node.id}-stem`}
x1="50"
y1="50"
x2={node.x}
y2={node.y}
stroke={node.stem}
strokeWidth={1.6 + node.depth * 0.8}
strokeLinecap="round"
pointerEvents="none"
/>
))}
{backNodes.map((node) => renderNode(node))}
<g
role="button"
tabIndex={0}
aria-label={is2d ? "Fit 2D view" : "Reset to default isometric view"}
className="group cursor-pointer focus:outline-none"
onPointerDown={(event) => {
event.stopPropagation();
}}
onPointerEnter={() => {
setHoveredNodeId("__default__");
}}
onPointerMove={() => {
setHoveredNodeId("__default__");
}}
onPointerLeave={() => {
setHoveredNodeId((current) => (current === "__default__" ? "" : current));
}}
onMouseEnter={() => {
setHoveredNodeId("__default__");
}}
onMouseMove={() => {
setHoveredNodeId("__default__");
}}
onMouseLeave={() => {
setHoveredNodeId((current) => (current === "__default__" ? "" : current));
}}
onFocus={() => {
setHoveredNodeId("__default__");
}}
onBlur={() => {
setHoveredNodeId((current) => (current === "__default__" ? "" : current));
}}
onClick={(event) => {
event.stopPropagation();
activateDefaultViewPlane?.();
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
event.stopPropagation();
activateDefaultViewPlane?.();
}}
>
<circle cx="50" cy="50" r="10.2" fill="transparent" stroke="none" />
<circle
cx="50"
cy="50"
r="11.4"
className="transition-opacity duration-150"
opacity={centerHovered ? 1 : 0}
fill={rgbToCss(palette.center.fill, 0.16)}
stroke="var(--sidebar-foreground)"
strokeOpacity="0.72"
strokeWidth="1.1"
/>
<circle
cx="50"
cy="50"
r="7.3"
className="transition-transform duration-150 ease-out"
style={{
transform: centerHovered ? "scale(1.1)" : "scale(1)",
transformBox: "fill-box",
transformOrigin: "center"
}}
fill={rgbToCss(palette.center.fill, 0.95)}
stroke={rgbToCss(palette.center.stroke, 0.72)}
strokeWidth="1.05"
/>
</g>
{frontNodes.map((node) => (
<line
key={`${node.id}-stem-front`}
x1="50"
y1="50"
x2={node.x}
y2={node.y}
stroke={node.stem}
strokeWidth={1.6 + node.depth * 0.8}
strokeLinecap="round"
pointerEvents="none"
/>
))}
{frontNodes.map((node) => renderNode(node))}
</svg>
</div>
</div>
);
}
@@ -0,0 +1,263 @@
import { useEffect } from "react";
import { DRAWING_TOOL } from "../../../workbench/constants";
export function useViewerDrawingOverlay({
drawingCanvasRef,
drawingDraftRef,
drawingStrokesRef,
drawingChangeRef,
drawingIdRef,
drawingEnabled,
drawingTool,
meshData,
previewMode,
viewerReadyTick,
renderDrawingOverlay,
redrawDrawingCanvas,
buildDrawingPoint,
distanceToStrokeInPixels,
strokeLengthInPixels,
drawingToolNeedsTwoPoints,
buildFillStrokeAtPoint,
buildSurfaceLineAnchor,
updateSurfaceLineAnchor,
drawingEraseThresholdPx,
drawingMinPointDistancePx,
drawingMinStrokeLengthPx
}) {
useEffect(() => {
const canvas = drawingCanvasRef.current;
if (!canvas) {
return undefined;
}
if (!drawingEnabled || previewMode || !meshData) {
drawingDraftRef.current = null;
renderDrawingOverlay();
return undefined;
}
const interaction = {
pointerId: null,
erasing: false
};
const commitStrokes = (nextStrokes) => {
drawingStrokesRef.current = nextStrokes;
drawingChangeRef.current?.(nextStrokes);
redrawDrawingCanvas(canvas, nextStrokes, drawingDraftRef.current);
};
const commitSingleStroke = (stroke) => {
if (!stroke) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
return;
}
drawingIdRef.current += 1;
commitStrokes([
...drawingStrokesRef.current,
{
id: `stroke-${drawingIdRef.current}`,
...stroke
}
]);
};
const eraseStrokeAt = (point) => {
const width = canvas.width || 1;
const height = canvas.height || 1;
let bestIndex = -1;
let bestDistance = Infinity;
for (let index = 0; index < drawingStrokesRef.current.length; index += 1) {
const distance = distanceToStrokeInPixels(point, drawingStrokesRef.current[index], width, height);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
}
if (bestIndex === -1 || bestDistance > drawingEraseThresholdPx) {
return;
}
const nextStrokes = drawingStrokesRef.current.filter((_, index) => index !== bestIndex);
commitStrokes(nextStrokes);
};
const beginDraft = (point) => {
drawingDraftRef.current = {
id: "__draft__",
tool: drawingTool,
points: [point]
};
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
};
const updateDraft = (point) => {
const draft = drawingDraftRef.current;
if (!draft) {
return;
}
if (drawingTool === DRAWING_TOOL.FREEHAND) {
const lastPoint = draft.points[draft.points.length - 1];
const dx = (point.x - lastPoint.x) * (canvas.width || 1);
const dy = (point.y - lastPoint.y) * (canvas.height || 1);
if (Math.hypot(dx, dy) >= drawingMinPointDistancePx) {
draft.points = [...draft.points, point];
}
} else {
draft.points = [draft.points[0], point];
}
redrawDrawingCanvas(canvas, drawingStrokesRef.current, draft);
};
const endDraft = () => {
const draft = drawingDraftRef.current;
if (!draft) {
return;
}
drawingDraftRef.current = null;
const strokeLength = strokeLengthInPixels(draft, canvas.width || 1, canvas.height || 1);
if (drawingToolNeedsTwoPoints(draft.tool) && (draft.points.length < 2 || strokeLength < drawingMinStrokeLengthPx)) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
if (strokeLength < drawingMinStrokeLengthPx && draft.points.length > 1) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
commitSingleStroke({
tool: draft.tool,
points: draft.points.map((point) => ({ x: point.x, y: point.y })),
surfaceLine: draft.surfaceLine || null
});
};
const handlePointerDown = (event) => {
if (event.button !== 0) {
return;
}
interaction.erasing = drawingTool === DRAWING_TOOL.ERASE;
const point = buildDrawingPoint(event, canvas);
if (interaction.erasing) {
event.preventDefault();
event.stopPropagation();
interaction.pointerId = event.pointerId;
canvas.setPointerCapture?.(event.pointerId);
eraseStrokeAt(point);
return;
}
if (drawingTool === DRAWING_TOOL.FILL) {
event.preventDefault();
event.stopPropagation();
commitSingleStroke(buildFillStrokeAtPoint(point, drawingStrokesRef.current, canvas));
return;
}
if (drawingTool === DRAWING_TOOL.SURFACE_LINE) {
const anchor = buildSurfaceLineAnchor?.(event, canvas);
if (!anchor) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
event.preventDefault();
event.stopPropagation();
interaction.pointerId = event.pointerId;
canvas.setPointerCapture?.(event.pointerId);
drawingDraftRef.current = {
id: "__draft__",
tool: drawingTool,
points: [anchor.screenPoint, anchor.screenPoint],
surfaceLine: anchor.surfaceLine
};
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
return;
}
event.preventDefault();
event.stopPropagation();
interaction.pointerId = event.pointerId;
canvas.setPointerCapture?.(event.pointerId);
beginDraft(point);
};
const handlePointerMove = (event) => {
if (interaction.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
const point = buildDrawingPoint(event, canvas);
if (interaction.erasing) {
eraseStrokeAt(point);
return;
}
if (drawingTool === DRAWING_TOOL.SURFACE_LINE) {
const draft = drawingDraftRef.current;
if (!draft?.surfaceLine?.referenceId) {
return;
}
const nextAnchor = updateSurfaceLineAnchor?.(event, canvas, draft.surfaceLine);
if (!nextAnchor) {
return;
}
draft.points = [draft.points[0], nextAnchor.screenPoint];
draft.surfaceLine = nextAnchor.surfaceLine;
redrawDrawingCanvas(canvas, drawingStrokesRef.current, draft);
return;
}
updateDraft(point);
};
const handlePointerEnd = (event) => {
if (interaction.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
canvas.releasePointerCapture?.(event.pointerId);
interaction.pointerId = null;
if (interaction.erasing) {
interaction.erasing = false;
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
endDraft();
};
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerEnd);
canvas.addEventListener("pointercancel", handlePointerEnd);
return () => {
interaction.pointerId = null;
interaction.erasing = false;
drawingDraftRef.current = null;
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerEnd);
canvas.removeEventListener("pointercancel", handlePointerEnd);
};
}, [
buildDrawingPoint,
buildFillStrokeAtPoint,
buildSurfaceLineAnchor,
distanceToStrokeInPixels,
drawingCanvasRef,
drawingChangeRef,
drawingDraftRef,
drawingEnabled,
drawingEraseThresholdPx,
drawingIdRef,
drawingMinPointDistancePx,
drawingMinStrokeLengthPx,
drawingStrokesRef,
drawingTool,
drawingToolNeedsTwoPoints,
meshData,
previewMode,
redrawDrawingCanvas,
renderDrawingOverlay,
strokeLengthInPixels,
updateSurfaceLineAnchor,
viewerReadyTick
]);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";
import { screenLimitedPickThreshold, worldUnitsPerPixelAtDistance } from "cadjs/lib/viewer/pickingThresholds.js";
import {
createViewerContextMenuGestureState,
VIEWER_CONTEXT_MENU_SUPPRESSION_MS
} from "./viewerContextMenuGesture.js";
test("worldUnitsPerPixelAtDistance converts perspective depth to screen scale", () => {
const camera = {
isPerspectiveCamera: true,
fov: 60
};
const unitsPerPixel = worldUnitsPerPixelAtDistance(camera, 600, 300);
assert.ok(Number.isFinite(unitsPerPixel));
assert.ok(Math.abs(unitsPerPixel - 0.5773502691896257) < 1e-9);
});
test("screenLimitedPickThreshold preserves the base threshold until zoom would make it too wide on screen", () => {
const camera = {
isPerspectiveCamera: true,
fov: 60
};
const farThreshold = screenLimitedPickThreshold({
baseThreshold: 1.5,
thresholdScale: 1,
maxScreenDistancePx: 10,
camera,
viewportHeightPx: 600,
distance: 300
});
const nearThreshold = screenLimitedPickThreshold({
baseThreshold: 1.5,
thresholdScale: 1,
maxScreenDistancePx: 10,
camera,
viewportHeightPx: 600,
distance: 30
});
assert.equal(farThreshold, 1.5);
assert.ok(nearThreshold < farThreshold);
assert.ok(Math.abs(nearThreshold - 0.5773502691896257) < 1e-9);
});
test("screenLimitedPickThreshold falls back to the scaled base threshold when screen scaling is unavailable", () => {
const threshold = screenLimitedPickThreshold({
baseThreshold: 0.9,
thresholdScale: 0.5,
maxScreenDistancePx: 5,
camera: null,
viewportHeightPx: 600,
distance: 30
});
assert.equal(threshold, 0.45);
});
test("viewer context menu gesture suppression blocks one menu event", () => {
let time = 1000;
const gesture = createViewerContextMenuGestureState({
now: () => time
});
gesture.suppressNextContextMenu();
assert.equal(gesture.isSuppressed(), true);
assert.equal(gesture.consumeSuppression(), true);
assert.equal(gesture.isSuppressed(), false);
assert.equal(gesture.consumeSuppression(), false);
});
test("viewer context menu gesture suppression expires", () => {
let time = 2000;
const gesture = createViewerContextMenuGestureState({
now: () => time
});
gesture.suppressNextContextMenu();
assert.equal(gesture.isSuppressed(), true);
time += VIEWER_CONTEXT_MENU_SUPPRESSION_MS + 1;
assert.equal(gesture.isSuppressed(), false);
assert.equal(gesture.consumeSuppression(), false);
});
@@ -0,0 +1,695 @@
import { useEffect } from "react";
import { isEditableTarget } from "../../../ui/dom";
import {
isWebGlContextCreationError,
runtimeErrorMessage
} from "cadjs/lib/viewer/webglSupport";
import {
createCadWebGlRenderer
} from "cadjs/common/webglRenderer";
import {
resolveInteractionPixelRatioCap
} from "cadjs/lib/viewer/renderQuality";
import { updateOrbitControls } from "../orbitControls.js";
function createWebGlRenderer(THREE) {
return createCadWebGlRenderer(THREE, {
allowFallback: true,
isRecoverableError: isWebGlContextCreationError
});
}
export function useViewerRuntime({
mountRef,
runtimeRef,
previewModeRef,
setError,
setViewerReadyTick,
viewerTheme,
syncDrawingCanvasSize,
renderDrawingOverlay,
emitPerspectiveChange,
setActiveViewPlaneFace,
activeViewPlaneFaceRef,
stepCameraTransition,
stepKeyboardOrbit,
getActiveViewPlaneFaceId,
cancelCameraTransition,
clearKeyboardOrbitState,
isTrackpadLikeWheelEvent,
getKeyboardOrbitCommand,
getKeyboardOrbitAxes,
applyOrbitDelta,
getViewerThemeValue,
getPixelRatioCap,
applySceneBackground,
applyCameraFrameInsets,
frameInsetsRef,
applyInitialPerspective,
updateGridHelper,
clearSceneGroup,
disposeSceneObject,
disposeTexture,
syncViewPlaneOrientation,
BASE_VIEWER_THEME,
DEFAULT_LIGHTING,
DEFAULT_DAMPING_FACTOR,
DEFAULT_ZOOM_SPEED,
COARSE_POINTER_ZOOM_SPEED,
INTERACTION_PIXEL_RATIO_CAP,
IDLE_PIXEL_RATIO_CAP,
INTERACTION_IDLE_DELAY_MS,
TRACKPAD_PINCH_ZOOM_SPEED,
COARSE_POINTER_PINCH_ZOOM_SPEED,
ACCELERATED_WHEEL_ZOOM_SPEED,
KEYBOARD_ORBIT_NUDGE_RAD,
defaultGridRadius,
sceneScaleMode,
floorMode,
onManualCameraInteraction,
onViewportResize,
onContextLost,
onContextRestored,
onInitializationError,
preserveInteractionPixelRatio = false,
runtimeResetToken = 0
}) {
useEffect(() => {
if (runtimeRef.current) {
runtimeRef.current.preserveInteractionPixelRatio = preserveInteractionPixelRatio === true;
}
}, [preserveInteractionPixelRatio, runtimeRef, runtimeResetToken]);
// Runtime setup/teardown should run once per WebGL runtime epoch.
useEffect(() => {
let cancelled = false;
let cleanup = () => {};
async function initializeViewer() {
const [
THREE,
{ OrbitControls },
{ Line2 },
{ LineGeometry },
{ LineSegments2 },
{ LineSegmentsGeometry },
{ LineMaterial }
] = await Promise.all([
import("three"),
import("three/examples/jsm/controls/OrbitControls.js"),
import("three/examples/jsm/lines/Line2.js"),
import("three/examples/jsm/lines/LineGeometry.js"),
import("three/examples/jsm/lines/LineSegments2.js"),
import("three/examples/jsm/lines/LineSegmentsGeometry.js"),
import("three/examples/jsm/lines/LineMaterial.js")
]);
if (cancelled || !mountRef.current) {
return;
}
const container = mountRef.current;
const coarsePointerQuery = typeof window.matchMedia === "function"
? window.matchMedia("(pointer: coarse)")
: null;
const prefersCoarsePointer = coarsePointerQuery?.matches ?? false;
const getOrbitControlsPixelRatioBucket = () => Math.max((window.devicePixelRatio || 1) | 0, 1);
const initialWheelPixelRatioBucket = getOrbitControlsPixelRatioBucket();
const getDefaultZoomSpeed = () => (prefersCoarsePointer ? COARSE_POINTER_ZOOM_SPEED : DEFAULT_ZOOM_SPEED);
const getPinchZoomSpeed = () => (prefersCoarsePointer ? COARSE_POINTER_PINCH_ZOOM_SPEED : TRACKPAD_PINCH_ZOOM_SPEED);
const getWheelZoomSpeed = (baseZoomSpeed) => {
// OrbitControls divides wheel deltas by a floored devicePixelRatio internally.
// Browser zoom changes that bucket, so scale our speed back to the initial bucket.
return baseZoomSpeed * (getOrbitControlsPixelRatioBucket() / initialWheelPixelRatioBucket);
};
const width = container.clientWidth || 800;
const height = container.clientHeight || 640;
const scene = new THREE.Scene();
const syncCameraViewport = (targetCamera, nextWidth = width, nextHeight = height) => {
if (!targetCamera) {
return;
}
const aspect = Math.max(nextWidth, 1) / Math.max(nextHeight, 1);
if (targetCamera.isPerspectiveCamera) {
targetCamera.aspect = aspect;
} else if (targetCamera.isOrthographicCamera) {
const halfHeight = Math.max(Number(targetCamera.userData?.cadHalfHeight) || 120, 1e-3);
targetCamera.left = -halfHeight * aspect;
targetCamera.right = halfHeight * aspect;
targetCamera.top = halfHeight;
targetCamera.bottom = -halfHeight;
}
targetCamera.updateProjectionMatrix?.();
};
const perspectiveCamera = new THREE.PerspectiveCamera(48, width / height, 0.1, 50000);
const orthographicCamera = new THREE.OrthographicCamera(-120, 120, 120, -120, 0.1, 50000);
orthographicCamera.userData.cadHalfHeight = 120;
const camera = perspectiveCamera;
camera.up.set(0, 0, 1);
camera.position.set(180, -180, 120);
orthographicCamera.up.copy(camera.up);
orthographicCamera.position.copy(camera.position);
syncCameraViewport(perspectiveCamera, width, height);
syncCameraViewport(orthographicCamera, width, height);
const renderer = createWebGlRenderer(THREE);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = getViewerThemeValue(viewerTheme, "toneMappingExposure", DEFAULT_LIGHTING.toneMappingExposure);
renderer.localClippingEnabled = true;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(getPixelRatioCap(IDLE_PIXEL_RATIO_CAP));
renderer.setSize(width, height);
container.innerHTML = "";
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
controls.rotateSpeed = 1;
controls.panSpeed = 1.35;
controls.zoomSpeed = getDefaultZoomSpeed();
if ("zoomToCursor" in controls) {
controls.zoomToCursor = true;
}
const hemisphereLight = new THREE.HemisphereLight(
getViewerThemeValue(viewerTheme, "hemisphereSky", DEFAULT_LIGHTING.hemisphereSky),
getViewerThemeValue(viewerTheme, "hemisphereGround", DEFAULT_LIGHTING.hemisphereGround),
getViewerThemeValue(viewerTheme, "hemisphereIntensity", DEFAULT_LIGHTING.hemisphereIntensity)
);
scene.add(hemisphereLight);
const ambientLight = new THREE.AmbientLight("#ffffff", 0);
scene.add(ambientLight);
const keyLight = new THREE.DirectionalLight(
getViewerThemeValue(viewerTheme, "keyLightColor", DEFAULT_LIGHTING.keyLightColor),
getViewerThemeValue(viewerTheme, "keyLightIntensity", DEFAULT_LIGHTING.keyLightIntensity)
);
keyLight.position.set(240, -150, 340);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.00025;
keyLight.shadow.normalBias = 0.024;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(
getViewerThemeValue(viewerTheme, "fillLightColor", DEFAULT_LIGHTING.fillLightColor),
getViewerThemeValue(viewerTheme, "fillLightIntensity", DEFAULT_LIGHTING.fillLightIntensity)
);
fillLight.position.set(120, 80, 210);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(
getViewerThemeValue(viewerTheme, "rimLightColor", DEFAULT_LIGHTING.rimLightColor),
getViewerThemeValue(viewerTheme, "rimLightIntensity", DEFAULT_LIGHTING.rimLightIntensity)
);
rimLight.position.set(-260, 240, 180);
scene.add(rimLight);
const spotLight = new THREE.SpotLight("#ffffff", 0, 0, Math.PI / 6);
spotLight.position.set(160, -120, 140);
spotLight.visible = false;
spotLight.castShadow = false;
spotLight.shadow.mapSize.set(1024, 1024);
spotLight.shadow.bias = -0.00025;
spotLight.shadow.normalBias = 0.01;
scene.add(spotLight);
scene.add(spotLight.target);
const pointLight = new THREE.PointLight("#ffffff", 0, 0);
pointLight.position.set(-120, 80, 140);
pointLight.visible = false;
pointLight.castShadow = false;
scene.add(pointLight);
const axesHelper = null;
const stageGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const edgesGroup = new THREE.Group();
const facePickGroup = new THREE.Group();
const edgePickGroup = new THREE.Group();
const vertexPickGroup = new THREE.Group();
scene.add(stageGroup);
scene.add(modelGroup);
scene.add(edgesGroup);
scene.add(facePickGroup);
scene.add(edgePickGroup);
scene.add(vertexPickGroup);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const interactionState = {
active: false,
pixelRatioCap: IDLE_PIXEL_RATIO_CAP,
pixelRatio: getPixelRatioCap(IDLE_PIXEL_RATIO_CAP),
renderQueued: false,
renderQueuedAt: 0,
renderFallbackTimerId: 0,
restoreTimerId: 0
};
const keyboardOrbitState = {
pressedKeys: new Set(),
directionCounts: {
left: 0,
right: 0,
up: 0,
down: 0
},
lastFrameTime: 0
};
const screenSpaceLineMaterials = new Set();
const getScreenSpaceLineMaterialCount = () => (
screenSpaceLineMaterials.size +
Number(runtimeRef.current?.cadScene?.runtime?.screenSpaceLineMaterials?.size || 0)
);
const syncScreenSpaceLineMaterials = () => {
const nextWidth = container.clientWidth || width || 1;
const nextHeight = container.clientHeight || height || 1;
for (const material of screenSpaceLineMaterials) {
material?.resolution?.set?.(nextWidth, nextHeight);
}
runtimeRef.current?.cadScene?.runtime?.syncScreenSpaceLineMaterials?.(nextWidth, nextHeight);
};
const registerScreenSpaceLineMaterial = (material) => {
if (!material?.resolution?.set) {
return;
}
screenSpaceLineMaterials.add(material);
material.resolution.set(container.clientWidth || width || 1, container.clientHeight || height || 1);
};
const unregisterScreenSpaceLineMaterial = (material) => {
if (!material) {
return;
}
screenSpaceLineMaterials.delete(material);
};
const handleContextLost = (event) => {
event.preventDefault();
clearKeyboardOrbitState(keyboardOrbitState);
setError("WebGL context was lost. Restoring CAD Viewer...");
onContextLost?.();
};
const handleContextRestored = () => {
setError("");
onContextRestored?.();
};
const applyRenderQuality = (pixelRatioCap) => {
const nextPixelRatio = getPixelRatioCap(pixelRatioCap);
if (
Math.abs(interactionState.pixelRatioCap - pixelRatioCap) < 1e-4 &&
Math.abs((interactionState.pixelRatio || 0) - nextPixelRatio) < 1e-4
) {
return;
}
interactionState.pixelRatioCap = pixelRatioCap;
interactionState.pixelRatio = nextPixelRatio;
renderer.setPixelRatio(nextPixelRatio);
renderer.setSize(container.clientWidth || width, container.clientHeight || height, false);
syncScreenSpaceLineMaterials();
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
};
let rafId = 0;
const requestRender = () => {
if (interactionState.renderQueued) {
const now = typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
if (interactionState.renderQueuedAt && now - interactionState.renderQueuedAt < 120) {
return;
}
window.cancelAnimationFrame(rafId);
interactionState.renderQueued = false;
interactionState.renderQueuedAt = 0;
}
interactionState.renderQueued = true;
interactionState.renderQueuedAt = typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
rafId = window.requestAnimationFrame(renderFrame);
if (interactionState.renderFallbackTimerId) {
window.clearTimeout(interactionState.renderFallbackTimerId);
}
interactionState.renderFallbackTimerId = window.setTimeout(() => {
if (!interactionState.renderQueued) {
return;
}
window.cancelAnimationFrame(rafId);
renderFrame(
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now()
);
}, 120);
if (runtimeRef.current) {
runtimeRef.current.rafId = rafId;
}
};
function renderFrame(timestamp) {
interactionState.renderQueued = false;
interactionState.renderQueuedAt = 0;
if (interactionState.renderFallbackTimerId) {
window.clearTimeout(interactionState.renderFallbackTimerId);
interactionState.renderFallbackTimerId = 0;
}
const cameraTransitionActive = stepCameraTransition(runtimeRef.current, timestamp);
const keyboardOrbitMoved = stepKeyboardOrbit(runtimeRef.current, timestamp);
const needsMoreFrames = updateOrbitControls(controls, timestamp, runtimeRef.current);
if (cameraTransitionActive || keyboardOrbitMoved) {
emitPerspectiveChange(runtimeRef.current);
}
renderer.render(scene, runtimeRef.current?.camera || camera);
const previewOrbitActive = !!runtimeRef.current?.previewOrbitEnabled;
if (!previewOrbitActive) {
const nextActiveFace = getActiveViewPlaneFaceId(runtimeRef.current);
if (nextActiveFace !== activeViewPlaneFaceRef.current) {
activeViewPlaneFaceRef.current = nextActiveFace;
setActiveViewPlaneFace(nextActiveFace);
}
syncViewPlaneOrientation(runtimeRef.current);
}
if (
cameraTransitionActive ||
keyboardOrbitMoved ||
needsMoreFrames ||
interactionState.active ||
previewOrbitActive
) {
requestRender();
}
}
const beginInteraction = () => {
if (interactionState.restoreTimerId) {
window.clearTimeout(interactionState.restoreTimerId);
interactionState.restoreTimerId = 0;
}
interactionState.active = true;
applyRenderQuality(resolveInteractionPixelRatioCap({
idlePixelRatioCap: IDLE_PIXEL_RATIO_CAP,
interactionPixelRatioCap: INTERACTION_PIXEL_RATIO_CAP,
preservePixelRatio: runtimeRef.current?.preserveInteractionPixelRatio === true,
screenSpaceLineMaterialCount: getScreenSpaceLineMaterialCount()
}));
requestRender();
};
const scheduleIdleQuality = () => {
if (interactionState.restoreTimerId) {
window.clearTimeout(interactionState.restoreTimerId);
}
interactionState.restoreTimerId = window.setTimeout(() => {
interactionState.restoreTimerId = 0;
interactionState.active = false;
controls.enableDamping = true;
controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
controls.zoomSpeed = getDefaultZoomSpeed();
applyRenderQuality(IDLE_PIXEL_RATIO_CAP);
requestRender();
}, INTERACTION_IDLE_DELAY_MS);
};
const onResize = () => {
const w = container.clientWidth || 800;
const h = container.clientHeight || 640;
applyRenderQuality(interactionState.pixelRatioCap);
renderer.setSize(w, h);
syncCameraViewport(perspectiveCamera, w, h);
syncCameraViewport(orthographicCamera, w, h);
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current, { updateProjection: false });
syncScreenSpaceLineMaterials();
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
runtimeRef.current?.onViewportResize?.();
requestRender();
};
window.addEventListener("resize", onResize);
const resizeObserver = typeof ResizeObserver === "function"
? new ResizeObserver(() => {
onResize();
})
: null;
resizeObserver?.observe(container);
let controlsStartDistance = null;
const readControlsDistance = () => {
const activeRuntime = runtimeRef.current;
if (!activeRuntime?.camera || !activeRuntime?.controls?.target) {
return null;
}
return activeRuntime.camera.position.distanceTo(activeRuntime.controls.target);
};
const handleControlsStart = () => {
controlsStartDistance = readControlsDistance();
cancelCameraTransition(runtimeRef.current);
beginInteraction();
};
const handleControlsChange = () => {
emitPerspectiveChange(runtimeRef.current);
requestRender();
};
const handleControlsEnd = () => {
const controlsEndDistance = readControlsDistance();
if (Number.isFinite(controlsStartDistance) && Number.isFinite(controlsEndDistance)) {
const threshold = Math.max(Math.abs(controlsStartDistance) * 0.002, 1e-4);
if (Math.abs(controlsEndDistance - controlsStartDistance) > threshold) {
runtimeRef.current?.onManualCameraInteraction?.("zoom");
}
}
controlsStartDistance = null;
scheduleIdleQuality();
};
const handleWheel = (event) => {
runtimeRef.current?.onManualCameraInteraction?.("wheel");
cancelCameraTransition(runtimeRef.current);
controls.enableDamping = false;
controls.zoomSpeed = getWheelZoomSpeed(isTrackpadLikeWheelEvent(event)
? getPinchZoomSpeed()
: ACCELERATED_WHEEL_ZOOM_SPEED);
beginInteraction();
};
const wheelListenerOptions = { passive: true, capture: true };
controls.addEventListener("start", handleControlsStart);
controls.addEventListener("change", handleControlsChange);
controls.addEventListener("end", handleControlsEnd);
renderer.domElement.addEventListener("wheel", handleWheel, wheelListenerOptions);
renderer.domElement.addEventListener("webglcontextlost", handleContextLost, false);
renderer.domElement.addEventListener("webglcontextrestored", handleContextRestored, false);
const handleKeyDown = (event) => {
if (
previewModeRef.current ||
event.defaultPrevented ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
isEditableTarget(event.target)
) {
return;
}
const command = getKeyboardOrbitCommand(event);
if (!command) {
return;
}
if (keyboardOrbitState.pressedKeys.has(command.keyId)) {
event.preventDefault();
return;
}
keyboardOrbitState.pressedKeys.add(command.keyId);
keyboardOrbitState.directionCounts[command.direction] += 1;
keyboardOrbitState.lastFrameTime = 0;
cancelCameraTransition(runtimeRef.current);
beginInteraction();
applyOrbitDelta(
runtimeRef.current,
(command.direction === "right" ? 1 : command.direction === "left" ? -1 : 0) * KEYBOARD_ORBIT_NUDGE_RAD,
(command.direction === "down" ? 1 : command.direction === "up" ? -1 : 0) * KEYBOARD_ORBIT_NUDGE_RAD
);
emitPerspectiveChange(runtimeRef.current);
requestRender();
event.preventDefault();
};
const handleKeyUp = (event) => {
const command = getKeyboardOrbitCommand(event);
if (!command) {
return;
}
if (!keyboardOrbitState.pressedKeys.delete(command.keyId)) {
return;
}
keyboardOrbitState.directionCounts[command.direction] = Math.max(
0,
keyboardOrbitState.directionCounts[command.direction] - 1
);
const axes = getKeyboardOrbitAxes(keyboardOrbitState);
if (!axes.azimuth && !axes.polar) {
keyboardOrbitState.lastFrameTime = 0;
scheduleIdleQuality();
}
event.preventDefault();
};
const clearKeyboardOrbit = () => {
if (!keyboardOrbitState.pressedKeys.size) {
return;
}
clearKeyboardOrbitState(keyboardOrbitState);
scheduleIdleQuality();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") {
clearKeyboardOrbit();
}
};
runtimeRef.current = {
THREE,
scene,
camera,
perspectiveCamera,
orthographicCamera,
projection: "perspective",
syncCameraViewport,
renderer,
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
controls,
stageGroup,
modelGroup,
edgesGroup,
facePickGroup,
edgePickGroup,
vertexPickGroup,
facePickMesh: null,
edgePickLines: null,
vertexPickPoints: null,
edgePickObjects: [],
displayRecords: [],
modelBounds: null,
modelRadius: 1,
activeModelKey: "",
sceneScaleMode,
raycaster,
pointer,
hemisphereLight,
ambientLight,
keyLight,
fillLight,
rimLight,
spotLight,
pointLight,
axesHelper,
sceneBackgroundTexture: null,
environmentTexture: null,
environmentTextureUrl: "",
gridConfig: null,
gridHelper: null,
floorMode,
hasVisibleModel: false,
edgePickThreshold: 1.5,
vertexPickThreshold: 0.9,
cameraTransition: null,
previewOrbitEnabled: false,
orbitControlsLastTimestamp: 0,
preserveInteractionPixelRatio: preserveInteractionPixelRatio === true,
interactionState,
keyboardOrbitState,
onResize,
resizeObserver,
rafId,
requestRender,
beginInteraction,
scheduleIdleQuality,
applyCameraFrameInsets,
frameInsetsRef,
onManualCameraInteraction,
onViewportResize,
registerScreenSpaceLineMaterial,
unregisterScreenSpaceLineMaterial
};
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
applySceneBackground(runtimeRef.current, viewerTheme);
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current);
applyInitialPerspective?.(runtimeRef.current);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", clearKeyboardOrbit);
document.addEventListener("visibilitychange", handleVisibilityChange);
requestRender();
updateGridHelper(runtimeRef.current, viewerTheme, defaultGridRadius, 0, sceneScaleMode, floorMode);
setViewerReadyTick((value) => value + 1);
cleanup = () => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (runtime.interactionState.restoreTimerId) {
window.clearTimeout(runtime.interactionState.restoreTimerId);
}
if (runtime.interactionState.renderFallbackTimerId) {
window.clearTimeout(runtime.interactionState.renderFallbackTimerId);
}
cancelCameraTransition(runtime, { scheduleIdle: false });
window.cancelAnimationFrame(runtime.rafId);
window.removeEventListener("resize", runtime.onResize);
runtime.resizeObserver?.disconnect();
runtime.controls.removeEventListener("start", handleControlsStart);
runtime.controls.removeEventListener("change", handleControlsChange);
runtime.controls.removeEventListener("end", handleControlsEnd);
runtime.renderer.domElement.removeEventListener("wheel", handleWheel, wheelListenerOptions);
runtime.renderer.domElement.removeEventListener("webglcontextlost", handleContextLost, false);
runtime.renderer.domElement.removeEventListener("webglcontextrestored", handleContextRestored, false);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", clearKeyboardOrbit);
document.removeEventListener("visibilitychange", handleVisibilityChange);
runtime.controls.dispose();
clearSceneGroup(runtime.stageGroup);
clearSceneGroup(runtime.modelGroup);
clearSceneGroup(runtime.edgesGroup);
clearSceneGroup(runtime.facePickGroup);
clearSceneGroup(runtime.edgePickGroup);
clearSceneGroup(runtime.vertexPickGroup);
disposeSceneObject(runtime.gridHelper);
disposeSceneObject(runtime.axesHelper);
disposeTexture(runtime.sceneBackgroundTexture);
disposeTexture(runtime.environmentTexture);
runtime.renderer.dispose();
if (container.contains(runtime.renderer.domElement)) {
container.removeChild(runtime.renderer.domElement);
}
runtimeRef.current = null;
};
}
initializeViewer().catch((err) => {
if (!cancelled) {
setError(runtimeErrorMessage(err));
onInitializationError?.(err);
}
});
return () => {
cancelled = true;
cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runtimeResetToken]);
}
@@ -0,0 +1,28 @@
export const VIEWER_CONTEXT_MENU_SUPPRESSION_MS = 1200;
export function createViewerContextMenuGestureState({
now = () => Date.now(),
suppressionMs = VIEWER_CONTEXT_MENU_SUPPRESSION_MS
} = {}) {
let suppressUntil = 0;
return {
suppressNextContextMenu() {
suppressUntil = Math.max(suppressUntil, now() + suppressionMs);
},
consumeSuppression() {
if (now() > suppressUntil) {
suppressUntil = 0;
return false;
}
suppressUntil = 0;
return true;
},
clear() {
suppressUntil = 0;
},
isSuppressed() {
return now() <= suppressUntil;
}
};
}
@@ -0,0 +1,37 @@
const MAX_ORBIT_DELTA_SECONDS = 1;
export const PREVIEW_ORBIT_SECONDS_PER_TURN = 60;
export const PREVIEW_AUTO_ROTATE_SPEED = 60 / PREVIEW_ORBIT_SECONDS_PER_TURN;
function finiteTimestamp(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
export function orbitControlsDeltaSeconds(timestamp, previousTimestamp) {
const current = finiteTimestamp(timestamp);
const previous = finiteTimestamp(previousTimestamp);
if (current === null || previous === null || previous <= 0 || current <= previous) {
return null;
}
return Math.min((current - previous) / 1000, MAX_ORBIT_DELTA_SECONDS);
}
export function updateOrbitControls(controls, timestamp, state) {
if (!controls || typeof controls.update !== "function") {
return false;
}
if (!controls.autoRotate) {
if (state) {
state.orbitControlsLastTimestamp = 0;
}
return controls.update();
}
const current = finiteTimestamp(timestamp);
const deltaSeconds = orbitControlsDeltaSeconds(current, state?.orbitControlsLastTimestamp);
if (state) {
state.orbitControlsLastTimestamp = current ?? 0;
}
return deltaSeconds === null ? controls.update() : controls.update(deltaSeconds);
}
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
orbitControlsDeltaSeconds,
PREVIEW_AUTO_ROTATE_SPEED,
PREVIEW_ORBIT_SECONDS_PER_TURN,
updateOrbitControls
} from "./orbitControls.js";
test("preview auto-rotate speed uses the configured full-turn duration", () => {
assert.equal(PREVIEW_ORBIT_SECONDS_PER_TURN, 60);
assert.equal(PREVIEW_AUTO_ROTATE_SPEED, 1);
});
test("orbitControlsDeltaSeconds converts animation timestamps from ms to seconds", () => {
assert.equal(orbitControlsDeltaSeconds(1016, 1000), 0.016);
});
test("orbitControlsDeltaSeconds preserves slow render frames", () => {
assert.equal(orbitControlsDeltaSeconds(1400, 1000), 0.4);
});
test("orbitControlsDeltaSeconds clamps stale frame gaps", () => {
assert.equal(orbitControlsDeltaSeconds(3000, 1000), 1);
});
test("updateOrbitControls passes seconds while auto-rotate is active", () => {
const updateArgs = [];
const controls = {
autoRotate: true,
update(...args) {
updateArgs.push(args);
return true;
}
};
const state = { orbitControlsLastTimestamp: 1000 };
assert.equal(updateOrbitControls(controls, 1016, state), true);
assert.deepEqual(updateArgs, [[0.016]]);
assert.equal(state.orbitControlsLastTimestamp, 1016);
});
test("updateOrbitControls resets timing when auto-rotate is inactive", () => {
const updateArgs = [];
const controls = {
autoRotate: false,
update(...args) {
updateArgs.push(args);
return false;
}
};
const state = { orbitControlsLastTimestamp: 1016 };
assert.equal(updateOrbitControls(controls, 1032, state), false);
assert.deepEqual(updateArgs, [[]]);
assert.equal(state.orbitControlsLastTimestamp, 0);
});
@@ -0,0 +1,20 @@
import {
normalizeThemeSettings
} from "cadjs/lib/themeSettings.js";
import {
normalizeDisplaySettings
} from "cadjs/lib/displaySettings.js";
export function normalizeViewerRenderState({
themeSettings = {},
displaySettings = null
} = {}) {
const normalizedThemeSettings = normalizeThemeSettings(themeSettings);
const normalizedDisplaySettings = normalizeDisplaySettings(displaySettings);
return {
themeSettings: normalizedThemeSettings,
displaySettings: normalizedDisplaySettings,
displayMode: normalizedDisplaySettings.mode,
clipSettings: normalizedDisplaySettings.clip
};
}
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeStepClipSettings
} from "cadjs/lib/viewer/clipPlane.js";
import {
cloneThemePresetSettings,
normalizeThemeSettings
} from "cadjs/lib/themeSettings.js";
import {
CAD_DISPLAY_MODE,
normalizeDisplaySettings
} from "cadjs/lib/displaySettings.js";
import {
normalizeViewerRenderState
} from "./renderState.js";
test("viewer render-state normalization preserves current theme and display behavior", () => {
const themeSettings = cloneThemePresetSettings("dark");
const displaySettings = {
mode: CAD_DISPLAY_MODE.WIREFRAME,
clip: {
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}
};
const state = normalizeViewerRenderState({
themeSettings,
displaySettings
});
assert.deepEqual(state.themeSettings, normalizeThemeSettings(themeSettings));
assert.deepEqual(state.displaySettings, normalizeDisplaySettings(displaySettings));
assert.equal(state.displayMode, CAD_DISPLAY_MODE.WIREFRAME);
assert.deepEqual(state.clipSettings, normalizeStepClipSettings({
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}));
});
test("viewer render-state normalization keeps viewer-side defaults local", () => {
const state = normalizeViewerRenderState();
assert.deepEqual(state.themeSettings, normalizeThemeSettings({}));
assert.deepEqual(state.displaySettings, normalizeDisplaySettings(null));
assert.equal(state.displayMode, "solid");
assert.deepEqual(state.clipSettings, normalizeStepClipSettings(null));
});
@@ -0,0 +1,173 @@
function AssemblyContextMenuItemLabel({ children }) {
return <span className="min-w-0 truncate">{children}</span>;
}
export default function AssemblyContextMenuItems({
Item,
Separator,
itemClassName = "text-xs",
selected = false,
isolated = false,
hidden = false,
actionCount = 1,
copyReferenceDisabled = false,
selectDisabled = false,
showIsolate = true,
isolateDisabled = false,
showExitAllIsolate = false,
exitAllIsolateDisabled = false,
showHideOther = true,
hideOtherDisabled = false,
hideAllDisabled = true,
hideAllLabel = "Show all",
showVisibility = true,
visibilityDisabled = false,
showCameraActions = false,
resetZoomDisabled = false,
zoomToFitDisabled = false,
showHideAll = false,
showExpandCollapse = false,
expandSelectedDisabled = true,
collapseSelectedDisabled = true,
expandAllDisabled = true,
collapseAllDisabled = true,
onCopyReference,
onSelect,
onIsolate,
onExitAllIsolate,
onHideOther,
onHideAll,
onToggleVisibility,
onResetZoom,
onZoomToFit,
onExpandSelected,
onCollapseSelected,
onExpandAll,
onCollapseAll
}) {
const selectLabel = selected ? "Deselect" : "Select";
const isolateLabel = isolated
? "Exit isolate"
: "Isolate";
const visibilityLabel = hidden
? "Reveal"
: "Hide";
return (
<>
<Item
className={itemClassName}
disabled={copyReferenceDisabled}
onSelect={onCopyReference}
>
<AssemblyContextMenuItemLabel>Copy Reference</AssemblyContextMenuItemLabel>
</Item>
<Separator />
<Item
className={itemClassName}
disabled={selectDisabled}
onSelect={onSelect}
>
<AssemblyContextMenuItemLabel>{selectLabel}</AssemblyContextMenuItemLabel>
</Item>
{showIsolate ? (
<Item
className={itemClassName}
disabled={isolateDisabled}
onSelect={onIsolate}
>
<AssemblyContextMenuItemLabel>{isolateLabel}</AssemblyContextMenuItemLabel>
</Item>
) : null}
{showExitAllIsolate ? (
<Item
className={itemClassName}
disabled={exitAllIsolateDisabled}
onSelect={onExitAllIsolate}
>
<AssemblyContextMenuItemLabel>Exit all isolates</AssemblyContextMenuItemLabel>
</Item>
) : null}
{showHideOther || showHideAll || showVisibility ? <Separator /> : null}
{showHideOther ? (
<Item
className={itemClassName}
disabled={hideOtherDisabled}
onSelect={onHideOther}
>
<AssemblyContextMenuItemLabel>Hide others</AssemblyContextMenuItemLabel>
</Item>
) : null}
{showHideAll ? (
<Item
className={itemClassName}
disabled={hideAllDisabled}
onSelect={onHideAll}
>
<AssemblyContextMenuItemLabel>{hideAllLabel}</AssemblyContextMenuItemLabel>
</Item>
) : null}
{showVisibility ? (
<Item
className={itemClassName}
disabled={visibilityDisabled}
onSelect={onToggleVisibility}
>
<AssemblyContextMenuItemLabel>{visibilityLabel}</AssemblyContextMenuItemLabel>
</Item>
) : null}
{showCameraActions ? (
<>
<Separator />
<Item
className={itemClassName}
disabled={resetZoomDisabled}
onSelect={onResetZoom}
>
<AssemblyContextMenuItemLabel>Reset Zoom</AssemblyContextMenuItemLabel>
</Item>
<Item
className={itemClassName}
disabled={zoomToFitDisabled}
onSelect={onZoomToFit}
>
<AssemblyContextMenuItemLabel>Zoom To Fit</AssemblyContextMenuItemLabel>
</Item>
</>
) : null}
{showExpandCollapse ? (
<>
<Separator />
<Item
className={itemClassName}
disabled={expandSelectedDisabled}
onSelect={onExpandSelected}
>
<AssemblyContextMenuItemLabel>Expand</AssemblyContextMenuItemLabel>
</Item>
<Item
className={itemClassName}
disabled={collapseSelectedDisabled}
onSelect={onCollapseSelected}
>
<AssemblyContextMenuItemLabel>Collapse</AssemblyContextMenuItemLabel>
</Item>
<Item
className={itemClassName}
disabled={expandAllDisabled}
onSelect={onExpandAll}
>
<AssemblyContextMenuItemLabel>Expand all</AssemblyContextMenuItemLabel>
</Item>
<Item
className={itemClassName}
disabled={collapseAllDisabled}
onSelect={onCollapseAll}
>
<AssemblyContextMenuItemLabel>Collapse all</AssemblyContextMenuItemLabel>
</Item>
</>
) : null}
</>
);
}
@@ -0,0 +1,753 @@
import { useEffect, useMemo } from "react";
import CadViewer from "../CadViewer";
import DxfViewer from "../DxfViewer";
import ImplicitCadViewer from "../ImplicitCadViewer";
import { CircleAlert, X } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { Button } from "../ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "../ui/dropdown-menu";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import AssemblyContextMenuItems from "./AssemblyContextMenuItems";
import { cn } from "@/ui/utils";
import { RENDER_FORMAT } from "@/workbench/constants";
import {
isMeshRenderFormat,
isRobotRenderFormat
} from "cadjs/lib/fileFormats";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "cadjs/lib/displaySettings";
import { VIEWER_SCENE_SCALE } from "cadjs/lib/viewer/sceneScale";
import { VIEWER_PICK_MODE } from "cadjs/lib/viewer/constants";
import { useStepAnimationSnapshot } from "@/workbench/stepAnimationStore";
import { viewerPickModeForRenderPane } from "@/workbench/viewerPickMode";
const EMPTY_LIST = Object.freeze([]);
const VIEWPORT_ISSUE_META = Object.freeze({
error: {
label: "Error",
borderClassName: "border-destructive/45",
iconClassName: "border-destructive/45 bg-destructive/10 text-destructive dark:text-red-300",
labelClassName: "text-destructive dark:text-red-300"
},
warning: {
label: "Warning",
borderClassName: "border-amber-500/45",
iconClassName: "border-amber-500/55 bg-amber-500/10 text-amber-500 dark:text-amber-300",
labelClassName: "text-amber-500 dark:text-amber-300"
}
});
function viewportInsetPx(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : 0;
}
function viewportIssueMetaForAlert(alert) {
return alert?.severity === "warning"
? VIEWPORT_ISSUE_META.warning
: VIEWPORT_ISSUE_META.error;
}
function viewerContextMenuAnchorStyle(menu, viewportFrameInsets) {
if (!menu) {
return null;
}
const margin = 8;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 0;
const minX = viewportInsetPx(viewportFrameInsets?.left) + margin;
const minY = viewportInsetPx(viewportFrameInsets?.top) + margin;
const maxX = viewportWidth > 0
? Math.max(minX, viewportWidth - viewportInsetPx(viewportFrameInsets?.right) - margin)
: Number(menu.x) || minX;
const maxY = viewportHeight > 0
? Math.max(minY, viewportHeight - viewportInsetPx(viewportFrameInsets?.bottom) - margin)
: Number(menu.y) || minY;
const x = Math.min(Math.max(Number(menu.x) || minX, minX), maxX);
const y = Math.min(Math.max(Number(menu.y) || minY, minY), maxY);
return {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
width: "1px",
height: "1px"
};
}
function DxfViewModeControl({
value,
threeDimensionalAvailable = false,
onChange
}) {
const normalizedValue = value === "3d" && threeDimensionalAvailable ? "3d" : "2d";
return (
<div className="cad-glass-surface rounded-md border border-sidebar-border p-0.5 shadow-sm">
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={normalizedValue}
onValueChange={(nextValue) => {
if (!nextValue) {
return;
}
if (nextValue === "3d" && !threeDimensionalAvailable) {
return;
}
onChange?.(nextValue);
}}
className="grid h-7 w-[5.5rem] grid-cols-2"
aria-label="DXF view mode"
>
<ToggleGroupItem
value="2d"
className="!h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground data-[state=on]:!bg-accent data-[state=on]:!text-foreground data-[state=on]:font-semibold"
title="Show DXF flat pattern"
>
2D
</ToggleGroupItem>
<ToggleGroupItem
value="3d"
disabled={!threeDimensionalAvailable}
className="!h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground data-[state=on]:!bg-accent data-[state=on]:!text-foreground data-[state=on]:font-semibold"
title={threeDimensionalAvailable ? "Show DXF bend preview" : "3D bend preview unavailable"}
>
3D
</ToggleGroupItem>
</ToggleGroup>
</div>
);
}
function ViewerContextMenu({
menu,
positionStyle,
onClose,
onCopyReference,
onSelect,
onFocus,
onExitAllIsolate,
onHideOther,
onHideAll,
onHide,
onReveal,
onResetZoom,
onZoomToFit,
onExpandSelected,
onCollapseSelected,
onExpandAll,
onCollapseAll
}) {
if (!menu || !positionStyle) {
return null;
}
const itemClassName = "text-xs";
const handleAction = (action) => {
action?.(menu);
onClose?.();
};
const selected = menu.selected === true;
const hidden = menu.hidden === true;
const focused = menu.focused === true;
return (
<DropdownMenu
open={true}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose?.();
}
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
tabIndex={-1}
aria-hidden="true"
className="pointer-events-none fixed size-px opacity-0"
style={positionStyle}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={4}
className="w-44"
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
{menu.global === true ? (
<>
{menu.showShowAll === true ? (
<DropdownMenuItem
className={itemClassName}
onSelect={() => handleAction(onHideAll)}
>
Show all
</DropdownMenuItem>
) : null}
{menu.showShowAll === true && menu.showCameraActions !== false ? (
<DropdownMenuSeparator />
) : null}
{menu.showCameraActions !== false ? (
<>
<DropdownMenuItem
className={itemClassName}
disabled={menu.resetZoomDisabled === true}
onSelect={() => handleAction(onResetZoom)}
>
Reset Zoom
</DropdownMenuItem>
</>
) : null}
{menu.showCameraActions !== false && menu.showExpandCollapse === true ? (
<DropdownMenuSeparator />
) : null}
{menu.showShowAll === true && menu.showCameraActions === false && menu.showExpandCollapse === true ? (
<DropdownMenuSeparator />
) : null}
{menu.showExpandCollapse === true ? (
<>
<DropdownMenuItem
className={itemClassName}
disabled={menu.expandAllDisabled === true}
onSelect={() => handleAction(onExpandAll)}
>
Expand all
</DropdownMenuItem>
<DropdownMenuItem
className={itemClassName}
disabled={menu.collapseAllDisabled === true}
onSelect={() => handleAction(onCollapseAll)}
>
Collapse all
</DropdownMenuItem>
</>
) : null}
</>
) : (
<AssemblyContextMenuItems
Item={DropdownMenuItem}
Separator={DropdownMenuSeparator}
itemClassName={itemClassName}
selected={selected}
isolated={focused}
hidden={hidden}
actionCount={menu.actionCount}
copyReferenceDisabled={!String(menu.copyText || "").trim()}
selectDisabled={menu.selectDisabled === true}
showIsolate={menu.showIsolate !== false}
isolateDisabled={menu.isolateDisabled === true}
showExitAllIsolate={menu.showExitAllIsolate === true}
exitAllIsolateDisabled={menu.exitAllIsolateDisabled === true}
showHideOther={menu.showHideOther !== false}
hideOtherDisabled={menu.hideOtherDisabled === true}
showVisibility={menu.showVisibility !== false}
showHideAll={menu.showHideAll === true}
hideAllDisabled={menu.hideAllDisabled === true}
hideAllLabel={String(menu.hideAllLabel || "").trim() || "Show all"}
visibilityDisabled={menu.visibilityDisabled === true}
showCameraActions={menu.showCameraActions !== false}
resetZoomDisabled={menu.resetZoomDisabled === true}
zoomToFitDisabled={menu.zoomToFitDisabled === true}
showExpandCollapse={menu.showExpandCollapse === true}
expandSelectedDisabled={menu.expandSelectedDisabled !== false}
collapseSelectedDisabled={menu.collapseSelectedDisabled !== false}
expandAllDisabled={menu.expandAllDisabled !== false}
collapseAllDisabled={menu.collapseAllDisabled !== false}
onCopyReference={() => handleAction(onCopyReference)}
onSelect={() => handleAction(onSelect)}
onIsolate={() => handleAction(onFocus)}
onExitAllIsolate={() => handleAction(onExitAllIsolate)}
onHideOther={() => handleAction(onHideOther)}
onHideAll={() => handleAction(onHideAll)}
onToggleVisibility={() => handleAction(hidden ? onReveal : onHide)}
onResetZoom={() => handleAction(onResetZoom)}
onZoomToFit={() => handleAction(onZoomToFit)}
onExpandSelected={() => handleAction(onExpandSelected)}
onCollapseSelected={() => handleAction(onCollapseSelected)}
onExpandAll={() => handleAction(onExpandAll)}
onCollapseAll={() => handleAction(onCollapseAll)}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function CadRenderPane({
viewerRef,
renderFormat,
renderPartsIndividually = false,
selectedMeshData,
selectedDxfData,
selectedDxfMeshData,
dxfViewMode = "2d",
onDxfViewModeChange,
selectedImplicitModel,
implicitDynamicRenderActive = false,
implicitGraphicsSettings = null,
selectedKey,
selectedDxfKey,
missingFileRef = "",
viewerPerspective,
viewerPerspectiveRef,
themeSettings,
previewMode,
viewportFrameInsets,
viewerLoading,
viewerAlert,
stepUpdateInProgress,
referenceSelectionPending = false,
referenceSelectionUnavailable = false,
referenceSelectionDeferred = false,
viewPlaneOffsetRight = 16,
viewerMode,
assemblyPickingActive = false,
assemblyParts,
hiddenPartIds,
selectedPartIds,
hoveredPartId,
assemblyMates = EMPTY_LIST,
selectedMateIds = EMPTY_LIST,
hoveredMateId = "",
hoveredReferenceId,
selectedReferenceIds,
selectorRuntime,
displayEdgeRuntime,
stepParameters = null,
pickableFaces,
pickableEdges,
pickableVertices,
focusedPartIds = "",
displaySettings = null,
onProjectionChange,
onDisplayModeChange,
boundsAnimationActive = false,
drawToolActive,
drawingTool,
drawingStrokes,
handleDrawingStrokesChange,
handlePerspectiveChange,
handleModelHoverChange,
handleModelReferenceActivate,
handleModelReferenceDoubleActivate,
handleModelReferenceContext,
viewerContextMenu = null,
onViewerContextMenuClose,
onViewerContextMenuCopyReference,
onViewerContextMenuSelect,
onViewerContextMenuFocus,
onViewerContextMenuExitAllIsolate,
onViewerContextMenuHideOther,
onViewerContextMenuHideAll,
onViewerContextMenuHide,
onViewerContextMenuReveal,
onViewerContextMenuResetZoom,
onViewerContextMenuZoomToFit,
onViewerContextMenuExpandSelected,
onViewerContextMenuCollapseSelected,
onViewerContextMenuExpandAll,
onViewerContextMenuCollapseAll,
handleViewerAlertChange,
handleStepModuleTransformDetectedChange,
selectionCount,
copyButtonLabel,
handleCopySelection,
handleScreenshotCopy,
urdfPosePicker = null
}) {
const liveStepAnimation = useStepAnimationSnapshot();
const resolvedStepParameters = useMemo(() => {
if (!stepParameters?.animationState?.playing) {
return stepParameters;
}
const liveParameterValues = liveStepAnimation?.parameterValues;
if (!liveParameterValues || typeof liveParameterValues !== "object") {
return stepParameters;
}
return {
...stepParameters,
parameterValues: liveParameterValues,
animationState: {
...stepParameters.animationState,
elapsedSec: liveStepAnimation.elapsedSec
}
};
}, [stepParameters, liveStepAnimation]);
const viewerAlertIconLabel = "Viewer error. See the Issues section for details.";
const dxfMode = renderFormat === RENDER_FORMAT.DXF;
const gcodeMode = renderFormat === RENDER_FORMAT.GCODE;
const urdfMode = isRobotRenderFormat(renderFormat);
const implicitMode = renderFormat === RENDER_FORMAT.IMPLICIT;
const meshOnlyMode = isMeshRenderFormat(renderFormat);
const pathPreviewMode = meshOnlyMode || gcodeMode;
const dxf3dAvailable = !!selectedDxfMeshData;
const activeDxfViewMode = dxfViewMode === "3d" && dxf3dAvailable ? "3d" : "2d";
const dxfMeshPreviewReady = dxfMode && activeDxfViewMode === "3d" && dxf3dAvailable;
const activeMeshData = dxfMeshPreviewReady ? selectedDxfMeshData : selectedMeshData;
const stepDisplaySettingsActive = renderFormat === RENDER_FORMAT.STEP && !!displaySettings && !dxfMode && !pathPreviewMode;
const cadProjection = stepDisplaySettingsActive
? normalizeCameraProjection(displaySettings.projection)
: CAMERA_PROJECTION.PERSPECTIVE;
const activeModelKey = dxfMeshPreviewReady ? (selectedDxfKey || selectedKey) : selectedKey;
const stepBoundsAnimationActive = Boolean(resolvedStepParameters?.animationState?.playing);
const cadViewerBoundsAnimationActive = Boolean(boundsAnimationActive || stepBoundsAnimationActive);
const missingFileLabel = String(missingFileRef || "").trim();
const topologySelectionPending = Boolean(referenceSelectionPending && !dxfMode && !urdfMode && !pathPreviewMode);
const topologySelectionUnavailable = Boolean(referenceSelectionUnavailable && !dxfMode && !urdfMode && !pathPreviewMode);
const topologySelectionDeferred = Boolean(referenceSelectionDeferred && activeMeshData && !dxfMode && !urdfMode && !pathPreviewMode);
const urdfPosePickerActive = Boolean(urdfPosePicker?.active);
const urdfPosePickerPrompt = "Select target";
const posePickerExitStyle = {
left: `calc(${Math.max(Number(viewportFrameInsets?.left) || 0, 0)}px + 0.75rem)`,
top: `calc(${Math.max(Number(viewportFrameInsets?.top) || 0, 0)}px + 0.75rem)`
};
const ctaMode = !dxfMode && !pathPreviewMode && drawToolActive
? "screenshot"
: !dxfMode && !pathPreviewMode && selectionCount > 0
? "selection"
: "";
const dxfViewPlaneHeader = dxfMode ? (
<DxfViewModeControl
value={activeDxfViewMode}
threeDimensionalAvailable={dxf3dAvailable}
onChange={onDxfViewModeChange}
/>
) : null;
const bottomOverlayStyle = {
bottom: "1rem"
};
const modelViewportOverlayStyle = {
left: `${viewportInsetPx(viewportFrameInsets?.left)}px`,
right: `${viewportInsetPx(viewportFrameInsets?.right)}px`,
top: `${viewportInsetPx(viewportFrameInsets?.top)}px`,
bottom: `${viewportInsetPx(viewportFrameInsets?.bottom)}px`
};
const modelViewportBottomOverlayStyle = {
left: `${viewportInsetPx(viewportFrameInsets?.left)}px`,
right: `${viewportInsetPx(viewportFrameInsets?.right)}px`,
bottom: `calc(${viewportInsetPx(viewportFrameInsets?.bottom)}px + 1rem)`
};
const ctaOverlayStyle = {
...bottomOverlayStyle,
left: `calc(${viewportInsetPx(viewportFrameInsets?.left)}px + 1rem)`,
right: `calc(${viewportInsetPx(viewportFrameInsets?.right)}px + 1rem)`
};
const ctaLabel = ctaMode === "screenshot" ? "Copy Screenshot" : copyButtonLabel;
const ctaTitle = ctaMode === "screenshot" ? "Copy screenshot to clipboard" : copyButtonLabel;
const ctaDisabled = ctaMode === "screenshot" ? viewerLoading || !activeMeshData : false;
const viewportHasRenderableContent = implicitMode
? !!selectedImplicitModel
: dxfMode && !dxfMeshPreviewReady
? !!selectedDxfData
: !!activeMeshData;
const blockingViewerAlert = viewerAlert && viewerAlert.blocking !== false && (
viewerAlert.blocking ||
viewerAlert.severity !== "warning" ||
!viewportHasRenderableContent
)
? viewerAlert
: null;
const viewportIssueMeta = viewportIssueMetaForAlert(blockingViewerAlert);
const viewerContextMenuStyle = useMemo(
() => viewerContextMenuAnchorStyle(viewerContextMenu, viewportFrameInsets),
[viewerContextMenu, viewportFrameInsets]
);
useEffect(() => {
if (!urdfPosePickerActive || typeof window === "undefined" || typeof document === "undefined") {
return undefined;
}
const handleEscape = (event) => {
if (event.defaultPrevented) {
return;
}
if (event.key !== "Escape" && event.key !== "Esc" && event.code !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
urdfPosePicker?.onCancel?.();
};
window.addEventListener("keydown", handleEscape, true);
document.addEventListener("keydown", handleEscape, true);
return () => {
window.removeEventListener("keydown", handleEscape, true);
document.removeEventListener("keydown", handleEscape, true);
};
}, [urdfPosePicker, urdfPosePickerActive]);
return (
<div className="absolute inset-0">
{implicitMode ? (
<ImplicitCadViewer
key={`implicit:${activeModelKey}`}
ref={viewerRef}
model={selectedImplicitModel}
modelKey={activeModelKey}
isLoading={viewerLoading}
previewMode={previewMode}
viewportFrameInsets={viewportFrameInsets}
viewPlaneOffsetRight={viewPlaneOffsetRight}
themeSettings={themeSettings}
graphicsSettings={implicitGraphicsSettings}
dynamicRenderActive={implicitDynamicRenderActive}
perspective={viewerPerspective}
perspectiveRef={viewerPerspectiveRef}
onPerspectiveChange={handlePerspectiveChange}
onViewerAlertChange={handleViewerAlertChange}
/>
) : dxfMode && !dxfMeshPreviewReady ? (
<DxfViewer
ref={viewerRef}
dxfData={selectedDxfData}
modelKey={selectedDxfKey}
themeSettings={themeSettings}
viewPlaneOffsetRight={viewPlaneOffsetRight}
viewPlaneOffsetBottom="1rem"
viewPlaneHeader={dxfViewPlaneHeader}
onViewerAlertChange={handleViewerAlertChange}
/>
) : (
<CadViewer
ref={viewerRef}
meshData={activeMeshData}
modelKey={activeModelKey}
renderFormat={renderFormat}
perspective={viewerPerspective}
projection={cadProjection}
perspectiveRef={viewerPerspectiveRef}
onProjectionChange={stepDisplaySettingsActive ? onProjectionChange : undefined}
onDisplayModeChange={stepDisplaySettingsActive ? onDisplayModeChange : undefined}
showEdges={!gcodeMode}
recomputeNormals={false}
themeSettings={themeSettings}
displaySettings={stepDisplaySettingsActive ? displaySettings : null}
previewMode={dxfMode ? false : previewMode}
showViewPlane={dxfMode || gcodeMode ? true : !previewMode}
scale={urdfMode ? VIEWER_SCENE_SCALE.URDF : VIEWER_SCENE_SCALE.CAD}
viewPlaneOffsetRight={viewPlaneOffsetRight}
viewPlaneOffsetBottom="1rem"
viewPlaneHeader={dxfViewPlaneHeader}
compactViewPlane={false}
viewportFrameInsets={viewportFrameInsets}
isLoading={viewerLoading}
pickMode={urdfMode
? VIEWER_PICK_MODE.NONE
: viewerPickModeForRenderPane({
dxfMode,
pathPreviewMode,
topologySelectionPending,
topologySelectionUnavailable,
topologySelectionDeferred,
topologyPickingActive: Boolean(
pickableFaces?.length ||
pickableEdges?.length ||
pickableVertices?.length
),
viewerMode,
assemblyPickingActive,
focusedPartIds
})}
renderPartsIndividually={urdfMode ? true : (renderPartsIndividually || Boolean(resolvedStepParameters?.definition))}
pickableParts={dxfMode || urdfMode || pathPreviewMode ? EMPTY_LIST : assemblyParts}
hiddenPartIds={dxfMode || pathPreviewMode ? [] : hiddenPartIds}
selectedPartIds={dxfMode || pathPreviewMode ? [] : selectedPartIds}
hoveredPartId={dxfMode || pathPreviewMode ? "" : hoveredPartId}
assemblyMates={dxfMode || pathPreviewMode ? [] : assemblyMates}
selectedMateIds={dxfMode || pathPreviewMode ? [] : selectedMateIds}
hoveredMateId={dxfMode || pathPreviewMode ? "" : hoveredMateId}
hoveredReferenceId={dxfMode || pathPreviewMode ? "" : hoveredReferenceId}
selectedReferenceIds={dxfMode || pathPreviewMode ? [] : selectedReferenceIds}
selectorRuntime={dxfMode || pathPreviewMode ? null : selectorRuntime}
displayEdgeRuntime={dxfMode || pathPreviewMode ? null : displayEdgeRuntime}
stepParameters={dxfMode || pathPreviewMode ? null : resolvedStepParameters}
pickableFaces={dxfMode || pathPreviewMode ? [] : pickableFaces}
pickableEdges={dxfMode || pathPreviewMode ? [] : pickableEdges}
pickableVertices={dxfMode || pathPreviewMode ? [] : pickableVertices}
focusedPartId={dxfMode || pathPreviewMode ? "" : focusedPartIds}
boundsAnimationActive={cadViewerBoundsAnimationActive}
drawingEnabled={!dxfMode && !pathPreviewMode && drawToolActive}
drawingTool={drawingTool}
drawingStrokes={dxfMode || pathPreviewMode ? [] : drawingStrokes}
onDrawingStrokesChange={handleDrawingStrokesChange}
onPerspectiveChange={handlePerspectiveChange}
onHoverReferenceChange={handleModelHoverChange}
onActivateReference={handleModelReferenceActivate}
onDoubleActivateReference={handleModelReferenceDoubleActivate}
onContextReference={handleModelReferenceContext}
onViewerAlertChange={handleViewerAlertChange}
onStepModuleTransformDetectedChange={handleStepModuleTransformDetectedChange}
urdfPosePicker={urdfPosePicker}
/>
)}
{!previewMode ? (
<ViewerContextMenu
menu={viewerContextMenu}
positionStyle={viewerContextMenuStyle}
onClose={onViewerContextMenuClose}
onCopyReference={onViewerContextMenuCopyReference}
onSelect={onViewerContextMenuSelect}
onFocus={onViewerContextMenuFocus}
onExitAllIsolate={onViewerContextMenuExitAllIsolate}
onHideOther={onViewerContextMenuHideOther}
onHideAll={onViewerContextMenuHideAll}
onHide={onViewerContextMenuHide}
onReveal={onViewerContextMenuReveal}
onResetZoom={onViewerContextMenuResetZoom}
onZoomToFit={onViewerContextMenuZoomToFit}
onExpandSelected={onViewerContextMenuExpandSelected}
onCollapseSelected={onViewerContextMenuCollapseSelected}
onExpandAll={onViewerContextMenuExpandAll}
onCollapseAll={onViewerContextMenuCollapseAll}
/>
) : null}
{!previewMode && missingFileLabel ? (
<div
className="pointer-events-none absolute z-30 flex min-w-0 items-center justify-center px-4 py-4"
style={modelViewportOverlayStyle}
>
<Alert
variant="destructive"
className="cad-glass-popover pointer-events-auto w-full max-w-xl min-w-0 p-5 text-center shadow-lg"
>
<p className="col-start-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-destructive">
File does not exist
</p>
<AlertTitle className="col-start-1 mt-1 line-clamp-none text-lg text-foreground">File does not exist</AlertTitle>
<AlertDescription className="col-start-1 mt-1 text-sm leading-6 text-muted-foreground">
<code className="rounded-md bg-muted px-2 py-1 text-xs text-foreground">{missingFileLabel}</code>
</AlertDescription>
</Alert>
</div>
) : null}
{!previewMode && blockingViewerAlert ? (
<div
className="pointer-events-none absolute z-30 flex min-w-0 items-center justify-center px-3 py-3 sm:px-4"
style={modelViewportOverlayStyle}
>
<div
role="alert"
aria-label={viewerAlertIconLabel}
title={viewerAlertIconLabel}
className={cn(
"cad-glass-popover pointer-events-auto flex w-full max-w-sm min-w-0 flex-col items-center gap-2 rounded-md border px-4 py-3 text-center shadow-md",
viewportIssueMeta.borderClassName
)}
>
<span className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full border",
viewportIssueMeta.iconClassName
)}>
<CircleAlert className="size-5" strokeWidth={2} aria-hidden="true" />
</span>
<div className="min-w-0 max-w-full">
<span className={cn(
"text-[10px] font-medium uppercase tracking-[0.08em]",
viewportIssueMeta.labelClassName
)}>
{viewportIssueMeta.label}
</span>
<div className="mt-1 line-clamp-2 min-w-0 max-w-full break-words text-sm font-medium leading-5 text-foreground">
{viewerAlert.title || viewerAlert.summary || "Viewer issue"}
</div>
{viewerAlert.message ? (
<p className="mt-1 line-clamp-3 min-w-0 max-w-full break-words text-xs leading-5 text-muted-foreground">
{viewerAlert.message}
</p>
) : null}
{viewerAlert.resolution ? (
<p className="mt-1 line-clamp-2 min-w-0 max-w-full break-words text-xs leading-5 text-muted-foreground">
{viewerAlert.resolution}
</p>
) : null}
</div>
</div>
</div>
) : null}
{!previewMode && stepUpdateInProgress ? (
<div className="pointer-events-none absolute z-20 flex justify-center px-4" style={modelViewportBottomOverlayStyle}>
<Alert
role="status"
className="cad-glass-popover w-auto px-3 py-1.5 text-[11px] font-medium text-popover-foreground shadow-sm"
>
STEP changed. Updating/regenerating references...
</Alert>
</div>
) : null}
{!previewMode && !stepUpdateInProgress && topologySelectionPending ? (
<div className="pointer-events-none absolute z-20 flex justify-center px-4" style={modelViewportBottomOverlayStyle}>
<Alert
role="status"
className="cad-glass-popover w-auto px-3 py-1.5 text-[11px] font-medium text-popover-foreground shadow-sm"
>
Preparing selectable topology...
</Alert>
</div>
) : null}
{!previewMode && urdfPosePickerActive ? (
<Button
type="button"
variant="outline"
size="icon-xs"
className="cad-glass-popover pointer-events-auto absolute z-30 size-6 rounded-md border-sidebar-border p-0 text-popover-foreground shadow-sm"
style={posePickerExitStyle}
onClick={() => {
urdfPosePicker?.onCancel?.();
}}
aria-label="Exit Select Pose"
title="Exit Select Pose"
>
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
</Button>
) : null}
{!previewMode && urdfPosePickerActive ? (
<div className="pointer-events-none absolute z-20 flex justify-center px-4" style={modelViewportBottomOverlayStyle}>
<Alert
role="status"
className="cad-glass-popover w-auto px-3 py-1.5 text-[11px] font-medium text-popover-foreground shadow-sm"
>
{urdfPosePickerPrompt}
</Alert>
</div>
) : null}
{!previewMode && ctaMode && !stepUpdateInProgress && !topologySelectionPending && !topologySelectionUnavailable && !topologySelectionDeferred ? (
<div
className="pointer-events-none absolute z-20 flex min-w-0 justify-center"
style={ctaOverlayStyle}
>
<Button
type="button"
variant="default"
size="sm"
className="pointer-events-auto h-9 w-fit min-w-0 max-w-[min(28rem,100%)] shrink overflow-hidden border border-primary/20 bg-primary/85 px-4 text-[12px] font-semibold text-primary-foreground shadow-lg shadow-black/20 hover:bg-primary/75 focus-visible:ring-primary/35 max-sm:w-full"
disabled={ctaDisabled}
onClick={() => {
if (ctaMode === "screenshot") {
void handleScreenshotCopy?.();
return;
}
void handleCopySelection();
}}
title={ctaTitle}
>
<span className="block min-w-0 max-w-full truncate">{ctaLabel}</span>
</Button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,47 @@
import { ChevronLeft, X } from "lucide-react";
import { Button } from "../ui/button";
export default function CadWorkspaceAssemblyInspectPill({
previewMode,
inspectedAssemblyPart,
canGoBack = false,
toolbarHeight,
onExit
}) {
const partLabel = String(
inspectedAssemblyPart?.name ||
inspectedAssemblyPart?.label ||
inspectedAssemblyPart?.id ||
""
).trim();
if (previewMode || !partLabel) {
return null;
}
return (
<div
className="pointer-events-none absolute inset-x-0 z-20 flex justify-center px-4"
style={{ top: `${toolbarHeight + 14}px` }}
>
<Button
type="button"
variant="outline"
size="sm"
className="cad-glass-popover pointer-events-auto max-w-[min(32rem,calc(100vw-6rem))] rounded-full px-3 py-1.5 text-[12px] font-medium text-popover-foreground shadow-sm"
onClick={onExit}
aria-label={canGoBack ? `Back to parent assembly from ${partLabel}` : `Exit focus for ${partLabel}`}
title={canGoBack ? `Back to parent assembly from ${partLabel}` : `Exit focus for ${partLabel}`}
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-[var(--ui-panel-muted)] text-[var(--ui-text-faint)]">
{canGoBack ? (
<ChevronLeft className="h-3 w-3" strokeWidth={2.1} aria-hidden="true" />
) : (
<X className="h-3 w-3" strokeWidth={2.1} aria-hidden="true" />
)}
</span>
<span className="truncate">{partLabel}</span>
</Button>
</div>
);
}
@@ -0,0 +1,297 @@
import {
Bot,
Boxes,
ChevronRight,
Code,
Cuboid,
DraftingCompass,
FileBox,
FolderOpen,
Layers3,
Package,
Route
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/ui/utils";
import { RENDER_FORMAT } from "@/workbench/constants";
import {
entrySourceFormat,
isMeshRenderFormat,
isRobotRenderFormat
} from "cadjs/lib/fileFormats";
import {
ENTRY_ICON_KIND,
entryIconKind
} from "@/workbench/entryIconKind";
import {
fileKey,
sidebarLabelForEntry
} from "@/workbench/sidebar";
const MAX_HOME_OPTIONS = 6;
const ENTRY_ICON_COMPONENTS = {
[ENTRY_ICON_KIND.ASSEMBLY]: Boxes,
[ENTRY_ICON_KIND.DXF]: DraftingCompass,
[ENTRY_ICON_KIND.GCODE]: Route,
[ENTRY_ICON_KIND.IMPLICIT]: Code,
[ENTRY_ICON_KIND.ROBOT]: Bot,
[ENTRY_ICON_KIND.STEP_PART]: Package,
[ENTRY_ICON_KIND.STL_MESH]: Cuboid,
[ENTRY_ICON_KIND.THREE_MF_MESH]: Layers3,
[ENTRY_ICON_KIND.GLB_MESH]: FileBox
};
function iconForEntry(entry, sourceFormat) {
return ENTRY_ICON_COMPONENTS[entryIconKind(entry, { sourceFormat })] || Package;
}
function formatLabelForEntry(entry, sourceFormat) {
if (entry?.kind === "assembly") {
return "Assembly";
}
if (sourceFormat === RENDER_FORMAT.DXF) {
return "DXF";
}
if (sourceFormat === RENDER_FORMAT.GCODE) {
return "G-code";
}
if (sourceFormat === RENDER_FORMAT.IMPLICIT) {
return "Implicit";
}
if (entry?.kind === "srdf") {
return "SRDF";
}
if (sourceFormat === RENDER_FORMAT.URDF) {
return "URDF";
}
if (sourceFormat === RENDER_FORMAT.SDF) {
return "SDF";
}
if (isMeshRenderFormat(sourceFormat)) {
return sourceFormat.toUpperCase();
}
return "STEP";
}
function pathLabelForEntry(entry) {
return String(entry?.file || "").trim();
}
function directoryLabelForOption(option) {
const rootName = String(option?.rootName || "").trim();
if (rootName) {
return rootName;
}
const pathLabel = String(option?.rootPath || option?.dir || "").trim().replace(/\\/g, "/").replace(/\/+$/g, "");
return pathLabel.split("/").filter(Boolean).pop() || pathLabel || "Directory";
}
function directoryPathLabelForOption(option) {
return String(option?.rootPath || option?.dir || "").trim();
}
function normalizeDirectoryOptions(options) {
const seen = new Set();
const result = [];
for (const option of Array.isArray(options) ? options : []) {
const dir = String(option?.dir || "").trim();
const rootPath = String(option?.rootPath || "").trim();
const key = rootPath || dir;
if (!dir || !key || seen.has(key)) {
continue;
}
seen.add(key);
result.push({
dir,
rootPath,
rootName: String(option?.rootName || "").trim()
});
}
return result;
}
function compareEntryLabels(a, b) {
return sidebarLabelForEntry(a).localeCompare(sidebarLabelForEntry(b), undefined, {
numeric: true,
sensitivity: "base"
});
}
function addHomeEntry(result, seenKeys, entry) {
const key = fileKey(entry);
if (!key || seenKeys.has(key)) {
return;
}
seenKeys.add(key);
result.push(entry);
}
export function selectHomeEntries(entries) {
const sortedEntries = [...(Array.isArray(entries) ? entries : [])].sort(compareEntryLabels);
const result = [];
const seenKeys = new Set();
const groups = [
(entry) => entry?.kind === "assembly",
(entry) => entrySourceFormat(entry) === RENDER_FORMAT.STEP && entry?.kind !== "assembly",
(entry) => entrySourceFormat(entry) === RENDER_FORMAT.DXF,
(entry) => entrySourceFormat(entry) === RENDER_FORMAT.GCODE,
(entry) => entrySourceFormat(entry) === RENDER_FORMAT.IMPLICIT,
(entry) => isRobotRenderFormat(entrySourceFormat(entry)) || entry?.kind === "srdf",
(entry) => isMeshRenderFormat(entrySourceFormat(entry))
];
for (const matchesGroup of groups) {
const match = sortedEntries.find((entry) => matchesGroup(entry));
addHomeEntry(result, seenKeys, match);
}
for (const entry of sortedEntries) {
if (result.length >= MAX_HOME_OPTIONS) {
break;
}
addHomeEntry(result, seenKeys, entry);
}
return result.slice(0, MAX_HOME_OPTIONS);
}
export default function CadWorkspaceHome({
entries,
onSelectEntry,
catalogHydrated = false,
catalogRefreshing = false,
catalogError = "",
directorySelectionActive = false,
directoryOptions = [],
onSelectDirectory
}) {
const homeEntries = selectHomeEntries(entries);
const normalizedDirectoryOptions = normalizeDirectoryOptions(directoryOptions);
const hasEntries = homeEntries.length > 0;
const hasDirectoryOptions = normalizedDirectoryOptions.length > 0;
const catalogErrorMessage = String(catalogError || "").trim();
const catalogLoading = !catalogHydrated || (catalogRefreshing && !hasEntries);
const heading = directorySelectionActive
? "Select a directory"
: "Select a file";
return (
<div className="pointer-events-none absolute inset-0 z-20 flex min-w-0 items-center justify-center px-4 py-6">
<section
className="cad-glass-popover pointer-events-auto w-full max-w-2xl overflow-hidden rounded-md border border-sidebar-border text-popover-foreground shadow-xl shadow-black/10"
aria-label="CAD Viewer home"
>
<div className="border-b border-sidebar-border px-5 py-4 sm:px-6">
<h1 className="text-lg font-medium leading-6 text-foreground sm:text-xl">
{heading}
</h1>
</div>
<div className="divide-y divide-sidebar-border/70">
{directorySelectionActive ? (
hasDirectoryOptions ? normalizedDirectoryOptions.map((option) => {
const label = directoryLabelForOption(option);
const pathLabel = directoryPathLabelForOption(option);
return (
<Button
key={option.rootPath || option.dir}
type="button"
variant="ghost"
className="group h-auto w-full justify-start rounded-none px-5 py-3 text-left hover:bg-sidebar-accent/80 focus-visible:ring-inset has-[>svg]:px-5 sm:px-6 sm:has-[>svg]:px-6"
onClick={() => {
if (typeof onSelectDirectory === "function") {
onSelectDirectory(option.dir);
}
}}
title={pathLabel || label}
>
<FolderOpen className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block min-w-0 truncate text-sm font-medium text-foreground">
{label}
</span>
{pathLabel ? (
<span className="mt-0.5 block min-w-0 truncate text-[11px] font-normal text-muted-foreground">
{pathLabel}
</span>
) : null}
</span>
<span
className={cn(
"shrink-0 rounded-md border border-sidebar-border px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-muted-foreground",
"max-sm:hidden"
)}
>
Directory
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" aria-hidden="true" />
</Button>
);
}) : (
<p className="px-5 py-5 text-sm text-muted-foreground sm:px-6">
No active directories found.
</p>
)
) : hasEntries ? homeEntries.map((entry) => {
const key = fileKey(entry);
const sourceFormat = entrySourceFormat(entry);
const EntryIcon = iconForEntry(entry, sourceFormat);
const label = sidebarLabelForEntry(entry) || key;
const pathLabel = pathLabelForEntry(entry);
const formatLabel = formatLabelForEntry(entry, sourceFormat);
return (
<Button
key={key}
type="button"
variant="ghost"
className="group h-auto w-full justify-start rounded-none px-5 py-3 text-left hover:bg-sidebar-accent/80 focus-visible:ring-inset has-[>svg]:px-5 sm:px-6 sm:has-[>svg]:px-6"
onClick={() => {
if (key && typeof onSelectEntry === "function") {
onSelectEntry(key);
}
}}
title={pathLabel || label}
>
<EntryIcon className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block min-w-0 truncate text-sm font-medium text-foreground">
{label}
</span>
{pathLabel ? (
<span className="mt-0.5 block min-w-0 truncate text-[11px] font-normal text-muted-foreground">
{pathLabel}
</span>
) : null}
</span>
<span
className={cn(
"shrink-0 rounded-md border border-sidebar-border px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-muted-foreground",
"max-sm:hidden"
)}
>
{formatLabel}
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground" aria-hidden="true" />
</Button>
);
}) : catalogErrorMessage ? (
<p className="break-words px-5 py-5 text-sm text-muted-foreground sm:px-6" role="status">
CAD catalog unavailable: {catalogErrorMessage}
</p>
) : catalogLoading ? (
<p className="px-5 py-5 text-sm text-muted-foreground sm:px-6" role="status">
Loading CAD catalog...
</p>
) : (
<p className="px-5 py-5 text-sm text-muted-foreground sm:px-6">
No CAD entries found.
</p>
)}
</div>
</section>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
import { Redo2, Trash2, Undo2 } from "lucide-react";
import { cn } from "@/ui/utils";
import { ScrollArea } from "../ui/scroll-area";
import { TooltipProvider } from "../ui/tooltip";
import ToolbarShell from "./ToolbarShell";
import { ToolbarButton } from "./ToolbarButton";
export default function DrawingToolbar({
className,
layout = "wrap",
drawingToolOptions,
drawingTool,
handleSelectDrawingTool,
handleUndoDrawing,
handleRedoDrawing,
handleClearDrawings,
canUndoDrawing,
canRedoDrawing,
drawingStrokes
}) {
const historyUndo = handleUndoDrawing;
const historyRedo = handleRedoDrawing;
const clearAction = handleClearDrawings;
const canUndo = canUndoDrawing;
const canRedo = canRedoDrawing;
const actionCount = drawingStrokes.length;
const scrollLayout = layout === "scroll";
const toolbarButtons = (
<div
className={cn(
"flex gap-1",
scrollLayout ? "w-max min-w-full flex-nowrap" : "flex-wrap"
)}
>
{drawingToolOptions.map(({ id, label, Icon }) => {
const active = drawingTool === id;
return (
<ToolbarButton
key={id}
label={label}
active={active}
aria-pressed={active}
onClick={() => handleSelectDrawingTool(id)}
>
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
);
})}
<ToolbarButton
label="Undo"
onClick={historyUndo}
disabled={!canUndo}
>
<Undo2 className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
label="Redo"
onClick={historyRedo}
disabled={!canRedo}
>
<Redo2 className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
label="Clear all"
onClick={clearAction}
disabled={!actionCount}
>
<Trash2 className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
</div>
);
return (
<ToolbarShell className={cn("p-1.5", className)}>
<TooltipProvider delayDuration={250}>
{scrollLayout ? (
<ScrollArea
className="min-w-0 max-w-full"
type="auto"
scrollbars="horizontal"
>
{toolbarButtons}
</ScrollArea>
) : toolbarButtons}
</TooltipProvider>
</ToolbarShell>
);
}
@@ -0,0 +1,277 @@
import { useEffect, useState } from "react";
import { Minus, Plus } from "lucide-react";
import {
DXF_BEND_DIRECTION,
normalizeDxfBendAngleDeg,
normalizeDxfBendDirection,
normalizeDxfPreviewThicknessMm
} from "cadjs/lib/dxf/buildPreviewMesh";
import {
Accordion
} from "../ui/accordion";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import FileSheet, {
FILE_SHEET_COMPACT_ICON_BUTTON_CLASSES,
FILE_SHEET_COMPACT_NUMERIC_INPUT_CLASSES,
FILE_SHEET_SEGMENTED_ITEM_CLASSES,
FileSheetControlRow,
FileSheetSection,
FileSheetSectionBody,
FileSheetSubsection
} from "./FileSheet";
import FileMetadataSection from "./FileMetadataSection";
import FileStatusSection from "./FileStatusSection";
const compactInputClasses = FILE_SHEET_COMPACT_NUMERIC_INPUT_CLASSES;
const compactIconButtonClasses = FILE_SHEET_COMPACT_ICON_BUTTON_CLASSES;
function formatThicknessInput(valueMm) {
const numericValue = Number(valueMm);
if (!Number.isFinite(numericValue)) {
return "";
}
return numericValue.toFixed(4).replace(/\.?0+$/, "");
}
function formatAngleInput(valueDeg) {
const rounded = Math.round(Number(valueDeg) * 10) / 10;
return Number.isFinite(rounded) ? String(rounded) : "";
}
function DxfBendRow({
index,
setting,
onChange
}) {
const [draftAngle, setDraftAngle] = useState(() => formatAngleInput(setting?.angleDeg));
useEffect(() => {
setDraftAngle(formatAngleInput(setting?.angleDeg));
}, [setting?.angleDeg]);
const commitAngle = (nextValue) => {
const normalizedAngle = normalizeDxfBendAngleDeg(nextValue, setting?.angleDeg);
onChange(index, { angleDeg: normalizedAngle });
setDraftAngle(formatAngleInput(normalizedAngle));
};
const direction = normalizeDxfBendDirection(setting?.direction);
return (
<FileSheetControlRow label={`B${index + 1}`}>
<div className="grid grid-cols-[minmax(0,1fr)_5.75rem] gap-2">
<div className="min-w-0">
<span className="sr-only">Direction</span>
<ToggleGroup
type="single"
variant="outline"
size="sm"
value={direction}
onValueChange={(nextDirection) => {
if (!nextDirection) {
return;
}
onChange(index, { direction: nextDirection });
}}
className="grid h-7 w-full min-w-0 grid-cols-2"
aria-label={`Bend ${index + 1} direction`}
>
<ToggleGroupItem
value={DXF_BEND_DIRECTION.UP}
className={`!h-7 text-[11px] ${FILE_SHEET_SEGMENTED_ITEM_CLASSES}`}
>
Up
</ToggleGroupItem>
<ToggleGroupItem
value={DXF_BEND_DIRECTION.DOWN}
className={`!h-7 text-[11px] ${FILE_SHEET_SEGMENTED_ITEM_CLASSES}`}
>
Down
</ToggleGroupItem>
</ToggleGroup>
</div>
<label className="block">
<span className="sr-only">Angle</span>
<div className="relative">
<Input
type="number"
min="0"
max="180"
step="1"
inputMode="decimal"
value={draftAngle}
onChange={(event) => {
setDraftAngle(event.target.value);
}}
onFocus={(event) => {
event.currentTarget.select();
}}
onMouseUp={(event) => {
event.preventDefault();
}}
onBlur={() => {
commitAngle(draftAngle);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className={`${compactInputClasses} w-full pr-9 text-right`}
aria-label={`Bend ${index + 1} angle in degrees`}
/>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-muted-foreground">deg</span>
</div>
</label>
</div>
</FileSheetControlRow>
);
}
export default function DxfFileSheet({
open,
isDesktop,
width,
selectedEntry = null,
onOpenChange,
valueMm,
bendSettings,
hasDxfData,
viewerLoading,
onStartResize,
onThicknessChange,
onBendChange,
fileDownloadAvailable = false,
viewerServerInfo = null,
localFileOpenAvailable = false,
fileAccessBusyKey = "",
onOpenFileAsset,
suppressDynamicMetadataStatus = false,
statusItems = [],
themeSections = null,
openSectionIds = [],
onOpenSectionIdsChange
}) {
const [draftValue, setDraftValue] = useState(() => formatThicknessInput(valueMm));
const normalizedBendSettings = Array.isArray(bendSettings) ? bendSettings : [];
useEffect(() => {
setDraftValue(formatThicknessInput(valueMm));
}, [valueMm]);
const commitValue = (nextValue) => {
const normalizedValue = normalizeDxfPreviewThicknessMm(nextValue, valueMm);
onThicknessChange(normalizedValue);
setDraftValue(formatThicknessInput(normalizedValue));
};
return (
<FileSheet
open={open}
title="DXF"
isDesktop={isDesktop}
width={width}
onOpenChange={onOpenChange}
onStartResize={onStartResize}
>
<Accordion
type="multiple"
value={openSectionIds}
onValueChange={onOpenSectionIdsChange}
className="text-sm"
>
<FileStatusSection items={statusItems} />
<FileSheetSection value="dxf" title="DXF">
<FileSheetSectionBody className="py-0">
<FileSheetSubsection title="Thickness">
<FileSheetControlRow label="Material">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem] items-center gap-1.5">
<Button
type="button"
variant="ghost"
size="icon-sm"
className={compactIconButtonClasses}
onClick={() => {
commitValue(valueMm - 0.25);
}}
disabled={!hasDxfData}
aria-label="Reduce DXF material thickness"
title="Reduce thickness"
>
<Minus className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</Button>
<div className="relative block">
<Input
type="number"
min="0.2"
max="25"
step="any"
inputMode="decimal"
value={draftValue}
disabled={!hasDxfData}
onChange={(event) => {
setDraftValue(event.target.value);
}}
onBlur={() => {
commitValue(draftValue);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className={`${compactInputClasses} w-full pr-9 text-right`}
aria-label="DXF material thickness in millimeters"
/>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-xs text-muted-foreground">mm</span>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className={compactIconButtonClasses}
onClick={() => {
commitValue(valueMm + 0.25);
}}
disabled={!hasDxfData}
aria-label="Increase DXF material thickness"
title="Increase thickness"
>
<Plus className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</Button>
</div>
</FileSheetControlRow>
</FileSheetSubsection>
<FileSheetSubsection title="Bends">
{normalizedBendSettings.length ? normalizedBendSettings.map((setting, index) => (
<DxfBendRow
key={setting.id || `bend-${index + 1}`}
index={index}
setting={setting}
onChange={onBendChange}
/>
)) : (
<p className="px-3 py-1 text-xs text-muted-foreground">
{viewerLoading ? "Loading bends..." : "No bends are available."}
</p>
)}
</FileSheetSubsection>
</FileSheetSectionBody>
</FileSheetSection>
{themeSections}
<FileMetadataSection
entry={selectedEntry}
fileDownloadAvailable={fileDownloadAvailable}
viewerServerInfo={viewerServerInfo}
localFileOpenAvailable={localFileOpenAvailable}
fileAccessBusyKey={fileAccessBusyKey}
onOpenFileAsset={onOpenFileAsset}
suppressDynamicStatus={suppressDynamicMetadataStatus}
/>
</Accordion>
</FileSheet>
);
}
@@ -0,0 +1,205 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from "@/components/ui/context-menu";
import { fileAccessAssetsForEntry } from "@/workbench/fileAccessAssets";
import { IMPLICIT_EXPORT_FORMATS } from "@/workbench/implicitExport";
function ExplorerViewSection({
entry,
onRevealInExplorerView
}) {
if (typeof onRevealInExplorerView !== "function") {
return null;
}
return (
<ContextMenuItem
className="text-xs"
onSelect={() => {
onRevealInExplorerView(entry);
}}
>
<span className="min-w-0 truncate">Reveal in Explorer View</span>
</ContextMenuItem>
);
}
function FileAccessSection({
entry,
asset,
canRevealFileAssets,
canCopyFileAssetLinks,
canCopyFileAssetPaths,
busyKey = "",
onDownloadFileAsset,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference
}) {
if (!asset) {
return null;
}
const key = `${asset.fileRef}:${asset.asset}`;
const revealBusy = busyKey === key;
const canCopyFileAssetReference = typeof onCopyFileAssetReference === "function";
return (
<>
{canRevealFileAssets ? (
<ContextMenuItem
className="text-xs"
disabled={revealBusy}
onSelect={() => {
onRevealFileAsset(entry, asset.asset, asset);
}}
>
<span className="min-w-0 truncate">Reveal in Folder</span>
</ContextMenuItem>
) : null}
<ExplorerViewSection
entry={entry}
onRevealInExplorerView={onRevealInExplorerView}
/>
{canCopyFileAssetPaths && canCopyFileAssetReference ? (
<>
<ContextMenuItem
className="text-xs"
onSelect={() => {
onCopyFileAssetReference(entry, asset.asset, asset, "path");
}}
>
<span className="min-w-0 truncate">Copy Path</span>
</ContextMenuItem>
<ContextMenuItem
className="text-xs"
onSelect={() => {
onCopyFileAssetReference(entry, asset.asset, asset, "relativePath");
}}
>
<span className="min-w-0 truncate">Copy Relative Path</span>
</ContextMenuItem>
</>
) : null}
{canCopyFileAssetLinks && canCopyFileAssetReference ? (
<ContextMenuItem
className="text-xs"
onSelect={() => {
onCopyFileAssetReference(entry, asset.asset, asset, "link");
}}
>
<span className="min-w-0 truncate">Copy Link</span>
</ContextMenuItem>
) : null}
<ContextMenuItem
className="text-xs"
onSelect={() => {
onDownloadFileAsset(entry, asset.asset, asset);
}}
>
<span className="min-w-0 truncate">Download</span>
</ContextMenuItem>
</>
);
}
function ImplicitExportSection({
entry,
busyKey = "",
onExportImplicitFile
}) {
if (
String(entry?.kind || "").trim().toLowerCase() !== "implicit" ||
typeof onExportImplicitFile !== "function"
) {
return null;
}
const fileRef = String(entry?.file || entry?.id || "").trim();
return (
<>
<ContextMenuSeparator />
{IMPLICIT_EXPORT_FORMATS.map((format) => {
const upperFormat = format.toUpperCase();
const key = `${fileRef}:export:${format}`;
return (
<ContextMenuItem
key={format}
className="text-xs"
disabled={busyKey === key}
onSelect={() => {
onExportImplicitFile(entry, format);
}}
>
<span className="min-w-0 truncate">Export to {upperFormat}</span>
</ContextMenuItem>
);
})}
</>
);
}
export default function FileAccessContextMenu({
entry,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
busyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
children
}) {
const revealInExplorerViewAvailable = entry && typeof onRevealInExplorerView === "function";
const assetActionsAvailable = entry && typeof onDownloadFileAsset === "function";
const implicitExportAvailable = entry && typeof onExportImplicitFile === "function" &&
String(entry?.kind || "").trim().toLowerCase() === "implicit";
if (!revealInExplorerViewAvailable && !assetActionsAvailable && !implicitExportAvailable) {
return children;
}
const assets = fileAccessAssetsForEntry(entry);
if (!revealInExplorerViewAvailable && !assets.output && !implicitExportAvailable) {
return children;
}
return (
<ContextMenu modal={false}>
<ContextMenuTrigger asChild>
{children}
</ContextMenuTrigger>
<ContextMenuContent className="w-64">
{!assets.output || !assetActionsAvailable ? (
<ExplorerViewSection
entry={entry}
onRevealInExplorerView={onRevealInExplorerView}
/>
) : null}
{assets.output && assetActionsAvailable ? (
<FileAccessSection
entry={entry}
asset={assets.output}
canRevealFileAssets={canRevealFileAssets && typeof onRevealFileAsset === "function"}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
busyKey={busyKey}
onDownloadFileAsset={onDownloadFileAsset}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
) : null}
<ImplicitExportSection
entry={entry}
busyKey={busyKey}
onExportImplicitFile={onExportImplicitFile}
/>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,185 @@
import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/ui/utils";
import { copyTextToClipboard } from "@/ui/clipboard";
import { FILE_SHEET_SECTION_IDS } from "@/workbench/fileSheetSections";
import { fileMetadataGroupsForEntry } from "@/workbench/fileMetadata";
import {
FILE_SHEET_FIELD_LABEL_CLASSES,
FileSheetSection,
FileSheetSubsection
} from "./FileSheet";
function MetadataCopyButton({ value, label }) {
const [copied, setCopied] = useState(false);
const Icon = copied ? Check : Copy;
const text = String(value || "").trim();
if (!text) {
return null;
}
return (
<button
type="button"
className={cn(
"mt-[-0.125rem] inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45"
)}
title={`Copy ${label}`}
aria-label={`Copy ${label}`}
onClick={async () => {
try {
await copyTextToClipboard(text);
setCopied(true);
window.setTimeout?.(() => setCopied(false), 1200);
} catch {
setCopied(false);
}
}}
>
<Icon className="size-3" strokeWidth={2} aria-hidden="true" />
</button>
);
}
function MetadataValue({
entry,
row,
fileAccessBusyKey = "",
onOpenFileAsset
}) {
const text = row.displayValue || row.value;
const copyValue = String(row.copyValue || "").trim();
const valueClassName = cn(
"block min-w-0 break-words text-[11px] leading-4",
row.mono && "break-all font-mono text-[10px]"
);
const withCopyButton = (valueNode) => {
if (!copyValue) {
return valueNode;
}
return (
<div className="inline-flex max-w-full items-center gap-1.5 align-top">
<div className="min-w-0 max-w-full">{valueNode}</div>
<MetadataCopyButton value={copyValue} label={row.label} />
</div>
);
};
if (row.action === "open" && row.asset) {
const busyKey = `${row.asset.fileRef}:${row.asset.asset}`;
const busy = fileAccessBusyKey === busyKey;
const canOpen = typeof onOpenFileAsset === "function";
return withCopyButton(
<a
href={row.openUrl || "#"}
className={cn(
valueClassName,
"text-primary underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45",
(!canOpen || busy) && "pointer-events-none text-muted-foreground"
)}
title={row.title}
aria-disabled={!canOpen || busy}
data-open-url={row.openUrl || undefined}
onClick={(event) => {
event.preventDefault();
if (!canOpen || busy) {
return;
}
onOpenFileAsset(entry, row.asset.asset, row.asset);
}}
>
{busy ? "Opening..." : text}
</a>
);
}
const content = (
<span
className={valueClassName}
title={row.title}
>
{text}
</span>
);
if (!row.href) {
return withCopyButton(content);
}
return withCopyButton(
<a
href={row.href}
download={row.action === "download" ? (row.asset?.filename || true) : undefined}
className={cn(
valueClassName,
"text-primary underline-offset-4 hover:underline focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45"
)}
title={row.title}
>
{text}
</a>
);
}
export default function FileMetadataSection({
entry,
fileDownloadAvailable = false,
viewerServerInfo = null,
localFileOpenAvailable = false,
fileAccessBusyKey = "",
onOpenFileAsset,
suppressDynamicStatus = false
}) {
const groups = fileMetadataGroupsForEntry(entry, {
includeFileDownloadActions: fileDownloadAvailable && !localFileOpenAvailable,
includeFileOpenActions: localFileOpenAvailable,
includePythonSource: localFileOpenAvailable,
viewerServerInfo,
suppressDynamicStatus
});
if (!groups.length) {
return null;
}
return (
<FileSheetSection
value={FILE_SHEET_SECTION_IDS.FILE_METADATA}
title="Metadata"
aria-label="File metadata"
>
<div>
{groups.map((group) => (
<FileSheetSubsection key={group.title} title={group.title} contentClassName="px-3">
<dl className="space-y-1.5">
{group.rows.map((row) => (
<div
key={`${group.title}:${row.label}`}
className="min-w-0 space-y-0.5 py-0.5"
>
<dt
className={cn(FILE_SHEET_FIELD_LABEL_CLASSES, "leading-4")}
title={row.label}
>
{row.label}
</dt>
<dd className="min-w-0 flex-1 text-sidebar-foreground">
<MetadataValue
entry={entry}
row={row}
fileAccessBusyKey={fileAccessBusyKey}
onOpenFileAsset={onOpenFileAsset}
/>
</dd>
</div>
))}
</dl>
</FileSheetSubsection>
))}
</div>
</FileSheetSection>
);
}
@@ -0,0 +1,538 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/ui/utils";
import {
AccordionContent,
AccordionItem,
AccordionTrigger
} from "../ui/accordion";
import { ScrollArea } from "../ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from "../ui/sheet";
import { Switch } from "../ui/switch";
const DEFAULT_FILE_SHEET_WIDTH = 365;
const DESKTOP_FILE_SHEET_MIN_WIDTH = 240;
const DESKTOP_FILE_SHEET_MAX_WIDTH = "min(28rem, calc(100vw - 0.75rem))";
const MOBILE_FILE_SHEET_WIDTH = "min(24rem, calc(100vw - 0.75rem))";
const FILE_SHEET_CONTROL_TEXT_CLASSES = [
"[&_[data-slot=input]]:!text-[11px]",
"[&_[data-slot=select-trigger]]:!text-[11px]",
"[&_[data-slot=color-picker-trigger]]:!text-[11px]"
].join(" ");
export const FILE_SHEET_SECTION_TRIGGER_CLASSES = "px-3 py-2 text-sm font-normal text-sidebar-foreground/90";
export const FILE_SHEET_SECTION_CONTENT_CLASSES = "py-1";
export const FILE_SHEET_CONTROL_ROW_CLASSES = "space-y-1 px-3 py-0.5";
export const FILE_SHEET_ROW_STACK_CLASSES = "space-y-2";
export const FILE_SHEET_SECTION_BODY_CLASSES = `${FILE_SHEET_ROW_STACK_CLASSES} py-3`;
export const FILE_SHEET_SLIDER_FIELD_CLASSES = "space-y-1 px-3 py-0.5";
export const FILE_SHEET_INLINE_CONTROL_ROW_CLASSES = "px-3 py-0.5";
export const FILE_SHEET_FIELD_LABEL_CLASSES = "block min-w-0 truncate text-[11px] font-medium leading-4 text-muted-foreground";
export const FILE_SHEET_SUBSUBSECTION_HEADER_CLASSES = "px-3 py-1 text-[11px] font-normal leading-4 text-sidebar-foreground";
export const FILE_SHEET_VALUE_BADGE_CLASSES = "shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium leading-none tabular-nums text-muted-foreground";
export const FILE_SHEET_VALUE_BADGE_INPUT_CLASSES = [
"h-7 w-20 shrink-0 rounded-md border border-input bg-transparent px-1.5 py-1 text-right text-[11px] font-medium leading-none tabular-nums text-foreground shadow-xs outline-none",
"m-0 box-border min-w-0 appearance-none transition-[color,box-shadow,border-color] placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 dark:bg-input/30"
].join(" ");
export const FILE_SHEET_COMPACT_BUTTON_CLASSES = "h-7 px-2 text-[11px] text-muted-foreground hover:text-foreground";
export const FILE_SHEET_COMPACT_ICON_BUTTON_CLASSES = "size-7 text-muted-foreground hover:text-foreground";
export const FILE_SHEET_COMPACT_INPUT_CLASSES = "!h-7 px-2 text-[11px] !text-foreground";
export const FILE_SHEET_COMPACT_NUMERIC_INPUT_CLASSES = "h-7 px-2 !text-[11px] font-medium leading-none tabular-nums text-foreground";
export const FILE_SHEET_COMPACT_JOINT_INPUT_CLASSES = "h-7 px-2 !text-[11px] font-medium leading-none tabular-nums text-foreground";
export const FILE_SHEET_BOOLEAN_SWITCH_CLASSES = [
"h-4 w-7 border shadow-none",
"data-[state=checked]:border-primary/75 data-[state=checked]:bg-primary",
"data-[state=unchecked]:!border-[rgb(115_125_140_/_0.52)] data-[state=unchecked]:!bg-[rgb(115_125_140_/_0.22)]",
"dark:data-[state=unchecked]:!border-[rgb(148_163_184_/_0.44)] dark:data-[state=unchecked]:!bg-[rgb(148_163_184_/_0.18)]"
].join(" ");
export const FILE_SHEET_BOOLEAN_SWITCH_THUMB_CLASSES = [
"!size-3 shadow-sm",
"data-[state=checked]:!translate-x-3 data-[state=checked]:!bg-background",
"data-[state=unchecked]:!translate-x-0 data-[state=unchecked]:!bg-[rgb(115_125_140)]",
"dark:data-[state=unchecked]:!bg-[rgb(148_163_184)]"
].join(" ");
export const FILE_SHEET_SEGMENTED_ITEM_CLASSES = [
"text-muted-foreground hover:text-foreground",
"data-[state=on]:!bg-accent data-[state=on]:!text-foreground data-[state=on]:font-semibold",
"data-[state=on]:ring-1 data-[state=on]:ring-inset data-[state=on]:ring-border/80",
"data-[state=on]:hover:!bg-accent data-[state=on]:hover:!text-foreground"
].join(" ");
export const FILE_SHEET_PRECISION_SLIDER_CLASSES = [
"h-4",
"[&_[data-slot=slider-track]]:h-px",
"[&_[data-slot=slider-track]]:rounded-none",
"[&_[data-slot=slider-track]]:bg-border",
"[&_[data-slot=slider-range]]:bg-primary",
"[&_[data-slot=slider-thumb]]:h-2.5",
"[&_[data-slot=slider-thumb]]:w-1.5",
"[&_[data-slot=slider-thumb]]:rounded-[1px]",
"[&_[data-slot=slider-thumb]]:border-primary",
"[&_[data-slot=slider-thumb]]:bg-background",
"[&_[data-slot=slider-thumb]]:shadow-none",
"[&_[data-slot=slider-thumb]]:ring-0",
"[&_[data-slot=slider-thumb]]:hover:border-primary/85",
"[&_[data-slot=slider-thumb]]:hover:ring-1",
"[&_[data-slot=slider-thumb]]:hover:ring-ring/25",
"[&_[data-slot=slider-thumb]]:focus-visible:ring-2",
"[&_[data-slot=slider-thumb]]:focus-visible:ring-ring/30"
].join(" ");
export function FileSheetSection({
value,
title,
children,
className,
contentClassName,
triggerClassName,
triggerProps,
...props
}) {
return (
<AccordionItem value={value} className={cn("border-border", className)} {...props}>
<AccordionTrigger
className={cn(FILE_SHEET_SECTION_TRIGGER_CLASSES, triggerClassName)}
{...triggerProps}
>
{title}
</AccordionTrigger>
<AccordionContent className={cn(FILE_SHEET_SECTION_CONTENT_CLASSES, contentClassName)}>
{children}
</AccordionContent>
</AccordionItem>
);
}
export function FileSheetSubsection({
title,
trailing = null,
children,
className,
contentClassName,
hideFirstSeparator = true
}) {
return (
<div
className={cn(
"py-2.5",
hideFirstSeparator && "first:pt-2 first:[&_.cad-sheet-subsection-separator]:hidden",
className
)}
>
<div className="cad-sheet-subsection-separator mx-3 mb-2 h-px bg-border/60" />
<div className="flex min-w-0 items-center justify-between gap-2 px-3 pb-1 text-[12px] font-normal leading-5 text-sidebar-foreground/80">
<span className="min-w-0 truncate">{title}</span>
{trailing ? <span className="shrink-0">{trailing}</span> : null}
</div>
<div
className={cn(FILE_SHEET_ROW_STACK_CLASSES, contentClassName)}
data-file-sheet-row-stack=""
>
{children}
</div>
</div>
);
}
// Use sparingly for rare third-level groups inside a FileSheet subsection.
export function FileSheetSubsubsection({
title,
children,
className,
contentClassName
}) {
return (
<div
className={cn("py-1", className)}
data-file-sheet-subsubsection=""
>
<div className={FILE_SHEET_SUBSUBSECTION_HEADER_CLASSES}>
{title}
</div>
<div
className={cn(FILE_SHEET_ROW_STACK_CLASSES, contentClassName)}
data-file-sheet-row-stack=""
>
{children}
</div>
</div>
);
}
export function FileSheetSectionBody({
children,
className
}) {
return (
<div
className={cn(FILE_SHEET_SECTION_BODY_CLASSES, className)}
data-file-sheet-section-body=""
data-file-sheet-row-stack=""
>
{children}
</div>
);
}
export function FileSheetControlRow({
label,
value,
trailing,
children,
className,
contentClassName,
labelClassName,
rowKind = "control"
}) {
return (
<div
className={cn(FILE_SHEET_CONTROL_ROW_CLASSES, className)}
data-file-sheet-control-row=""
data-file-sheet-row-kind={rowKind}
>
{label != null || value != null || trailing != null ? (
<div className="flex min-h-4 items-center justify-between gap-2">
{label != null ? (
<span className={cn(FILE_SHEET_FIELD_LABEL_CLASSES, labelClassName)}>{label}</span>
) : <span />}
{trailing != null ? trailing : value != null ? (
<span className={FILE_SHEET_VALUE_BADGE_CLASSES}>{value}</span>
) : null}
</div>
) : null}
<div className={cn("min-w-0", contentClassName)}>{children}</div>
</div>
);
}
export function parseFileSheetNumberInput(value, {
fallback = 0,
min = -Infinity,
max = Infinity,
integer = false
} = {}) {
const text = String(value ?? "").trim();
const match = text.match(/[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i);
const fallbackValue = Number.isFinite(Number(fallback)) ? Number(fallback) : 0;
const numericValue = match ? Number(match[0]) : NaN;
const lowerBound = Number.isFinite(Number(min)) ? Number(min) : -Infinity;
const upperBound = Number.isFinite(Number(max)) ? Number(max) : Infinity;
const resolvedValue = Number.isFinite(numericValue) ? numericValue : fallbackValue;
const clampedValue = Math.min(Math.max(resolvedValue, lowerBound), Math.max(lowerBound, upperBound));
return integer ? Math.round(clampedValue) : clampedValue;
}
export function FileSheetValueInput({
value,
onValueCommit,
disabled = false,
ariaLabel,
inputMode = "decimal",
className,
style
}) {
const inputRef = useRef(null);
const displayValue = String(value ?? "");
const [draftValue, setDraftValue] = useState(displayValue);
const [editing, setEditing] = useState(false);
const skipCommitRef = useRef(false);
const selectOnEditRef = useRef(false);
const visibleValue = editing ? draftValue : displayValue;
useEffect(() => {
if (!editing) {
setDraftValue(displayValue);
}
}, [displayValue, editing]);
useEffect(() => {
if (!editing || !selectOnEditRef.current) {
return;
}
selectOnEditRef.current = false;
inputRef.current?.select?.();
}, [editing, visibleValue]);
const selectInputValue = (input) => {
input?.select?.();
if (typeof window !== "undefined") {
window.requestAnimationFrame?.(() => input?.select?.());
}
};
return (
<input
ref={inputRef}
type="text"
inputMode={inputMode}
value={visibleValue}
disabled={disabled}
data-editing={editing ? "true" : "false"}
onChange={(event) => {
setDraftValue(event.target.value);
}}
onFocus={(event) => {
selectOnEditRef.current = true;
setEditing(true);
selectInputValue(event.currentTarget);
}}
onClick={(event) => {
selectOnEditRef.current = true;
setEditing(true);
selectInputValue(event.currentTarget);
}}
onMouseUp={(event) => {
event.preventDefault();
}}
onBlur={() => {
setEditing(false);
if (skipCommitRef.current) {
skipCommitRef.current = false;
return;
}
onValueCommit?.(draftValue);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
skipCommitRef.current = true;
setDraftValue(displayValue);
event.currentTarget.blur();
}
}}
className={cn(
FILE_SHEET_VALUE_BADGE_INPUT_CLASSES,
className
)}
style={{
borderColor: editing ? "var(--ring)" : undefined,
...style
}}
aria-label={ariaLabel}
/>
);
}
export function FileSheetSliderField({
label,
value,
trailing,
onValueCommit,
valueInputProps,
children,
className,
contentClassName,
labelClassName
}) {
const valueTrailing = trailing ?? (onValueCommit ? (
<FileSheetValueInput
value={value}
onValueCommit={onValueCommit}
{...valueInputProps}
/>
) : null);
return (
<FileSheetControlRow
label={valueTrailing ? null : label}
value={null}
className={cn(FILE_SHEET_SLIDER_FIELD_CLASSES, className)}
contentClassName={cn(valueTrailing ? "space-y-0" : "space-y-1", contentClassName)}
labelClassName={labelClassName}
rowKind="slider"
>
{valueTrailing ? (
<div
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2"
data-file-sheet-slider-input-row=""
>
<div className="min-w-0 pr-3">
{label != null ? (
<span
className={cn(
FILE_SHEET_FIELD_LABEL_CLASSES,
"block h-3 leading-3",
labelClassName
)}
>
{label}
</span>
) : null}
<div className={cn("min-w-0", label != null && "-mt-0.5")}>
{children}
</div>
</div>
{valueTrailing}
</div>
) : children}
</FileSheetControlRow>
);
}
export function FileSheetInlineControlRow({
label,
description,
children,
className,
labelClassName
}) {
return (
<div
className={cn(FILE_SHEET_INLINE_CONTROL_ROW_CLASSES, className)}
data-file-sheet-control-row=""
data-file-sheet-row-kind="inline"
>
<div className="flex min-h-7 max-w-full items-center justify-between gap-3">
<span className={cn(FILE_SHEET_FIELD_LABEL_CLASSES, labelClassName)}>{label}</span>
<span className="shrink-0">{children}</span>
</div>
{description ? (
<p className="mt-0.5 max-w-[28rem] text-[11px] leading-4 text-muted-foreground">{description}</p>
) : null}
</div>
);
}
export function FileSheetBooleanToggle({
checked,
onCheckedChange,
disabled = false,
ariaLabel,
className
}) {
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={onCheckedChange}
className={cn(FILE_SHEET_BOOLEAN_SWITCH_CLASSES, className)}
thumbClassName={FILE_SHEET_BOOLEAN_SWITCH_THUMB_CLASSES}
aria-label={ariaLabel}
/>
);
}
export function FileSheetToggleRow({
label,
checked,
onCheckedChange,
disabled = false,
description,
className,
labelClassName,
ariaLabel
}) {
return (
<FileSheetInlineControlRow
label={label}
description={description}
className={className}
labelClassName={labelClassName}
>
<FileSheetBooleanToggle
checked={checked}
onCheckedChange={onCheckedChange}
disabled={disabled}
ariaLabel={ariaLabel || label}
/>
</FileSheetInlineControlRow>
);
}
function normalizeFileSheetWidth(width) {
const numericWidth = Number(width);
if (!Number.isFinite(numericWidth) || numericWidth <= 0) {
return DEFAULT_FILE_SHEET_WIDTH;
}
return Math.max(DESKTOP_FILE_SHEET_MIN_WIDTH, numericWidth);
}
export default function FileSheet({
open,
title,
isDesktop,
width,
onOpenChange,
onStartResize,
bodyClassName,
children
}) {
const desktopWidth = `min(${normalizeFileSheetWidth(width)}px, ${DESKTOP_FILE_SHEET_MAX_WIDTH})`;
const sheetStyle = isDesktop
? {
width: desktopWidth,
flexBasis: desktopWidth,
minWidth: `min(${DESKTOP_FILE_SHEET_MIN_WIDTH}px, ${DESKTOP_FILE_SHEET_MAX_WIDTH})`,
maxWidth: DESKTOP_FILE_SHEET_MAX_WIDTH
}
: {
width: MOBILE_FILE_SHEET_WIDTH,
maxWidth: DESKTOP_FILE_SHEET_MAX_WIDTH
};
const sheetBody = (
<ScrollArea
className={cn("min-h-0 flex-1", FILE_SHEET_CONTROL_TEXT_CLASSES, bodyClassName)}
type="auto"
viewportClassName="h-full"
>
{children}
</ScrollArea>
);
if (!isDesktop) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
showCloseButton={false}
className="cad-glass-surface gap-0 p-0 text-sidebar-foreground"
style={sheetStyle}
aria-label={title}
>
<SheetHeader className="sr-only">
<SheetTitle>{title}</SheetTitle>
<SheetDescription>Inspect and adjust the selected file.</SheetDescription>
</SheetHeader>
{sheetBody}
</SheetContent>
</Sheet>
);
}
if (!open) {
return null;
}
return (
<aside
className={cn(
"cad-glass-surface pointer-events-auto z-30 flex h-full max-w-[calc(100vw_-_0.75rem)] flex-col border-l border-sidebar-border text-sidebar-foreground",
isDesktop
? "relative shrink-0"
: "absolute inset-y-0 right-0 shadow-xl"
)}
style={sheetStyle}
aria-label={title}
>
{isDesktop && typeof onStartResize === "function" ? (
<button
type="button"
aria-label={`Resize ${title} sidebar`}
title="Resize sidebar"
onPointerDown={onStartResize}
className="group/file-sheet-resize absolute inset-y-0 -left-1.5 z-30 flex h-auto w-3 cursor-col-resize touch-none items-stretch justify-center rounded-none px-0 py-0 hover:bg-transparent"
>
<span className="my-2 w-px rounded-full bg-transparent transition-colors group-hover/file-sheet-resize:bg-sidebar-border group-focus-visible/file-sheet-resize:bg-ring" />
</button>
) : null}
{sheetBody}
</aside>
);
}
@@ -0,0 +1,207 @@
import { useEffect, useState } from "react";
import { Check, Copy } from "lucide-react";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { copyTextToClipboard } from "@/ui/clipboard";
import { cn } from "@/ui/utils";
import {
FILE_STATUS_LEVELS,
formatFileStatusItemForAgent,
fileStatusLevelLabel,
fileStatusWarningOrErrorItems,
mostIntenseFileStatusLevel
} from "@/workbench/fileStatusItems";
import {
FileSheetSection
} from "./FileSheet";
const LEVEL_RENDER_META = Object.freeze({
[FILE_STATUS_LEVELS.ERROR]: {
badgeVariant: "destructive-outline",
borderClassName: "border-destructive/70"
},
[FILE_STATUS_LEVELS.WARNING]: {
badgeVariant: "warning-outline",
borderClassName: "border-amber-500/70"
},
[FILE_STATUS_LEVELS.INFO]: {
badgeVariant: "outline",
borderClassName: "border-border"
}
});
const STATUS_COUNT_BADGE_CLASSES = "h-4 min-w-4 self-center rounded-sm px-1 text-[10px] font-medium leading-none";
const STATUS_LEVEL_BADGE_CLASSES = "h-4 self-center rounded-sm px-1.5 py-0 text-[10px] font-medium leading-none";
function renderMetaForLevel(level) {
return LEVEL_RENDER_META[level] || LEVEL_RENDER_META[FILE_STATUS_LEVELS.INFO];
}
export default function FileStatusSection({
items = [],
value = "status"
}) {
const [expandedItemIds, setExpandedItemIds] = useState(() => new Set());
const [copiedItemId, setCopiedItemId] = useState("");
const statusItems = fileStatusWarningOrErrorItems(items);
useEffect(() => {
if (!copiedItemId) {
return undefined;
}
const timeout = globalThis.setTimeout(() => setCopiedItemId(""), 1400);
return () => globalThis.clearTimeout(timeout);
}, [copiedItemId]);
if (!statusItems.length) {
return null;
}
const toggleExpanded = (itemId) => {
setExpandedItemIds((current) => {
const next = new Set(current);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
};
const collapseExpandedItem = (itemId) => {
setExpandedItemIds((current) => {
if (!current.has(itemId)) {
return current;
}
const next = new Set(current);
next.delete(itemId);
return next;
});
};
const handleRowKeyDown = (event, itemId, hasDetails) => {
if (!hasDetails || (event.key !== "Enter" && event.key !== " ")) {
return;
}
event.preventDefault();
toggleExpanded(itemId);
};
const handleCopyIssue = async (event, item) => {
event.preventDefault();
event.stopPropagation();
try {
await copyTextToClipboard(formatFileStatusItemForAgent(item));
setCopiedItemId(item.id);
} catch {
setCopiedItemId("");
}
};
const highestLevel = mostIntenseFileStatusLevel(statusItems);
const headerMeta = renderMetaForLevel(highestLevel);
return (
<FileSheetSection
value={value}
title={(
<span className="flex min-w-0 items-center gap-2">
<span>Issues</span>
<Badge variant={headerMeta.badgeVariant} className={STATUS_COUNT_BADGE_CLASSES}>{statusItems.length}</Badge>
</span>
)}
>
<ul className="space-y-3 px-3 py-3 text-xs leading-5">
{statusItems.map((item) => {
const itemMeta = renderMetaForLevel(item.level);
const details = Array.isArray(item.details) ? item.details : [];
const hasDetails = details.length > 0;
const expanded = expandedItemIds.has(item.id);
return (
<li
key={item.id}
className={cn("min-w-0 border-l-2", itemMeta.borderClassName)}
>
<div
role={hasDetails ? "button" : undefined}
tabIndex={hasDetails ? 0 : undefined}
className={cn(
"block min-w-0 rounded-md py-1 pl-2.5 pr-1 text-left",
hasDetails && "w-full cursor-pointer hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
!hasDetails && "w-full"
)}
onClick={() => {
if (hasDetails) {
toggleExpanded(item.id);
}
}}
onKeyDown={(event) => handleRowKeyDown(event, item.id, hasDetails)}
aria-expanded={hasDetails ? expanded : undefined}
>
<div className="flex min-w-0 items-center gap-2">
<Badge variant={itemMeta.badgeVariant} className={cn("shrink-0", STATUS_LEVEL_BADGE_CLASSES)}>
{fileStatusLevelLabel(item.level)}
</Badge>
<div className="min-w-0 flex-1 truncate font-medium leading-4 text-sidebar-foreground" title={item.title}>{item.title}</div>
<span className="ml-auto inline-flex h-5 shrink-0 items-center">
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 rounded-sm text-muted-foreground hover:text-sidebar-foreground"
onClick={(event) => handleCopyIssue(event, item)}
aria-label={copiedItemId === item.id ? "Copied issue" : "Copy issue for agent"}
title={copiedItemId === item.id ? "Copied" : "Copy issue for agent"}
>
{copiedItemId === item.id ? (
<Check className="size-3" strokeWidth={2.25} aria-hidden="true" />
) : (
<Copy className="size-3" strokeWidth={2.1} aria-hidden="true" />
)}
</Button>
</span>
</div>
{item.message ? (
<div className="mt-1 line-clamp-2 min-w-0 break-words text-[11px] font-normal leading-4 text-muted-foreground">
{item.message}
</div>
) : null}
</div>
{hasDetails && expanded ? (
<div className="mt-2 space-y-2 pl-2.5">
<dl className="space-y-1 text-[11px] leading-4">
{details.map((detailItem, index) => (
<div key={`${item.id}:${detailItem.label}:${index}`} className="min-w-0">
<dt className="text-muted-foreground">{detailItem.label}</dt>
<dd className={cn(
"min-w-0 break-words text-sidebar-foreground",
detailItem.mono && "break-all font-mono text-[10px] leading-4"
)}>
{detailItem.value}
</dd>
</div>
))}
</dl>
<Button
type="button"
variant="ghost"
size="xs"
className="h-auto rounded-sm px-0 py-0 text-[11px] font-medium text-muted-foreground hover:bg-transparent hover:text-sidebar-foreground"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
collapseExpandedItem(item.id);
}}
>
Collapse
</Button>
</div>
) : null}
</li>
);
})}
</ul>
</FileSheetSection>
);
}
@@ -0,0 +1,734 @@
import {
ArrowUpFromLine,
Bot,
Boxes,
ChevronDown,
ChevronRight,
Code,
Cuboid,
DraftingCompass,
FileBox,
FolderOpen,
Layers3,
LoaderCircle,
Package,
Route
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader as SheetHeaderPrimitive,
SheetTitle
} from "@/components/ui/sheet";
import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInput,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
useSidebar
} from "@/components/ui/sidebar";
import { cn } from "@/ui/utils";
import {
ENTRY_ICON_KIND,
entryIconKind
} from "@/workbench/entryIconKind";
import {
entryIconStatus,
entryStepSourceKind
} from "@/workbench/entryIconStatus";
import {
fileKey,
listSidebarItems,
sidebarLabelForEntry
} from "@/workbench/sidebar";
import FileAccessContextMenu from "./FileAccessContextMenu";
const DESKTOP_FILE_VIEWER_MIN_WIDTH = 150;
const DESKTOP_FILE_VIEWER_MAX_WIDTH = "calc(100vw - 0.75rem)";
const MOBILE_FILE_VIEWER_WIDTH = "min(18rem, calc(100vw - 0.75rem))";
const ENTRY_ICON_COMPONENTS = {
[ENTRY_ICON_KIND.LOADING]: LoaderCircle,
[ENTRY_ICON_KIND.ASSEMBLY]: Boxes,
[ENTRY_ICON_KIND.DXF]: DraftingCompass,
[ENTRY_ICON_KIND.GCODE]: Route,
[ENTRY_ICON_KIND.IMPLICIT]: Code,
[ENTRY_ICON_KIND.ROBOT]: Bot,
[ENTRY_ICON_KIND.STEP_PART]: Package,
[ENTRY_ICON_KIND.STL_MESH]: Cuboid,
[ENTRY_ICON_KIND.THREE_MF_MESH]: Layers3,
[ENTRY_ICON_KIND.GLB_MESH]: FileBox
};
function iconForEntry(entry, sourceFormat, status) {
return ENTRY_ICON_COMPONENTS[entryIconKind(entry, { sourceFormat, status })] || Package;
}
function FileEntryButton({
entry,
depth,
selectedKey,
onSelectEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
nested = false
}) {
const { isMobile, setOpenMobile } = useSidebar();
const key = fileKey(entry);
const active = key === selectedKey;
const label = sidebarLabelForEntry(entry);
const sourceFormat = entrySourceFormat(entry);
const status = entryIconStatus(entry, {
sourceFormat,
entryKey: key,
hasMesh: entryHasMesh(entry),
hasDxf: entryHasDxf(entry),
hasGcode: entryHasGcode(entry),
hasUrdf: entryHasUrdf(entry),
activeGenerationFiles,
activeStepArtifactGenerationFile,
stepArtifactGenerationAvailable
});
const EntryIcon = iconForEntry(entry, sourceFormat, status);
const stepSourceKind = entryStepSourceKind(entry);
const SourceBadgeIcon = stepSourceKind === "python"
? Code
: stepSourceKind === "step"
? ArrowUpFromLine
: null;
const showSourceBadge = Boolean(SourceBadgeIcon);
const title = [
label,
stepSourceKind === "python" ? "Python-backed" : "",
stepSourceKind === "step" ? "STEP-backed" : "",
status.statusLabel,
entry?.kind,
String(entry?.file || "")
].filter(Boolean).join(" | ");
const button = (
<SidebarMenuButton
type="button"
isActive={active}
size="sm"
title={title}
className={cn(
"min-w-0 w-full justify-start"
)}
onClick={() => {
onSelectEntry(key);
if (isMobile) {
setOpenMobile(false);
}
}}
tooltip={label}
>
<span className="relative flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
<EntryIcon
className={cn(
"size-4",
status.loading && "animate-spin"
)}
/>
{showSourceBadge ? (
<span className="absolute -bottom-1 -right-1 flex size-2.5 items-center justify-center rounded-[3px] border border-sidebar bg-sidebar text-sidebar-foreground shadow-sm">
<SourceBadgeIcon className="size-2" strokeWidth={2.5} />
</span>
) : null}
</span>
<span className="block min-w-0 flex-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
</SidebarMenuButton>
);
return (
<FileAccessContextMenu
entry={entry}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
busyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
>
{button}
</FileAccessContextMenu>
);
}
function DirectoryNode({
directory,
depth,
queryActive,
expandedDirectoryIds,
onToggleDirectory,
selectedKey,
onSelectEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
nested = false
}) {
const expanded = queryActive || expandedDirectoryIds.has(directory.id);
const DirectoryItem = nested ? SidebarMenuSubItem : SidebarMenuItem;
return (
<Collapsible asChild open={expanded}>
<DirectoryItem className="min-w-0 w-full max-w-full">
<CollapsibleTrigger asChild>
<SidebarMenuButton
type="button"
size="sm"
title={directory.name}
aria-disabled={queryActive}
className={cn(
"group/directory min-w-0 w-full justify-start",
queryActive && "cursor-default"
)}
onClick={(event) => {
if (queryActive) {
event.preventDefault();
return;
}
onToggleDirectory(directory.id);
}}
>
<ChevronRight
className={cn(
"transition-transform",
expanded && "rotate-90"
)}
aria-hidden="true"
/>
<span className="block min-w-0 flex-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap">{directory.name}</span>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className="min-w-0 w-full max-w-full">
<SidebarMenuSub className="min-w-0 w-full max-w-full">
{listSidebarItems(directory).map((item) => {
if (item.type === "directory") {
return (
<DirectoryNode
key={item.key}
directory={item.value}
depth={depth + 1}
queryActive={queryActive}
expandedDirectoryIds={expandedDirectoryIds}
onToggleDirectory={onToggleDirectory}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
nested={true}
/>
);
}
return (
<SidebarMenuSubItem key={item.key} className="min-w-0 w-full max-w-full">
<FileEntryButton
entry={item.value}
depth={depth + 1}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
nested={true}
/>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</DirectoryItem>
</Collapsible>
);
}
function SidebarResizeHandle({ onStartResize }) {
const { isMobile, state } = useSidebar();
if (isMobile || state !== "expanded" || typeof onStartResize !== "function") {
return null;
}
return (
<Button
type="button"
variant="ghost"
size="sm"
aria-label="Resize file viewer sidebar"
title="Resize sidebar"
onPointerDown={onStartResize}
className="group/sidebar-resize absolute inset-y-0 -right-1.5 z-30 flex h-auto w-3 cursor-col-resize touch-none items-stretch justify-center rounded-none px-0 py-0 hover:bg-transparent"
>
<span className="my-2 w-px rounded-full bg-transparent transition-colors group-hover/sidebar-resize:bg-sidebar-border group-focus-visible/sidebar-resize:bg-ring" />
</Button>
);
}
function directoryLabelForOption(option) {
const rootName = String(option?.rootName || "").trim();
if (rootName) {
return rootName;
}
const pathLabel = String(option?.rootPath || option?.dir || "").trim().replace(/\\/g, "/").replace(/\/+$/g, "");
return pathLabel.split("/").filter(Boolean).pop() || pathLabel || "Directory";
}
function directoryPathLabelForOption(option) {
return String(option?.rootPath || option?.dir || "").trim();
}
function normalizeDirectoryOptions(options) {
const seen = new Set();
const result = [];
for (const option of Array.isArray(options) ? options : []) {
const dir = String(option?.dir || "").trim();
const rootPath = String(option?.rootPath || "").trim();
const key = rootPath || dir;
if (!dir || !key || seen.has(key)) {
continue;
}
seen.add(key);
result.push({
dir,
rootPath,
rootName: String(option?.rootName || "").trim()
});
}
return result;
}
function DirectorySwitcher({
directoryOptions = [],
activeDirectory = "",
onSelectDirectory
}) {
const options = normalizeDirectoryOptions(directoryOptions);
if (options.length <= 1) {
return null;
}
const activeDir = String(activeDirectory || "").trim();
const activeOption = options.find((option) => option.dir === activeDir || option.rootPath === activeDir) || options[0];
const activeLabel = directoryLabelForOption(activeOption);
const activePathLabel = directoryPathLabelForOption(activeOption);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full justify-between gap-2 rounded-md p-2 text-xs font-medium text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
title={activePathLabel || activeLabel}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden="true">
<FolderOpen className="size-4 text-muted-foreground" />
</span>
<span className="min-w-0 truncate">{activeLabel}</span>
</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[--radix-dropdown-menu-trigger-width] max-w-[min(28rem,calc(100vw-1rem))]">
{options.map((option) => {
const label = directoryLabelForOption(option);
const pathLabel = directoryPathLabelForOption(option);
const active = option.dir === activeOption.dir || option.rootPath === activeOption.rootPath;
return (
<DropdownMenuItem
key={option.rootPath || option.dir}
className={cn(
"min-w-0 items-start gap-2 text-xs",
active && "bg-accent text-accent-foreground"
)}
onSelect={() => {
if (typeof onSelectDirectory === "function" && option.dir !== activeOption.dir) {
onSelectDirectory(option.dir);
}
}}
title={pathLabel || label}
>
<FolderOpen className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{label}</span>
{pathLabel ? (
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">{pathLabel}</span>
) : null}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function FileViewerContents({
query,
onQueryChange,
filteredEntries,
catalogEntries,
filteredEntriesTree,
selectedKey,
expandedDirectoryIds,
onToggleDirectory,
onSelectEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
catalogHydrated = false,
catalogRefreshing = false,
catalogError = "",
directoryOptions = [],
activeDirectory = "",
onSelectDirectory,
resizable = true,
onStartResize
}) {
const queryActive = query.trim().length > 0;
const hasMatches = filteredEntries.length > 0;
const hasEntries = catalogEntries.length > 0;
const catalogErrorMessage = String(catalogError || "").trim();
const catalogLoading = !catalogHydrated || (catalogRefreshing && !hasEntries);
return (
<>
<SidebarHeader className="gap-2">
<DirectorySwitcher
directoryOptions={directoryOptions}
activeDirectory={activeDirectory}
onSelectDirectory={onSelectDirectory}
/>
<SidebarInput
type="search"
placeholder="Search files, ids, or paths..."
value={query}
onChange={(event) => onQueryChange(event.target.value)}
aria-label="Search CAD files"
className="h-7 text-xs md:text-xs"
/>
</SidebarHeader>
<SidebarContent>
<ScrollArea className="cad-file-viewer-scroll min-h-0 min-w-0 flex-1 overflow-x-hidden" type="auto">
<SidebarGroup>
<SidebarGroupContent>
{hasMatches ? (
<SidebarMenu>
{listSidebarItems(filteredEntriesTree).map((item) => {
if (item.type === "directory") {
return (
<DirectoryNode
key={item.key}
directory={item.value}
depth={0}
queryActive={queryActive}
expandedDirectoryIds={expandedDirectoryIds}
onToggleDirectory={onToggleDirectory}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
);
}
return (
<SidebarMenuItem key={item.key} className="min-w-0 w-full max-w-full">
<FileEntryButton
entry={item.value}
depth={0}
selectedKey={selectedKey}
onSelectEntry={onSelectEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
/>
</SidebarMenuItem>
);
})}
</SidebarMenu>
) : catalogErrorMessage && !hasEntries ? (
<p className="px-2 py-3 text-xs text-muted-foreground">CAD catalog unavailable: {catalogErrorMessage}</p>
) : catalogLoading ? (
<p className="px-2 py-3 text-xs text-muted-foreground">Loading CAD catalog...</p>
) : hasEntries ? (
<p className="px-2 py-3 text-xs text-muted-foreground">No CAD entries match this filter.</p>
) : (
<p className="px-2 py-3 text-xs text-muted-foreground">No CAD entries found.</p>
)}
</SidebarGroupContent>
</SidebarGroup>
</ScrollArea>
</SidebarContent>
<SidebarResizeHandle onStartResize={resizable ? onStartResize : null} />
</>
);
}
export default function FileViewerSidebar({
previewMode,
query,
onQueryChange,
filteredEntries,
catalogEntries,
filteredEntriesTree,
selectedKey,
expandedDirectoryIds,
onToggleDirectory,
onSelectEntry,
entrySourceFormat,
entryHasMesh,
entryHasDxf,
entryHasGcode,
entryHasUrdf,
activeGenerationFiles = [],
activeStepArtifactGenerationFile = "",
stepArtifactGenerationAvailable = true,
canRevealFileAssets = false,
canCopyFileAssetLinks = false,
canCopyFileAssetPaths = false,
fileAccessBusyKey = "",
onDownloadFileAsset,
onExportImplicitFile,
onRevealFileAsset,
onRevealInExplorerView,
onCopyFileAssetReference,
catalogHydrated = false,
catalogRefreshing = false,
catalogError = "",
directoryOptions = [],
activeDirectory = "",
onSelectDirectory,
resizable = true,
onStartResize
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (previewMode) {
return null;
}
const content = (
<FileViewerContents
query={query}
onQueryChange={onQueryChange}
filteredEntries={filteredEntries}
catalogEntries={catalogEntries}
filteredEntriesTree={filteredEntriesTree}
selectedKey={selectedKey}
expandedDirectoryIds={expandedDirectoryIds}
onToggleDirectory={onToggleDirectory}
onSelectEntry={onSelectEntry}
entrySourceFormat={entrySourceFormat}
entryHasMesh={entryHasMesh}
entryHasDxf={entryHasDxf}
entryHasGcode={entryHasGcode}
entryHasUrdf={entryHasUrdf}
activeGenerationFiles={activeGenerationFiles}
activeStepArtifactGenerationFile={activeStepArtifactGenerationFile}
stepArtifactGenerationAvailable={stepArtifactGenerationAvailable}
canRevealFileAssets={canRevealFileAssets}
canCopyFileAssetLinks={canCopyFileAssetLinks}
canCopyFileAssetPaths={canCopyFileAssetPaths}
fileAccessBusyKey={fileAccessBusyKey}
onDownloadFileAsset={onDownloadFileAsset}
onExportImplicitFile={onExportImplicitFile}
onRevealFileAsset={onRevealFileAsset}
onRevealInExplorerView={onRevealInExplorerView}
onCopyFileAssetReference={onCopyFileAssetReference}
catalogHydrated={catalogHydrated}
catalogRefreshing={catalogRefreshing}
catalogError={catalogError}
directoryOptions={directoryOptions}
activeDirectory={activeDirectory}
onSelectDirectory={onSelectDirectory}
resizable={resizable}
onStartResize={onStartResize}
/>
);
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
<SheetContent
side="left"
showCloseButton={false}
className="cad-glass-surface gap-0 p-0 text-sidebar-foreground"
style={{
width: MOBILE_FILE_VIEWER_WIDTH,
maxWidth: DESKTOP_FILE_VIEWER_MAX_WIDTH
}}
>
<SheetHeaderPrimitive className="sr-only">
<SheetTitle>CAD Viewer</SheetTitle>
<SheetDescription>Browse files in the CAD catalog.</SheetDescription>
</SheetHeaderPrimitive>
<div className="flex h-full min-h-0 w-full flex-col" aria-label="CAD Viewer">
{content}
</div>
</SheetContent>
</Sheet>
);
}
if (state !== "expanded") {
return null;
}
const desktopWidth = `min(var(--sidebar-width), ${DESKTOP_FILE_VIEWER_MAX_WIDTH})`;
const sidebarStyle = isMobile
? {
width: MOBILE_FILE_VIEWER_WIDTH,
maxWidth: DESKTOP_FILE_VIEWER_MAX_WIDTH
}
: {
width: desktopWidth,
flexBasis: desktopWidth,
minWidth: `min(${DESKTOP_FILE_VIEWER_MIN_WIDTH}px, ${DESKTOP_FILE_VIEWER_MAX_WIDTH})`,
maxWidth: DESKTOP_FILE_VIEWER_MAX_WIDTH
};
return (
<aside
className={cn(
"cad-glass-surface pointer-events-auto z-30 flex h-full max-w-[calc(100vw_-_0.75rem)] flex-col border-r border-sidebar-border text-sidebar-foreground",
isMobile
? "absolute inset-y-0 left-0 shadow-xl"
: "relative shrink-0"
)}
style={sidebarStyle}
aria-label="CAD Viewer"
>
{content}
</aside>
);
}
@@ -0,0 +1,209 @@
import { useState } from "react";
import {
Crosshair,
Focus,
MousePointer2,
Orbit,
Pause,
Play,
PenTool
} from "lucide-react";
import { RENDER_FORMAT } from "@/workbench/constants";
import {
isMeshRenderFormat,
isRobotRenderFormat
} from "cadjs/lib/fileFormats";
import { TooltipProvider } from "../ui/tooltip";
import DrawingToolbar from "./DrawingToolbar";
import { ToolbarButton } from "./ToolbarButton";
import { CAD_WORKSPACE_TOOLBAR_DESKTOP_WIDTH_CLASS } from "./ToolbarShell";
import { DisplayProjectionControl } from "../viewer/DisplayProjectionControl";
const FLOATING_TOOL_BAR_SURFACE_CLASS =
"cad-glass-surface border border-sidebar-border text-sidebar-foreground shadow-sm";
const FLOATING_TOOL_BAR_BUTTON_CLASSES =
"grid size-6 shrink-0 place-items-center rounded-sm text-sidebar-foreground/70 shadow-none transition hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground";
function DesktopFloatingToolBar({
renderFormat,
floatingCadToolbarPosition,
selectionToolActive,
referenceSelectionPending = false,
referenceSelectionUnavailable = false,
referenceSelectionDeferred = false,
urdfPosePickerAvailable = false,
urdfPosePickerActive = false,
handleToggleUrdfPosePicker,
stepAnimationAvailable = false,
stepAnimationPlaying = false,
stepAnimationDisabled = false,
handleStepAnimationPlayToggle,
drawToolActive,
handleSelectTabToolMode,
displayMode,
onDisplayModeChange,
projection,
onProjectionChange,
viewerLoading,
selectedMeshData,
selectedDxfData,
selectedImplicitModel,
drawingToolOptions,
drawingTool,
handleSelectDrawingTool,
handleUndoDrawing,
handleRedoDrawing,
handleClearDrawings,
canUndoDrawing,
canRedoDrawing,
drawingStrokes,
handleEnterPreviewMode,
handleScreenshotCopy
}) {
const [displayMenuOpen, setDisplayMenuOpen] = useState(false);
const dxfMode = renderFormat === RENDER_FORMAT.DXF;
const implicitMode = renderFormat === RENDER_FORMAT.IMPLICIT;
const urdfMode = renderFormat === RENDER_FORMAT.URDF;
const robotMode = isRobotRenderFormat(renderFormat);
const meshOnlyMode = isMeshRenderFormat(renderFormat);
const renderReady = implicitMode ? !!selectedImplicitModel : dxfMode ? !!selectedDxfData : !!selectedMeshData;
const captureDisabled = viewerLoading || !renderReady;
const selectDisabled = viewerLoading ||
!selectedMeshData ||
referenceSelectionPending ||
referenceSelectionUnavailable ||
referenceSelectionDeferred;
const posePickerDisabled = viewerLoading || !selectedMeshData || !urdfPosePickerAvailable;
const selectLabel = referenceSelectionPending ? "Preparing selection" : "Select";
const showStepAnimationPlay = renderFormat === RENDER_FORMAT.STEP && stepAnimationAvailable;
const stepAnimationPlayDisabled = viewerLoading || !selectedMeshData || stepAnimationDisabled;
const stepAnimationLabel = stepAnimationPlaying ? "Pause" : "Play";
const displayControlAvailable = renderFormat === RENDER_FORMAT.STEP &&
typeof onDisplayModeChange === "function" &&
typeof onProjectionChange === "function";
return (
<div
className="absolute z-20 flex flex-col items-end gap-1"
style={floatingCadToolbarPosition}
>
<TooltipProvider delayDuration={250}>
<div className={`pointer-events-auto inline-flex h-8 w-fit items-center gap-0.5 self-end rounded-md p-1 ${FLOATING_TOOL_BAR_SURFACE_CLASS}`}>
{!dxfMode && !implicitMode && !robotMode && !meshOnlyMode ? (
<>
<ToolbarButton
label={selectLabel}
active={referenceSelectionDeferred ? false : selectionToolActive}
onClick={() => handleSelectTabToolMode("references")}
disabled={selectDisabled}
aria-pressed={referenceSelectionDeferred ? false : selectionToolActive}
>
<MousePointer2 className="size-3" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
label="Draw"
active={drawToolActive}
onClick={() => handleSelectTabToolMode("draw")}
disabled={viewerLoading || !selectedMeshData}
aria-pressed={drawToolActive}
>
<PenTool className="size-3" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
{displayControlAvailable ? (
<DisplayProjectionControl
displayMode={displayMode}
onDisplayModeChange={onDisplayModeChange}
projection={projection}
onProjectionChange={onProjectionChange}
open={displayMenuOpen}
onOpenChange={setDisplayMenuOpen}
triggerClassName={FLOATING_TOOL_BAR_BUTTON_CLASSES}
contentAlign="end"
contentSide="bottom"
contentSideOffset={6}
/>
) : null}
{showStepAnimationPlay ? (
<ToolbarButton
label={stepAnimationLabel}
active={stepAnimationPlaying}
onClick={handleStepAnimationPlayToggle}
disabled={stepAnimationPlayDisabled}
aria-pressed={stepAnimationPlaying}
>
{stepAnimationPlaying ? (
<Pause className="size-3" strokeWidth={2} aria-hidden="true" />
) : (
<Play className="size-3" strokeWidth={2} aria-hidden="true" />
)}
</ToolbarButton>
) : null}
</>
) : null}
{!dxfMode && urdfMode ? (
<ToolbarButton
label="Select Pose"
active={urdfPosePickerActive}
onClick={handleToggleUrdfPosePicker}
disabled={posePickerDisabled}
aria-pressed={urdfPosePickerActive}
>
<Crosshair className="size-3" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
) : null}
{!dxfMode ? (
<ToolbarButton
label="Orbit"
onClick={handleEnterPreviewMode}
disabled={viewerLoading || !renderReady}
>
<Orbit className="size-3" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
) : null}
<ToolbarButton
label="Copy screenshot"
onClick={() => {
void handleScreenshotCopy();
}}
disabled={captureDisabled}
>
<Focus className="size-3" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
</div>
</TooltipProvider>
{!dxfMode && !meshOnlyMode && drawToolActive ? (
<DrawingToolbar
className={CAD_WORKSPACE_TOOLBAR_DESKTOP_WIDTH_CLASS}
drawingToolOptions={drawingToolOptions}
drawingTool={drawingTool}
handleSelectDrawingTool={handleSelectDrawingTool}
handleUndoDrawing={handleUndoDrawing}
handleRedoDrawing={handleRedoDrawing}
handleClearDrawings={handleClearDrawings}
canUndoDrawing={canUndoDrawing}
canRedoDrawing={canRedoDrawing}
drawingStrokes={drawingStrokes}
/>
) : null}
</div>
);
}
export default function FloatingToolBar({
previewMode,
selectedEntry,
...toolbarProps
}) {
if (previewMode || !selectedEntry) {
return null;
}
return <DesktopFloatingToolBar {...toolbarProps} />;
}
@@ -0,0 +1,428 @@
import FileSheet, {
FILE_SHEET_FIELD_LABEL_CLASSES,
FILE_SHEET_PRECISION_SLIDER_CLASSES,
FileSheetSection,
FileSheetSliderField,
FileSheetSubsection,
FileSheetToggleRow,
parseFileSheetNumberInput
} from "./FileSheet";
import FileMetadataSection from "./FileMetadataSection";
import {
Accordion
} from "../ui/accordion";
import { Badge } from "../ui/badge";
import { Slider } from "../ui/slider";
import {
DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL,
GCODE_PREVIEW_DETAIL_MAX,
GCODE_PREVIEW_DETAIL_MIN
} from "cadjs/lib/gcode/buildPreviewMesh";
import FileStatusSection from "./FileStatusSection";
const fieldLabelClasses = FILE_SHEET_FIELD_LABEL_CLASSES;
const compactNumberBadgeClasses = "h-4 rounded-sm px-1.5 py-0 text-[10px] font-medium leading-none";
function formatNumber(value, digits = 2) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue.toFixed(digits) : (0).toFixed(digits);
}
function formatCount(value) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? String(Math.round(numericValue)) : "0";
}
function formatOrdinal(value) {
const numericValue = Math.round(Number(value) || 0);
const absoluteValue = Math.abs(numericValue);
const tensRemainder = absoluteValue % 100;
if (tensRemainder >= 11 && tensRemainder <= 13) {
return `${numericValue}th`;
}
switch (absoluteValue % 10) {
case 1:
return `${numericValue}st`;
case 2:
return `${numericValue}nd`;
case 3:
return `${numericValue}rd`;
default:
return `${numericValue}th`;
}
}
function formatDistance(value, digits = 1) {
return `${formatNumber(value, digits)} mm`;
}
function formatCommandCount(value) {
const count = formatCount(value);
return `${count} cmd${count === "1" ? "" : "s"}`;
}
function formatFeatureCategory(value) {
const normalized = String(value || "").trim();
return normalized ? `${normalized[0].toUpperCase()}${normalized.slice(1)}` : "Other";
}
function previewDetailLabel(value) {
switch (Math.round(Number(value) || DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL)) {
case 1:
return "Light";
case 2:
return "Lean";
case 4:
return "Dense";
case 5:
return "High";
case 6:
return "Very high";
case 7:
return "Max adaptive";
case 3:
default:
return "Standard";
}
}
function normalizedBounds(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [0, 0, 0];
return { min, max };
}
function boundsAxisText(bounds, axis, digits = 1) {
const { min, max } = normalizedBounds(bounds);
return `${formatNumber(min[axis], digits)} to ${formatNumber(max[axis], digits)} mm`;
}
function visibleLayerText(layerCount, maxLayer) {
if (layerCount < 1) {
return "0 / 0";
}
const visibleCount = maxLayer + 1;
return visibleCount > 1
? `1-${visibleCount} / ${layerCount}`
: `1 / ${layerCount}`;
}
function parseVisibleLayerInput(value, fallbackVisibleCount, layerCount) {
if (layerCount < 1) {
return 0;
}
const text = String(value ?? "").trim();
const rangeMatch = text.match(/\b\d+\s*-\s*(\d+)\b/);
const parsedValue = rangeMatch?.[1] ?? value;
return parseFileSheetNumberInput(parsedValue, {
fallback: fallbackVisibleCount,
min: 1,
max: Math.max(layerCount, 0),
integer: true
}) - 1;
}
function GcodeValueField({ label, value, mono = false }) {
const displayValue = String(value ?? "");
return (
<div className="block min-w-0">
<span className={fieldLabelClasses}>{label}</span>
<div
className={`mt-1 min-h-7 truncate rounded-md border border-border/70 bg-muted/25 px-2 py-1.5 text-[11px] font-medium leading-4 text-foreground ${mono ? "font-mono" : ""}`}
title={displayValue}
>
{displayValue}
</div>
</div>
);
}
export default function GcodeFileSheet({
open,
isDesktop,
width,
selectedEntry = null,
onOpenChange,
onStartResize,
gcodeData = null,
previewMetadata = null,
maxLayer = 0,
showTravel = false,
fullDetail = false,
previewDetailLevel = DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL,
onMaxLayerChange,
onShowTravelChange,
onFullDetailChange,
onPreviewDetailLevelChange,
fileDownloadAvailable = false,
viewerServerInfo = null,
localFileOpenAvailable = false,
fileAccessBusyKey = "",
onOpenFileAsset,
suppressDynamicMetadataStatus = false,
statusItems = [],
themeSections = null,
openSectionIds = [],
onOpenSectionIdsChange
}) {
const layers = Array.isArray(gcodeData?.layers) ? gcodeData.layers : [];
const stats = gcodeData?.stats || {};
const features = Array.isArray(gcodeData?.features) ? gcodeData.features : [];
const supportFeatures = features.filter((feature) => feature?.category === "support");
const hasSupports = supportFeatures.some((feature) => (
Number(feature?.extrusionMoves) > 0 ||
Number(feature?.markerCount) > 0
));
const featureMarkerCount = features.reduce((total, feature) => total + (Number(feature?.markerCount) || 0), 0);
const renderedSegments = Number(previewMetadata?.renderedSegments);
const visibleSegments = Number(previewMetadata?.visibleSegments);
const layerStride = Number(previewMetadata?.layerStride);
const renderedLayers = Number(previewMetadata?.renderedLayers);
const visiblePreviewLayers = Number(previewMetadata?.visibleLayers);
const decimatedPreview = Boolean(previewMetadata?.decimated);
const renderedPathText = Number.isFinite(renderedSegments) && Number.isFinite(visibleSegments)
? `${formatCount(renderedSegments)} / ${formatCount(visibleSegments)}`
: "";
const renderedLayerText = Number.isFinite(renderedLayers) && Number.isFinite(visiblePreviewLayers)
? `${formatCount(renderedLayers)} / ${formatCount(visiblePreviewLayers)}`
: "";
const layerCount = layers.length;
const safePreviewDetailLevel = Math.min(
Math.max(Math.round(Number(previewDetailLevel) || DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL), GCODE_PREVIEW_DETAIL_MIN),
GCODE_PREVIEW_DETAIL_MAX
);
const safeMaxLayer = layerCount > 0
? Math.min(Math.max(Math.trunc(Number(maxLayer) || 0), 0), layerCount - 1)
: 0;
const hasGcodeData = Boolean(gcodeData);
const visibleLayers = visibleLayerText(layerCount, safeMaxLayer);
return (
<FileSheet
open={open}
title="G-code"
isDesktop={isDesktop}
width={width}
onOpenChange={onOpenChange}
onStartResize={onStartResize}
>
<Accordion
type="multiple"
value={openSectionIds}
onValueChange={onOpenSectionIdsChange}
className="text-sm"
>
<FileStatusSection items={statusItems} />
<FileSheetSection value="toolpath" title="Toolpath">
<div>
{!hasGcodeData ? (
<p className="px-3 py-2 text-xs text-muted-foreground">Loading G-code...</p>
) : null}
<FileSheetSubsection title="Summary" contentClassName="px-3">
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary" className="font-normal">
{formatCount(layerCount)} layer{layerCount === 1 ? "" : "s"}
</Badge>
<Badge variant="outline" className="font-normal">
{formatCount(stats.extrusionMoves)} path{formatCount(stats.extrusionMoves) === "1" ? "" : "s"}
</Badge>
<Badge variant={hasSupports ? "secondary" : "outline"} className="font-normal">
Supports {hasSupports ? "yes" : "no"}
</Badge>
{decimatedPreview ? (
<Badge variant="outline" className="font-normal">
Layer preview
</Badge>
) : null}
</div>
</FileSheetSubsection>
<FileSheetSubsection title="Layers">
<FileSheetSliderField
label="Visible layers"
value={visibleLayers}
onValueCommit={(nextValue) => {
onMaxLayerChange?.(parseVisibleLayerInput(nextValue, safeMaxLayer + 1, layerCount));
}}
valueInputProps={{
disabled: layerCount <= 1,
ariaLabel: "Visible G-code layers value",
inputMode: "numeric"
}}
>
<Slider
value={[safeMaxLayer]}
min={0}
max={Math.max(layerCount - 1, 0)}
step={1}
disabled={layerCount <= 1}
onValueChange={(nextValue) => {
onMaxLayerChange?.(Number(nextValue?.[0] ?? 0));
}}
className={FILE_SHEET_PRECISION_SLIDER_CLASSES}
aria-label="Visible G-code layers"
/>
</FileSheetSliderField>
</FileSheetSubsection>
<FileSheetSubsection
title="Preview"
>
<FileSheetSliderField
label="Adaptive detail"
value={`${previewDetailLabel(safePreviewDetailLevel)} (${safePreviewDetailLevel}/${GCODE_PREVIEW_DETAIL_MAX})`}
onValueCommit={(nextValue) => {
onPreviewDetailLevelChange?.(parseFileSheetNumberInput(nextValue, {
fallback: safePreviewDetailLevel,
min: GCODE_PREVIEW_DETAIL_MIN,
max: GCODE_PREVIEW_DETAIL_MAX,
integer: true
}));
}}
valueInputProps={{
disabled: fullDetail,
ariaLabel: "G-code preview detail value",
inputMode: "numeric"
}}
>
<Slider
value={[safePreviewDetailLevel]}
min={GCODE_PREVIEW_DETAIL_MIN}
max={GCODE_PREVIEW_DETAIL_MAX}
step={1}
disabled={fullDetail}
onValueChange={(nextValue) => {
onPreviewDetailLevelChange?.(Number(nextValue?.[0] ?? DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL));
}}
className={FILE_SHEET_PRECISION_SLIDER_CLASSES}
aria-label="G-code preview detail"
/>
</FileSheetSliderField>
</FileSheetSubsection>
<FileSheetSubsection title="Rendering">
<FileSheetToggleRow
label="Travel moves"
description={`${formatCount(stats.travelMoves)} move${formatCount(stats.travelMoves) === "1" ? "" : "s"}`}
checked={showTravel}
onCheckedChange={(checked) => onShowTravelChange?.(checked)}
ariaLabel="Show G-code travel moves"
/>
<FileSheetToggleRow
label="Full detail"
description={fullDetail
? "Exact: render every visible path."
: renderedPathText
? `${renderedPathText} rendered path${renderedSegments === 1 ? "" : "s"}${decimatedPreview && Number.isFinite(layerStride) ? ` across ${renderedLayerText || "sampled"} layer${renderedLayers === 1 ? "" : "s"}, every ${formatOrdinal(layerStride)}` : ""}`
: "Use adaptive layer sampling."}
checked={fullDetail}
onCheckedChange={(checked) => onFullDetailChange?.(checked)}
ariaLabel="Render full G-code detail"
/>
</FileSheetSubsection>
</div>
</FileSheetSection>
<FileSheetSection
value="features"
title={(
<span className="flex min-w-0 items-center gap-2">
<span>Features</span>
{features.length ? (
<Badge variant="outline" className={compactNumberBadgeClasses}>{features.length}</Badge>
) : null}
{hasSupports ? (
<Badge variant="outline" className="font-normal">Supports</Badge>
) : null}
</span>
)}
>
<div className="space-y-2 px-3 py-2.5">
{!features.length ? (
<p className="text-xs leading-5 text-muted-foreground">
No slicer feature annotations found. The preview can still show layers, extrusion paths, and travel moves.
</p>
) : (
<>
<div className="flex flex-wrap gap-1.5 text-[11px]">
<Badge variant={hasSupports ? "secondary" : "outline"} className="font-normal">
Supports {hasSupports ? "included" : "not detected"}
</Badge>
<Badge variant="outline" className={compactNumberBadgeClasses}>
{formatCount(featureMarkerCount)} markers
</Badge>
</div>
<ul className="divide-y divide-border/60 overflow-hidden rounded-md border border-border/60 bg-muted/15">
{features.map((feature) => {
const extrusionMoves = formatCount(feature?.extrusionMoves);
const markerCount = formatCount(feature?.markerCount);
const detailParts = [
`${extrusionMoves} path${extrusionMoves === "1" ? "" : "s"}`,
formatDistance(feature?.pathMm, 1),
feature?.layerRangeText || "",
markerCount !== "0" ? `${markerCount} marker${markerCount === "1" ? "" : "s"}` : ""
].filter(Boolean);
const rawLabels = Array.isArray(feature?.rawLabels) && feature.rawLabels.length
? feature.rawLabels.join(", ")
: "";
return (
<li
key={feature?.id || feature?.label}
className="min-w-0 px-2.5 py-1.5"
title={rawLabels}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
{feature?.label || "Other"}
</span>
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
{formatFeatureCategory(feature?.category)}
</span>
</div>
<p className="truncate text-[11px] leading-5 text-muted-foreground">
{detailParts.join(" | ")}
</p>
</li>
);
})}
</ul>
</>
)}
</div>
</FileSheetSection>
<FileSheetSection value="stats" title="Stats">
<div className="grid grid-cols-2 gap-2 px-3 py-3">
<GcodeValueField label="Extrusion" value={formatDistance(stats.extrusionMm, 1)} />
<GcodeValueField label="Path length" value={formatDistance(stats.pathMm, 1)} />
<GcodeValueField label="Retracts" value={formatCount(stats.retractMoves)} />
<GcodeValueField label="Temperature" value={formatCommandCount(stats.temperatureCommands)} />
<GcodeValueField label="Move commands" value={formatCommandCount(stats.movementCommands)} />
<GcodeValueField label="Prime moves" value={formatCount(stats.primeMoves)} />
</div>
</FileSheetSection>
<FileSheetSection value="bounds" title="Bounds">
<div className="grid grid-cols-1 gap-2 px-3 py-3">
<GcodeValueField label="X" value={boundsAxisText(gcodeData?.bounds, 0, 1)} mono />
<GcodeValueField label="Y" value={boundsAxisText(gcodeData?.bounds, 1, 1)} mono />
<GcodeValueField label="Z" value={boundsAxisText(gcodeData?.bounds, 2, 2)} mono />
</div>
</FileSheetSection>
{themeSections}
<FileMetadataSection
entry={selectedEntry}
fileDownloadAvailable={fileDownloadAvailable}
viewerServerInfo={viewerServerInfo}
localFileOpenAvailable={localFileOpenAvailable}
fileAccessBusyKey={fileAccessBusyKey}
onOpenFileAsset={onOpenFileAsset}
suppressDynamicStatus={suppressDynamicMetadataStatus}
/>
</Accordion>
</FileSheet>
);
}

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