Import CAD projects and cad-router v1
This commit is contained in:
@@ -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=
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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"))
|
||||
@@ -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
@@ -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
@@ -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}" \
|
||||
"$@"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# shellcheck source=conda-common.sh
|
||||
source "${SCRIPT_DIR}/conda-common.sh"
|
||||
|
||||
REPO_ROOT="$(find_repo_root)"
|
||||
CONDA="$(find_conda)"
|
||||
export RMW_IMPLEMENTATION="${RMW_IMPLEMENTATION:-rmw_cyclonedds_cpp}"
|
||||
export PYTHONPATH="${SCRIPT_DIR}${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
"${CONDA}" env update --file "${SCRIPT_DIR}/environment.yml" --prune
|
||||
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m pip install -e "${SCRIPT_DIR}"
|
||||
"${CONDA}" run --no-capture-output -n "${MOVEIT2_SERVER_CONDA_ENV_NAME}" python -m moveit2_server.server --check --repo-root "${REPO_ROOT}"
|
||||
Generated
+4467
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -0,0 +1 @@
|
||||
../../packages/cadjs
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../packages/cadpy
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../packages/implicitjs
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
--editable ./packages/cadpy
|
||||
@@ -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
Reference in New Issue
Block a user