
Cad
- 9 installs
- 41 repo stars
- Updated May 19, 2026
- earthtojake/cad-skill
Create, modify, inspect, and validate parametric build123d/Python CAD parts and assemblies from natural-language specs, producing STEP-first artifacts with DXF/STL/3MF outputs.
About
Turns natural-language requirements into parametric build123d Python CAD models, generates validated STEP/STP artifacts, and inspects geometry with measurement and mating checks. A developer uses it to produce mechanical parts, assemblies, and CAD Explorer review links from prose.
- STEP-first workflow with build123d source-level joints, named mating datums, and geometric validation
- Provides scripts/step, scripts/inspect, scripts/render, and scripts/dxf launchers plus a CAD Explorer GUI
Cad by the numbers
- 9 all-time installs (skills.sh)
- Ranked #208 of 290 Python skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/earthtojake/cad-skill --skill cadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 41 |
| Last updated | May 19, 2026 |
| Repository | earthtojake/cad-skill ↗ |
What it does
Create, modify, inspect, and validate parametric build123d/Python CAD parts and assemblies from natural-language specs, producing STEP-first artifacts with DXF/STL/3MF outputs.
Files
CAD generation, inspection, and validation
Purpose
Create or modify parametric CAD models from natural-language requirements, generate validated STEP/STP artifacts, inspect geometry references, and return checked outputs plus CAD Explorer links. Treat STEP as the primary CAD artifact. Treat DXF, STL, and 3MF as secondary workflows that branch from, or accompany, a STEP-first process. For assemblies, prefer source-level build123d joints and named mating datums when the parts have functional assembly relationships.
Use this skill when
Use this skill when the user asks for CAD files, STEP/STP files, build123d source, @cad[...] references, mechanical parts, assemblies, enclosures, brackets, fixtures, holes, counterbores, countersinks, slots, pockets, bosses, standoffs, ribs, fillets, chamfers, shells, source-level joints, mating, measurements, or CAD Explorer review links.
Also use it when the user asks for DXF, STL, or 3MF output from CAD geometry. Keep those workflows secondary and load dxf.md or supported-exports.md for details.
Do not use this skill for render-only concept art, CAM toolpaths, engineering certification, FEA conclusions, architectural BIM, or freehand illustration unless the user also needs CAD geometry.
Default assumptions
Use these defaults unless the user specifies otherwise:
- Units: millimeters.
- Origin: center of the main part or assembly unless a mating interface or fixed root component suggests a better origin.
- Base plane: XY.
- Up/extrusion axis: positive Z.
- Output geometry: closed, positive-volume solids unless the user requests surfaces or construction geometry.
- STEP structure: one valid solid, a compound of solids, or a labeled assembly compound.
- Assembly structure: fixed root part, part-local frames, named mating datums, build123d joints where applicable, and explicit generated placements.
- Small plastic enclosure wall: 2.0-3.0 mm when unspecified.
- Cosmetic fillet: 1.0-3.0 mm when safe for local geometry.
- M3/M4/M5 normal clearance holes: 3.4/4.5/5.5 mm unless another standard is requested.
Ask one focused clarification question only when missing information makes the model impossible, fit-critical, safety-critical, or compliance-bound. Otherwise proceed with explicit assumptions.
Natural-language specs only
Do not ask the user to provide a JSON specification and do not make JSON the user-facing workflow. Convert the user's prose into an internal CAD brief with dimensions, features, assumptions, output paths, and validation criteria. Use references/natural-language-specs.md for brief-writing patterns.
Root model
Keep these roots separate:
- CAD skill directory: this folder. Tool launchers live here as
scripts/step,scripts/inspect,scripts/render, andscripts/dxf. - Tool process cwd: relative CAD targets are resolved from the command's current working directory. Use absolute target paths when running from the skill directory, or run from the workspace root and invoke the launchers with a path to this skill directory.
- Explorer scan root: CAD Explorer scans
EXPLORER_ROOT_DIRwhen set; otherwise it scans the workspace root inferred by Vite fromEXPLORER_WORKSPACE_ROOT, npmINIT_CWD, or the Vite process cwd. Explorerfile=links must be relative to the active scan root.
Short command examples in this skill use launcher paths relative to the CAD skill directory. Adapt the launcher path or target path so project CAD files resolve from the intended workspace, not accidentally under the skill directory.
Prefer keeping a STEP output and its Python generator in the same directory so the source stays easy to discover. Unless the user explicitly requests otherwise, keep the STEP basename and generator basename the same even when they cannot live side by side.
Available tools
From the CAD skill directory, the launcher shape is:
python scripts/step ...
python scripts/inspect ...
python scripts/render ...
python scripts/dxf ...
npm --prefix explorer run dev
npm --prefix explorer run dev:ensure -- --file path/to/model.stepUse the active project Python interpreter. If only the repo-local virtualenv is available, invoke that interpreter while keeping the root model above explicit.
Use python scripts/<tool> --help for the complete current command interface; reference docs show recommended workflows, not every flag.
Required workflow
1. Classify the task. Identify whether this is a new part, new assembly, source modification, direct STEP/STP inspection, reference selection, measurement/mating check, render review, or secondary output request. 2. Load only the needed references. Use the triggers below instead of reading the whole reference set. 3. Create a natural-language CAD brief. Extract dimensions, units, coordinate convention, feature intent, output paths, assumptions, and validation targets. 4. Plan before coding. Define parameters, labels, source paths, expected bounding boxes, and any mating/positioning datums before editing. 5. Edit source, not generated artifacts. Prefer build123d Python with gen_step() for STEP generation. 6. Generate explicit targets. Use scripts/step for STEP/STP generation and sidecars. Use --kind part or --kind assembly only for direct STEP/STP imports. Do not run directory-wide generation. 7. Validate geometrically. Use scripts/inspect refs --facts --planes --positioning, then targeted measure, mate, frame, or diff when needed. 8. Return Explorer links. Use references/rendering-and-explorer.md for Explorer startup, scan-root, and link rules. 9. Render conditionally. Use scripts/render only when requested, Explorer is unavailable, visual ambiguity remains, or section/wireframe review answers a real validation question. 10. Repair and rerun. If a check fails, change the smallest responsible source section, regenerate, and rerun the failed validation.
Non-negotiables
- Treat generated STEP/STP, STL, 3MF, GLB/topology, DXF outputs, and Explorer sidecars as derived artifacts.
- Keep STEP as the primary validated CAD artifact; DXF/STL/3MF are secondary unless the user explicitly says otherwise.
- When a Python generator exists, run
scripts/stepon the generator. Use a direct STEP/STP target only when the generator is unavailable or the user explicitly identifies that STEP/STP file as the target. - Use named parameters, closed solids, explicit labels, and source-controlled geometry intent.
- Author assembly positioning in source with part-local datums, explicit
Locationtransforms, or build123d joints. Treat CLIinspect mateas read-only validation, not as a source-editing API. - Do not use
git status,git diff, or file-size churn as CAD comparison for large exported STEP/STP, GLB/topology, STL, 3MF, or DXF artifacts. Compare source changes,scripts/inspectsummaries, targeted renders, or CAD Explorer output instead; use path-limited git status only for bookkeeping. - Report only checks that actually ran or are directly supported by tool output.
- If Explorer fails, say so and rely on CLI inspection for validation.
Progressive references
Load these files only when their trigger applies:
references/natural-language-specs.md— converting prose requirements into a CAD brief without requiring user JSON.references/step-generation.md— STEP generation, direct STEP/STP targets, part-vs-assembly behavior, and post-generation inspection.references/inspection-and-validation.md— validation gates,@cad[...]refs, facts, planes, topology, measurements, mating, diff, frame, and final validation reporting.references/positioning.md— part-local datums, assembly transforms, build123d joints, CLI mate validation, and positioning reports.references/rendering-and-explorer.md— CAD Explorer links and conditional render view/wireframe/section/list.references/dxf.md— secondary DXF workflow.references/supported-exports.md— secondary STL/3MF sidecar workflows.references/build123d-modeling.md— build123d modeling patterns, topology, selectors, features, assemblies.references/repair-loop.md— diagnosis and repair procedures.
Final responses should include generated files, CAD Explorer links, validation actually run, assumptions, and caveats. Use references/inspection-and-validation.md for report structure.
__pycache__/
*.py[cod]
.venv/
venv/
env/
.DS_Store
tmp/
scripts/tmp/
interface:
display_name: "CAD"
short_description: "Generate CAD and hand off Explorer links."
default_prompt: "Use $cad to create, regenerate, inspect, validate, and hand off CAD Explorer links for explicit CAD files and @cad references."
Natural-language CAD brief template
Use this as an internal note-taking scaffold. Do not ask the user to fill it out.
CAD brief:
- Model:
- Task type:
- Units:
- Coordinate convention:
- Overall dimensions:
- Functional features:
- Manufacturing assumptions:
- Positioning/mating requirements:
- source-level joints or explicit placements:
- STEP source path:
- STEP target path:
- Secondary outputs:
- Validation targets:
- Explorer link target:
- Assumptions:# 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
# 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
@import "tailwindcss" source(none);
@import "tw-animate-css";
@source "../app";
@source "../components";
@source "../hooks";
@source "../lib";
@source "../main.jsx";
@source "../index.html";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-sans: "IBM Plex Sans", "Aptos", "Segoe UI", sans-serif;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--ui-app-bg: var(--background);
--ui-app-gradient: none;
--ui-panel: var(--card);
--ui-panel-strong: var(--muted);
--ui-panel-elevated: var(--popover);
--ui-panel-muted: var(--accent);
--ui-control-bg: var(--secondary);
--ui-control-bg-hover: var(--accent);
--ui-border: var(--border);
--ui-border-strong: var(--ring);
--ui-text: var(--foreground);
--ui-text-muted: var(--muted-foreground);
--ui-text-faint: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
--ui-accent: var(--primary);
--ui-accent-strong: var(--primary);
--ui-accent-soft: var(--accent);
--ui-accent-ring: color-mix(in oklch, var(--ring) 45%, transparent);
--ui-accent-text: var(--accent-foreground);
--ui-chip: var(--muted);
--ui-chip-text: var(--muted-foreground);
--ui-overlay: color-mix(in srgb, var(--foreground) 42%, transparent);
--ui-shadow-soft: 0 10px 24px -18px rgb(0 0 0 / 0.32);
--ui-shadow-panel: 0 24px 48px -36px rgb(0 0 0 / 0.28);
--ui-shadow-dialog: 0 28px 62px -36px rgb(0 0 0 / 0.36);
--ui-glass-blur: 26px;
--ui-glass-saturation: 1.4;
--ui-glass-surface: color-mix(in srgb, var(--sidebar) 58%, transparent);
--ui-glass-popover: color-mix(in srgb, var(--popover) 66%, transparent);
--ui-glass-control: color-mix(in srgb, var(--background) 42%, transparent);
--ui-warning-bg: oklch(0.962 0.059 95.617);
--ui-warning-text: oklch(0.473 0.137 46.201);
--ui-error-bg: oklch(0.936 0.032 17.717);
--ui-error-text: oklch(0.505 0.213 27.518);
--ui-success-bg: oklch(0.95 0.052 163.051);
--ui-success-text: oklch(0.448 0.119 151.328);
--ui-code-bg: var(--muted);
--ui-code-text: var(--foreground);
--ui-status-assembly-bg: var(--ui-warning-bg);
--ui-status-assembly-text: var(--ui-warning-text);
--ui-status-part-bg: var(--ui-success-bg);
--ui-status-part-text: var(--ui-success-text);
--ui-loading-overlay: color-mix(in srgb, var(--background) 38%, transparent);
--ui-loading-overlay-strong: color-mix(in srgb, var(--background) 76%, transparent);
--ui-loading-border: color-mix(in srgb, var(--ui-border) 72%, white 10%);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--ui-warning-bg: oklch(0.269 0.077 45.635);
--ui-warning-text: oklch(0.879 0.169 91.605);
--ui-error-bg: oklch(0.258 0.092 26.042);
--ui-error-text: oklch(0.808 0.114 19.571);
--ui-success-bg: oklch(0.262 0.051 172.552);
--ui-success-text: oklch(0.871 0.15 154.449);
--ui-glass-surface: rgb(22 22 25 / 82%);
--ui-glass-popover: rgb(24 24 27 / 86%);
--ui-glass-control: rgb(13 13 16 / 64%);
--ui-loading-overlay: rgb(5 6 10 / 34%);
--ui-loading-overlay-strong: rgb(5 6 10 / 72%);
}
:root[data-glass-tone="light"],
[data-glass-tone="light"] {
--ui-glass-surface: rgb(4 4 7 / 94%);
--ui-glass-popover: rgb(6 6 9 / 95%);
--ui-glass-control: rgb(2 2 5 / 84%);
--ui-glass-saturation: 1.16;
}
:root[data-glass-tone="dark"],
[data-glass-tone="dark"] {
--ui-glass-surface: rgb(22 22 25 / 49%);
--ui-glass-popover: rgb(24 24 27 / 56%);
--ui-glass-control: rgb(13 13 16 / 39%);
--ui-glass-saturation: 1.44;
}
body [data-glass-tone="light"] {
--background: rgb(4 4 7 / 72%);
--card: rgb(6 6 9 / 74%);
--popover: rgb(6 6 9 / 78%);
--secondary: rgb(255 255 255 / 10%);
--muted: rgb(255 255 255 / 8%);
--accent: rgb(255 255 255 / 11%);
--input: rgb(255 255 255 / 16%);
--sidebar-accent: rgb(255 255 255 / 11%);
--ui-glass-control: rgb(2 2 5 / 64%);
}
body [data-glass-tone="dark"] {
--background: rgb(13 13 16 / 48%);
--card: rgb(22 22 25 / 58%);
--popover: rgb(24 24 27 / 64%);
--secondary: rgb(255 255 255 / 8%);
--muted: rgb(255 255 255 / 7%);
--accent: rgb(255 255 255 / 10%);
--input: rgb(255 255 255 / 14%);
--sidebar-accent: rgb(255 255 255 / 10%);
--ui-glass-control: rgb(13 13 16 / 30%);
}
.look-settings-theme,
.look-settings-theme-portal {
--ui-panel: var(--card);
--ui-panel-strong: var(--muted);
--ui-panel-elevated: var(--popover);
--ui-panel-muted: var(--accent);
--ui-control-bg: var(--secondary);
--ui-control-bg-hover: var(--accent);
--ui-border: var(--border);
--ui-border-strong: var(--ring);
--ui-text: var(--foreground);
--ui-text-muted: var(--muted-foreground);
--ui-text-faint: color-mix(in oklch, var(--muted-foreground) 70%, transparent);
--ui-accent: var(--primary);
--ui-accent-strong: var(--primary);
--ui-accent-soft: var(--accent);
--ui-accent-ring: color-mix(in oklch, var(--ring) 45%, transparent);
--ui-accent-text: var(--accent-foreground);
--ui-chip: var(--muted);
--ui-chip-text: var(--muted-foreground);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body,
#root {
min-height: 100%;
}
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
}
.cad-glass-surface,
.cad-glass-popover,
.cad-glass-control {
-webkit-backdrop-filter: blur(var(--ui-glass-blur)) saturate(var(--ui-glass-saturation));
backdrop-filter: blur(var(--ui-glass-blur)) saturate(var(--ui-glass-saturation));
}
.cad-glass-surface {
background-color: var(--ui-glass-surface) !important;
}
.cad-glass-popover {
background-color: var(--ui-glass-popover) !important;
}
.cad-glass-control {
background-color: var(--ui-glass-control) !important;
}
.cad-loading-overlay {
background:
radial-gradient(circle at 50% 14%, color-mix(in srgb, var(--ui-accent) 18%, transparent) 0%, transparent 38%),
linear-gradient(180deg, color-mix(in srgb, var(--ui-loading-overlay) 72%, transparent) 0%, var(--ui-loading-overlay-strong) 100%);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
backdrop-filter: blur(14px) saturate(1.12);
}
.cad-loading-ascii {
text-shadow:
0 0 18px color-mix(in srgb, var(--ui-accent) 18%, transparent),
0 1px 0 rgb(0 0 0 / 0.45);
}
[data-slot="sidebar-wrapper"][data-sidebar-resizing="true"] [data-slot="sidebar-gap"],
[data-slot="sidebar-wrapper"][data-sidebar-resizing="true"] [data-slot="sidebar-container"] {
transition: none !important;
}
.cad-file-explorer-scroll [data-slot="scroll-area-viewport"] {
overflow-x: hidden !important;
}
.cad-file-explorer-scroll [data-slot="scroll-area-viewport"] > div {
display: block !important;
min-width: 0 !important;
width: 100% !important;
}
::selection {
background: var(--accent);
color: var(--accent-foreground);
}
}
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState
} from "react";
import { copyImageBlobToClipboard } from "../lib/clipboard";
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;
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 DxfExplorer = forwardRef(function DxfExplorer({
dxfData,
modelKey,
onExplorerAlertChange
}, 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 boundsWidth = Math.max(Number(dxfData?.bounds?.width) || 0, MIN_VIEW_SIZE);
const boundsHeight = Math.max(Number(dxfData?.bounds?.height) || 0, MIN_VIEW_SIZE);
const fittedViewBox = useMemo(
() => buildFittedViewBox(
{ width: boundsWidth, height: boundsHeight },
viewportSize
),
[boundsHeight, boundsWidth, viewportSize.height, viewportSize.width]
);
useEffect(() => {
onExplorerAlertChange?.(null);
}, [modelKey, onExplorerAlertChange]);
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("Explorer 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;
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
return blob;
}
}), [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}
/>
{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>
</div>
);
});
export default DxfExplorer;
import { useEffect } from "react";
import { DRAWING_TOOL } from "../../../lib/workbench/constants";
export function useExplorerDrawingOverlay({
drawingCanvasRef,
drawingDraftRef,
drawingStrokesRef,
drawingChangeRef,
drawingIdRef,
drawingEnabled,
drawingTool,
meshData,
previewMode,
explorerReadyTick,
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,
explorerReadyTick
]);
}
import { useEffect, useRef } from "react";
import { EXPLORER_PICK_MODE } from "../../../lib/explorer/constants";
import { screenLimitedPickThreshold } from "../../../lib/explorer/pickingThresholds";
const FACE_BOUNDS_EPSILON = 0.25;
const PLANE_SURFACE_EPSILON = 0.25;
const CYLINDER_SURFACE_EPSILON = 0.35;
const AUTO_EDGE_PICK_THRESHOLD_FACTOR = 1;
const FRONT_LAYER_DISTANCE_FACTOR = 0.0015;
const FRONT_LAYER_DISTANCE_MIN = 0.02;
const EDGE_OCCLUSION_EPSILON_FACTOR = 0.75;
const EDGE_OCCLUSION_EPSILON_MIN = 0.08;
const EDGE_PICK_MAX_SCREEN_DISTANCE_PX = 10;
const EDGE_PICK_MAX_SCREEN_DISTANCE_WITH_FACE_PX = EDGE_PICK_MAX_SCREEN_DISTANCE_PX;
const EDGE_HOVER_MAX_SCREEN_DISTANCE_PX = 6;
const EDGE_HOVER_MAX_SCREEN_DISTANCE_WITH_FACE_PX = EDGE_HOVER_MAX_SCREEN_DISTANCE_PX;
const EDGE_PICK_PRIORITY_WITH_FACE_PX = EDGE_PICK_MAX_SCREEN_DISTANCE_WITH_FACE_PX;
const EDGE_HOVER_PRIORITY_WITH_FACE_PX = EDGE_HOVER_MAX_SCREEN_DISTANCE_WITH_FACE_PX;
const CORNER_PICK_MAX_SCREEN_DISTANCE_PX = 5;
const CORNER_HOVER_MAX_SCREEN_DISTANCE_PX = 4;
const CORNER_PICK_PRIORITY_WITH_OTHER_PX = 4;
const CORNER_HOVER_PRIORITY_WITH_OTHER_PX = 3;
const HOVER_PICK_MIN_MOVE_PX = 2;
const FINE_POINTER_TAP_SLOP_PX = 4;
const COARSE_POINTER_TAP_SLOP_PX = 12;
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function subtract(a, b) {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
}
function dot(a, b) {
return (a[0] * b[0]) + (a[1] * b[1]) + (a[2] * b[2]);
}
function length(vector) {
return Math.sqrt(dot(vector, vector));
}
function normalizeAngleAround(angle, center) {
let adjusted = angle;
while (adjusted - center > Math.PI) {
adjusted -= Math.PI * 2;
}
while (adjusted - center < -Math.PI) {
adjusted += Math.PI * 2;
}
return adjusted;
}
function pointInBounds(point, bounds, epsilon = 0.8) {
if (!Array.isArray(point) || point.length !== 3 || !bounds || !Array.isArray(bounds.min) || !Array.isArray(bounds.max)) {
return false;
}
return (
point[0] >= Number(bounds.min[0] || 0) - epsilon &&
point[0] <= Number(bounds.max[0] || 0) + epsilon &&
point[1] >= Number(bounds.min[1] || 0) - epsilon &&
point[1] <= Number(bounds.max[1] || 0) + epsilon &&
point[2] >= Number(bounds.min[2] || 0) - epsilon &&
point[2] <= Number(bounds.max[2] || 0) + epsilon
);
}
function pointInPolygon2d(point, polygon) {
let inside = false;
for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index, index += 1) {
const x1 = polygon[index][0];
const y1 = polygon[index][1];
const x2 = polygon[previous][0];
const y2 = polygon[previous][1];
const intersects = ((y1 > point[1]) !== (y2 > point[1])) &&
(point[0] < ((x2 - x1) * (point[1] - y1)) / ((y2 - y1) || 1e-9) + x1);
if (intersects) {
inside = !inside;
}
}
return inside;
}
function distancePointToSegment2d(point, start, end) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) {
return Math.hypot(point[0] - start[0], point[1] - start[1]);
}
const t = clamp(
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy),
0,
1
);
const projected = [start[0] + dx * t, start[1] + dy * t];
return Math.hypot(point[0] - projected[0], point[1] - projected[1]);
}
function distancePointToLoop2d(point, loop) {
let best = Infinity;
for (let index = 0, previous = loop.length - 1; index < loop.length; previous = index, index += 1) {
best = Math.min(best, distancePointToSegment2d(point, loop[previous], loop[index]));
}
return best;
}
function compareReferenceIdentity(a, b) {
const aKey = String(a?.entityId ?? a?.id ?? "");
const bKey = String(b?.entityId ?? b?.id ?? "");
return aKey.localeCompare(bKey);
}
function projectPointToPlane(point, surface) {
const origin = surface.origin || [0, 0, 0];
const relative = subtract(point, origin);
return [dot(relative, surface.xDir || [1, 0, 0]), dot(relative, surface.yDir || [0, 1, 0])];
}
function projectPointToCylinder(point, surface, thetaCenter) {
const origin = surface.origin || [0, 0, 0];
const axis = surface.axis || [0, 1, 0];
const xDir = surface.xDir || [1, 0, 0];
const yDir = surface.yDir || [0, 0, 1];
const relative = subtract(point, origin);
const axial = dot(relative, axis);
const radial = [
relative[0] - axis[0] * axial,
relative[1] - axis[1] * axial,
relative[2] - axis[2] * axial
];
const theta = normalizeAngleAround(Math.atan2(dot(radial, yDir), dot(radial, xDir)), thetaCenter);
return [theta * (surface.radius || 1), axial];
}
function measurePointInsideLoops(projectedPoint, loops2d) {
const crossings = loops2d.reduce((count, loop) => count + (pointInPolygon2d(projectedPoint, loop) ? 1 : 0), 0);
if (crossings % 2 !== 1) {
return null;
}
const boundaryDistance = loops2d.reduce(
(best, loop) => Math.min(best, distancePointToLoop2d(projectedPoint, loop)),
Infinity
);
return {
boundaryDistance
};
}
function faceMatchAtPoint(reference, point) {
const pickData = reference?.pickData;
if (!pickData?.loops?.length || !pointInBounds(point, pickData.bbox, FACE_BOUNDS_EPSILON)) {
return null;
}
const surface = pickData.surface || {};
if (surface.type === "PLANE" && surface.origin && surface.normal && surface.xDir && surface.yDir) {
const planeDistance = Math.abs(dot(subtract(point, surface.origin), surface.normal));
if (planeDistance > PLANE_SURFACE_EPSILON) {
return null;
}
const projectedPoint = projectPointToPlane(point, surface);
const loops2d = pickData.loops.map((loop) => loop.map((loopPoint) => projectPointToPlane(loopPoint, surface)));
const loopMatch = measurePointInsideLoops(projectedPoint, loops2d);
if (!loopMatch) {
return null;
}
return {
boundaryDistance: loopMatch.boundaryDistance,
surfaceDistance: planeDistance
};
}
if (surface.type === "CYLINDRICAL_SURFACE" && surface.origin && surface.axis && surface.xDir && surface.yDir) {
const origin = surface.origin;
const axis = surface.axis;
const relative = subtract(point, origin);
const axial = dot(relative, axis);
const radial = [
relative[0] - axis[0] * axial,
relative[1] - axis[1] * axial,
relative[2] - axis[2] * axial
];
const radialDistance = length(radial);
const radialError = Math.abs(radialDistance - surface.radius);
if (!surface.radius || radialError > CYLINDER_SURFACE_EPSILON) {
return null;
}
const pointTheta = Math.atan2(dot(radial, surface.yDir), dot(radial, surface.xDir));
const projectedPoint = projectPointToCylinder(point, surface, pointTheta);
const loops2d = pickData.loops.map((loop) =>
loop.map((loopPoint) => projectPointToCylinder(loopPoint, surface, pointTheta))
);
const loopMatch = measurePointInsideLoops(projectedPoint, loops2d);
if (!loopMatch) {
return null;
}
return {
boundaryDistance: loopMatch.boundaryDistance,
surfaceDistance: radialError
};
}
return null;
}
function faceLoopCount(reference) {
return reference?.loopCount ?? reference?.pickData?.loopCount ?? reference?.pickData?.loops?.length ?? 0;
}
function isPreferredFaceReference(reference) {
return faceLoopCount(reference) <= 1;
}
function chooseRedirectFaceReference(reference, point, references) {
const pickData = reference?.pickData;
const surface = pickData?.surface || {};
if (isPreferredFaceReference(reference) || surface.type !== "PLANE" || !surface.origin || !surface.xDir || !surface.yDir) {
return null;
}
const projectedPoint = projectPointToPlane(point, surface);
const loopEntries = Array.isArray(pickData.loopsMeta) ? pickData.loopsMeta : [];
const faceByEntityId = new Map(
(Array.isArray(references) ? references : [])
.filter((candidate) => candidate?.entityType === "ADVANCED_FACE")
.map((candidate) => [candidate.entityId, candidate])
);
const redirectCandidates = [];
for (const loopEntry of loopEntries) {
if (loopEntry?.isOuter || !Array.isArray(loopEntry.points) || loopEntry.points.length < 3) {
continue;
}
const loop2d = loopEntry.points.map((loopPoint) => projectPointToPlane(loopPoint, surface));
const inside = pointInPolygon2d(projectedPoint, loop2d);
const distance = distancePointToLoop2d(projectedPoint, loop2d);
if (!inside && distance > 1.1) {
continue;
}
for (const faceEntityId of loopEntry.adjacentFaceEntityIds || []) {
const candidate = faceByEntityId.get(faceEntityId);
if (!candidate || !isPreferredFaceReference(candidate)) {
continue;
}
redirectCandidates.push({
candidate,
distance,
inside
});
}
}
redirectCandidates.sort((a, b) => {
if (a.inside !== b.inside) {
return a.inside ? -1 : 1;
}
if (Math.abs(a.distance - b.distance) > 1e-4) {
return a.distance - b.distance;
}
const aMetric = a.candidate.pickData?.metric ?? Infinity;
const bMetric = b.candidate.pickData?.metric ?? Infinity;
if (aMetric !== bMetric) {
return aMetric - bMetric;
}
return compareReferenceIdentity(a.candidate, b.candidate);
});
return redirectCandidates[0]?.candidate || null;
}
function compareFaceCandidates(a, b) {
const aSurfaceDistance = a.match?.surfaceDistance ?? Infinity;
const bSurfaceDistance = b.match?.surfaceDistance ?? Infinity;
if (Math.abs(aSurfaceDistance - bSurfaceDistance) > 1e-4) {
return aSurfaceDistance - bSurfaceDistance;
}
const aBoundaryDistance = a.match?.boundaryDistance ?? -Infinity;
const bBoundaryDistance = b.match?.boundaryDistance ?? -Infinity;
if (Math.abs(aBoundaryDistance - bBoundaryDistance) > 1e-4) {
return bBoundaryDistance - aBoundaryDistance;
}
const aMetric = a.reference.pickData?.metric ?? Infinity;
const bMetric = b.reference.pickData?.metric ?? Infinity;
if (Math.abs(aMetric - bMetric) > 1e-4) {
return bMetric - aMetric;
}
const aPreferred = isPreferredFaceReference(a.reference) ? 0 : 1;
const bPreferred = isPreferredFaceReference(b.reference) ? 0 : 1;
if (aPreferred !== bPreferred) {
return aPreferred - bPreferred;
}
return compareReferenceIdentity(a.reference, b.reference);
}
function findBestFaceReference(references, point) {
const matches = [];
for (const reference of Array.isArray(references) ? references : []) {
const match = faceMatchAtPoint(reference, point);
if (!match) {
continue;
}
matches.push({
match,
reference
});
}
matches.sort(compareFaceCandidates);
return matches[0]?.reference || null;
}
function chooseBestFaceReferenceFromIntersections(references, intersections) {
const candidates = new Map();
for (const intersection of intersections) {
if (!intersection?.point || !intersection?.object) {
continue;
}
const localPoint = intersection.object.worldToLocal(intersection.point.clone());
const point = [localPoint.x, localPoint.y, localPoint.z];
let reference = findBestFaceReference(references, point);
const redirected = chooseRedirectFaceReference(reference, point, references);
if (redirected) {
reference = redirected;
}
if (!reference) {
continue;
}
const existing = candidates.get(reference.id);
if (!existing || intersection.distance < existing.distance) {
candidates.set(reference.id, {
distance: intersection.distance,
reference
});
}
}
const scored = [...candidates.values()];
scored.sort((a, b) => {
if (Math.abs(a.distance - b.distance) > 1e-4) {
return a.distance - b.distance;
}
const aMetric = a.reference.pickData?.metric ?? Infinity;
const bMetric = b.reference.pickData?.metric ?? Infinity;
if (aMetric !== bMetric) {
return aMetric - bMetric;
}
const aPreferred = isPreferredFaceReference(a.reference) ? 0 : 1;
const bPreferred = isPreferredFaceReference(b.reference) ? 0 : 1;
if (aPreferred !== bPreferred) {
return aPreferred - bPreferred;
}
return compareReferenceIdentity(a.reference, b.reference);
});
return scored[0]?.reference || null;
}
function chooseBestEdgeIntersection(intersections, measureScreenDistance = null) {
if (!intersections.length) {
return null;
}
const scored = intersections.map((intersection) => ({
intersection,
screenDistance: typeof measureScreenDistance === "function"
? Number(measureScreenDistance(intersection))
: Infinity,
pickError: intersection.distanceToRay ?? intersection.distance ?? Infinity,
distance: intersection.distance ?? Infinity,
metric: intersection.object.userData.metric ?? Infinity
}));
scored.sort((a, b) => {
const aScreenDistance = Number.isFinite(a.screenDistance) ? a.screenDistance : Infinity;
const bScreenDistance = Number.isFinite(b.screenDistance) ? b.screenDistance : Infinity;
if (Math.abs(aScreenDistance - bScreenDistance) > 0.25) {
return aScreenDistance - bScreenDistance;
}
if (Math.abs(a.pickError - b.pickError) > 1e-4) {
return a.pickError - b.pickError;
}
if (Math.abs(a.distance - b.distance) > 1e-4) {
return a.distance - b.distance;
}
return a.metric - b.metric;
});
return scored[0].intersection;
}
function frontMostModelIntersections(intersections) {
if (!Array.isArray(intersections) || !intersections.length) {
return [];
}
const nearestDistance = Number(intersections[0]?.distance);
if (!Number.isFinite(nearestDistance)) {
return [];
}
const depthWindow = Math.max(FRONT_LAYER_DISTANCE_MIN, nearestDistance * FRONT_LAYER_DISTANCE_FACTOR);
return intersections.filter((intersection) => Number(intersection?.distance) <= nearestDistance + depthWindow);
}
function focusedPartIdSet(value) {
return new Set(
(Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
}
export function useExplorerPicking({
runtimeRef,
mountRef,
sceneMountRef = null,
drawingCanvasRef = null,
previewMode,
pickMode,
selectorRuntime,
pickableFaces,
pickableEdges,
pickableVertices,
focusedPartId,
onHoverReferenceChange,
onActivateReference,
onDoubleActivateReference,
explorerReadyTick
}) {
// Keep pointer listeners stable across parent rerenders; hover itself updates parent state.
const pickModeRef = useRef(pickMode);
const selectorRuntimeRef = useRef(selectorRuntime);
const pickableFacesRef = useRef(pickableFaces);
const pickableEdgesRef = useRef(pickableEdges);
const pickableVerticesRef = useRef(pickableVertices);
const focusedPartIdRef = useRef(focusedPartId);
const onHoverReferenceChangeRef = useRef(onHoverReferenceChange);
const onActivateReferenceRef = useRef(onActivateReference);
const onDoubleActivateReferenceRef = useRef(onDoubleActivateReference);
const allowedFaceReferenceIdsRef = useRef(new Set());
const allowedEdgeReferenceIdsRef = useRef(new Set());
const allowedVertexReferenceIdsRef = useRef(new Set());
pickModeRef.current = pickMode;
selectorRuntimeRef.current = selectorRuntime;
pickableFacesRef.current = pickableFaces;
pickableEdgesRef.current = pickableEdges;
pickableVerticesRef.current = pickableVertices;
focusedPartIdRef.current = focusedPartId;
onHoverReferenceChangeRef.current = onHoverReferenceChange;
onActivateReferenceRef.current = onActivateReference;
onDoubleActivateReferenceRef.current = onDoubleActivateReference;
allowedFaceReferenceIdsRef.current = new Set(
(Array.isArray(pickableFaces) ? pickableFaces : [])
.map((reference) => String(reference?.id || "").trim())
.filter(Boolean)
);
allowedEdgeReferenceIdsRef.current = new Set(
(Array.isArray(pickableEdges) ? pickableEdges : [])
.map((reference) => String(reference?.id || "").trim())
.filter(Boolean)
);
allowedVertexReferenceIdsRef.current = new Set(
(Array.isArray(pickableVertices) ? pickableVertices : [])
.map((reference) => String(reference?.id || "").trim())
.filter(Boolean)
);
useEffect(() => {
const runtime = runtimeRef.current;
if (!runtime || !mountRef.current || previewMode) {
onHoverReferenceChangeRef.current?.("");
return;
}
const container = mountRef.current;
const sceneMount = sceneMountRef?.current || null;
const drawingCanvas = drawingCanvasRef?.current || null;
const coarsePointerQuery = typeof window.matchMedia === "function"
? window.matchMedia("(pointer: coarse)")
: null;
const defaultToCoarsePointer = coarsePointerQuery?.matches ?? false;
const pointerDown = {
active: false,
x: 0,
y: 0,
pointerType: "",
referenceId: ""
};
const hoverState = {
rafId: 0,
x: 0,
y: 0,
lastX: NaN,
lastY: NaN,
hoveredReferenceId: ""
};
function setPointerFromPosition(clientX, clientY) {
const rect = container.getBoundingClientRect();
runtime.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
runtime.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
runtime.raycaster.setFromCamera(runtime.pointer, runtime.camera);
if (runtime.raycaster?.params?.Line) {
runtime.raycaster.params.Line.threshold = runtime.edgePickThreshold || 1;
}
if (runtime.raycaster?.params?.Points) {
runtime.raycaster.params.Points.threshold = runtime.vertexPickThreshold || 1;
}
}
function projectPointToClient(point) {
if (!point?.clone || !runtime?.camera) {
return null;
}
const projected = point.clone().project(runtime.camera);
if (!Number.isFinite(projected.x) || !Number.isFinite(projected.y)) {
return null;
}
const rect = container.getBoundingClientRect();
return {
x: rect.left + ((projected.x + 1) * 0.5 * rect.width),
y: rect.top + ((1 - projected.y) * 0.5 * rect.height)
};
}
function edgeScreenDistance(intersection, clientX, clientY) {
const clientPoint = projectPointToClient(intersection?.point);
if (!clientPoint) {
return Infinity;
}
return Math.hypot(clientX - clientPoint.x, clientY - clientPoint.y);
}
function pickViewportHeightPx() {
return container.clientHeight || container.getBoundingClientRect().height || 1;
}
function pickSurfaceDistance(modelIntersections) {
const surfaceDistance = Number(modelIntersections?.[0]?.distance);
if (Number.isFinite(surfaceDistance) && surfaceDistance > 0) {
return surfaceDistance;
}
const controlsDistance = runtime?.camera?.position?.distanceTo?.(runtime?.controls?.target);
if (Number.isFinite(controlsDistance) && controlsDistance > 0) {
return controlsDistance;
}
return Number(runtime?.modelRadius || 1);
}
function currentPickThreshold(baseThreshold, thresholdScale, maxScreenDistancePx, modelIntersections) {
return screenLimitedPickThreshold({
baseThreshold,
thresholdScale,
maxScreenDistancePx,
camera: runtime.camera,
viewportHeightPx: pickViewportHeightPx(),
distance: pickSurfaceDistance(modelIntersections)
});
}
function visibleModelMeshes() {
const focusIds = focusedPartIdSet(focusedPartIdRef.current);
return runtime.displayRecords
.filter((record) => {
if (!record?.mesh?.visible) {
return false;
}
if (focusIds.size && !focusIds.has(String(record?.partId || "").trim())) {
return false;
}
return true;
})
.map((record) => record.mesh);
}
function intersectVisibleModelMeshes() {
const modelMeshes = visibleModelMeshes();
if (!modelMeshes.length) {
return [];
}
return runtime.raycaster.intersectObjects(modelMeshes, false);
}
function pickPartReferenceFromIntersections(intersections) {
const focusIds = focusedPartIdSet(focusedPartIdRef.current);
for (const intersection of intersections) {
const partId = intersection?.object?.userData?.partId;
if (!partId) {
continue;
}
if (focusIds.size && !focusIds.has(String(partId || "").trim())) {
continue;
}
return partId;
}
return null;
}
function faceReferenceFromIntersection(intersection) {
const triangleIndex = Number(intersection?.faceIndex);
const faceIds = intersection?.object?.userData?.faceIds;
const rowIndex = Number.isInteger(triangleIndex) ? Number(faceIds?.[triangleIndex]) : NaN;
if (!Number.isInteger(rowIndex)) {
return null;
}
const reference = selectorRuntimeRef.current?.faceReferenceByRowIndex?.get?.(rowIndex) || null;
const referenceId = String(reference?.id || "").trim();
const allowedFaceReferenceIds = allowedFaceReferenceIdsRef.current;
if (!referenceId || (allowedFaceReferenceIds.size && !allowedFaceReferenceIds.has(referenceId))) {
return null;
}
return reference;
}
function edgeReferenceFromIntersection(intersection) {
// Three.js reports LineSegments hits as the starting index/vertex offset
// for the segment (0, 2, 4, ...), while edgeIds is packed per segment.
const hitIndex = Number(intersection?.index);
const edgeIds = intersection?.object?.userData?.edgeIds;
const segmentIndex = Number.isInteger(hitIndex) ? Math.floor(hitIndex / 2) : NaN;
const rowIndex = Number.isInteger(segmentIndex) ? Number(edgeIds?.[segmentIndex]) : NaN;
if (!Number.isInteger(rowIndex)) {
return null;
}
const reference = selectorRuntimeRef.current?.edgeReferenceByRowIndex?.get?.(rowIndex) || null;
const referenceId = String(reference?.id || "").trim();
const allowedEdgeReferenceIds = allowedEdgeReferenceIdsRef.current;
if (!referenceId || (allowedEdgeReferenceIds.size && !allowedEdgeReferenceIds.has(referenceId))) {
return null;
}
return reference;
}
function vertexReferenceFromIntersection(intersection) {
const pointIndex = Number(intersection?.index);
const vertexIds = intersection?.object?.userData?.vertexIds;
const rowIndex = Number.isInteger(pointIndex) ? Number(vertexIds?.[pointIndex]) : NaN;
if (!Number.isInteger(rowIndex)) {
return null;
}
const reference = selectorRuntimeRef.current?.vertexReferenceByRowIndex?.get?.(rowIndex) || null;
const referenceId = String(reference?.id || "").trim();
const allowedVertexReferenceIds = allowedVertexReferenceIdsRef.current;
if (!referenceId || (allowedVertexReferenceIds.size && !allowedVertexReferenceIds.has(referenceId))) {
return null;
}
return reference;
}
function pickFaceReference(modelIntersections) {
if (!(Array.isArray(pickableFacesRef.current) ? pickableFacesRef.current : []).length) {
return null;
}
for (const intersection of frontMostModelIntersections(modelIntersections)) {
const reference = faceReferenceFromIntersection(intersection);
if (reference) {
return reference;
}
}
if (!runtime.facePickMesh) {
return null;
}
const intersections = runtime.raycaster.intersectObject(runtime.facePickMesh, false);
if (!intersections.length) {
return null;
}
const frontModelIntersections = frontMostModelIntersections(modelIntersections);
let filteredIntersections = intersections;
const nearestSurfaceDistance = Number(frontModelIntersections[0]?.distance);
if (Number.isFinite(nearestSurfaceDistance)) {
const depthWindow = Math.max(FRONT_LAYER_DISTANCE_MIN, nearestSurfaceDistance * FRONT_LAYER_DISTANCE_FACTOR);
filteredIntersections = intersections.filter(
(intersection) => Number(intersection?.distance) <= nearestSurfaceDistance + depthWindow
);
}
for (const intersection of filteredIntersections.length ? filteredIntersections : intersections) {
const reference = faceReferenceFromIntersection(intersection);
if (!reference) {
continue;
}
return reference;
}
return null;
}
function pickEdgeCandidate(modelIntersections, clientX, clientY, {
thresholdScale = 1,
maxScreenDistancePx = EDGE_PICK_MAX_SCREEN_DISTANCE_PX
} = {}) {
const pickableEdges = Array.isArray(pickableEdgesRef.current) ? pickableEdgesRef.current : [];
if (!pickableEdges.length || !runtime.edgePickLines) {
return null;
}
const edgeThreshold = currentPickThreshold(
runtime.edgePickThreshold || 1,
thresholdScale,
maxScreenDistancePx,
modelIntersections
);
if (runtime.raycaster?.params?.Line) {
runtime.raycaster.params.Line.threshold = edgeThreshold;
}
let filteredIntersections = runtime.raycaster.intersectObject(runtime.edgePickLines, false);
const nearestSurfaceDistance = Number(modelIntersections?.[0]?.distance);
if (Number.isFinite(nearestSurfaceDistance)) {
const depthAllowance = Math.max(
EDGE_OCCLUSION_EPSILON_MIN,
edgeThreshold * EDGE_OCCLUSION_EPSILON_FACTOR
);
filteredIntersections = filteredIntersections.filter(
(intersection) => Number(intersection?.distance) <= nearestSurfaceDistance + depthAllowance
);
}
const best = chooseBestEdgeIntersection(
filteredIntersections,
(intersection) => edgeScreenDistance(intersection, clientX, clientY)
);
if (!best) {
return null;
}
const bestScreenDistance = edgeScreenDistance(best, clientX, clientY);
if (Number.isFinite(bestScreenDistance) && bestScreenDistance > maxScreenDistancePx) {
return null;
}
const reference = edgeReferenceFromIntersection(best);
if (!reference) {
return null;
}
return {
reference,
screenDistance: bestScreenDistance
};
}
function pickVertexCandidate(modelIntersections, clientX, clientY, {
thresholdScale = 1,
maxScreenDistancePx = CORNER_PICK_MAX_SCREEN_DISTANCE_PX
} = {}) {
const pickableVertices = Array.isArray(pickableVerticesRef.current) ? pickableVerticesRef.current : [];
if (!pickableVertices.length || !runtime.vertexPickPoints) {
return null;
}
const vertexThreshold = currentPickThreshold(
runtime.vertexPickThreshold || 1,
thresholdScale,
maxScreenDistancePx,
modelIntersections
);
if (runtime.raycaster?.params?.Points) {
runtime.raycaster.params.Points.threshold = vertexThreshold;
}
let filteredIntersections = runtime.raycaster.intersectObject(runtime.vertexPickPoints, false);
const nearestSurfaceDistance = Number(modelIntersections?.[0]?.distance);
if (Number.isFinite(nearestSurfaceDistance)) {
const depthAllowance = Math.max(
EDGE_OCCLUSION_EPSILON_MIN,
vertexThreshold * EDGE_OCCLUSION_EPSILON_FACTOR
);
filteredIntersections = filteredIntersections.filter(
(intersection) => Number(intersection?.distance) <= nearestSurfaceDistance + depthAllowance
);
}
const best = chooseBestEdgeIntersection(
filteredIntersections,
(intersection) => edgeScreenDistance(intersection, clientX, clientY)
);
if (!best) {
return null;
}
const bestScreenDistance = edgeScreenDistance(best, clientX, clientY);
if (Number.isFinite(bestScreenDistance) && bestScreenDistance > maxScreenDistancePx) {
return null;
}
const reference = vertexReferenceFromIntersection(best);
if (!reference) {
return null;
}
return {
reference,
screenDistance: bestScreenDistance
};
}
function areFaceAndEdgeAdjacent(faceReference, edgeReference) {
const adjacentSelectors = Array.isArray(faceReference?.pickData?.adjacentSelectors)
? faceReference.pickData.adjacentSelectors
: [];
if (!adjacentSelectors.length) {
return false;
}
const edgeDisplaySelector = String(edgeReference?.displaySelector || "").trim();
const edgeNormalizedSelector = String(edgeReference?.normalizedSelector || "").trim();
return adjacentSelectors.includes(edgeDisplaySelector) || adjacentSelectors.includes(edgeNormalizedSelector);
}
function areEdgeAndVertexAdjacent(edgeReference, vertexReference) {
const adjacentSelectors = Array.isArray(vertexReference?.pickData?.adjacentSelectors)
? vertexReference.pickData.adjacentSelectors
: [];
if (!adjacentSelectors.length) {
return false;
}
const edgeDisplaySelector = String(edgeReference?.displaySelector || "").trim();
const edgeNormalizedSelector = String(edgeReference?.normalizedSelector || "").trim();
return adjacentSelectors.includes(edgeDisplaySelector) || adjacentSelectors.includes(edgeNormalizedSelector);
}
function areFaceAndVertexAdjacent(faceReference, vertexReference) {
const faceAdjacentSelectors = Array.isArray(faceReference?.pickData?.adjacentSelectors)
? faceReference.pickData.adjacentSelectors
: [];
const vertexAdjacentSelectors = Array.isArray(vertexReference?.pickData?.adjacentSelectors)
? vertexReference.pickData.adjacentSelectors
: [];
if (!faceAdjacentSelectors.length || !vertexAdjacentSelectors.length) {
return false;
}
const edgeSelectorSet = new Set(faceAdjacentSelectors);
return vertexAdjacentSelectors.some((selector) => edgeSelectorSet.has(selector));
}
function pickTopologyReference(modelIntersections, clientX, clientY, { hover = false } = {}) {
const pickableFaces = Array.isArray(pickableFacesRef.current) ? pickableFacesRef.current : [];
const pickableEdges = Array.isArray(pickableEdgesRef.current) ? pickableEdgesRef.current : [];
const pickableVertices = Array.isArray(pickableVerticesRef.current) ? pickableVerticesRef.current : [];
const hasFaces = pickableFaces.length > 0;
const hasEdges = pickableEdges.length > 0;
const hasVertices = pickableVertices.length > 0;
if (!hasFaces && !hasEdges && !hasVertices) {
return null;
}
const faceReference = pickFaceReference(modelIntersections);
const maxScreenDistancePx = hover
? (
faceReference
? EDGE_HOVER_MAX_SCREEN_DISTANCE_WITH_FACE_PX
: EDGE_HOVER_MAX_SCREEN_DISTANCE_PX
)
: (
faceReference
? EDGE_PICK_MAX_SCREEN_DISTANCE_WITH_FACE_PX
: EDGE_PICK_MAX_SCREEN_DISTANCE_PX
);
const edgeCandidate = pickEdgeCandidate(
modelIntersections,
clientX,
clientY,
{
thresholdScale: faceReference ? AUTO_EDGE_PICK_THRESHOLD_FACTOR : 1,
maxScreenDistancePx
}
);
const vertexCandidate = pickVertexCandidate(
modelIntersections,
clientX,
clientY,
{
maxScreenDistancePx: hover ? CORNER_HOVER_MAX_SCREEN_DISTANCE_PX : CORNER_PICK_MAX_SCREEN_DISTANCE_PX
}
);
if (vertexCandidate) {
const vertexPriorityDistancePx = hover ? CORNER_HOVER_PRIORITY_WITH_OTHER_PX : CORNER_PICK_PRIORITY_WITH_OTHER_PX;
const adjacentToEdge = edgeCandidate && areEdgeAndVertexAdjacent(edgeCandidate.reference, vertexCandidate.reference);
const adjacentToFace = faceReference && areFaceAndVertexAdjacent(faceReference, vertexCandidate.reference);
if (!faceReference && !edgeCandidate) {
return vertexCandidate.reference.id;
}
if ((adjacentToEdge || adjacentToFace) && Number(vertexCandidate.screenDistance) <= vertexPriorityDistancePx) {
return vertexCandidate.reference.id;
}
}
if (faceReference && edgeCandidate) {
const priorityDistancePx = hover ? EDGE_HOVER_PRIORITY_WITH_FACE_PX : EDGE_PICK_PRIORITY_WITH_FACE_PX;
if (areFaceAndEdgeAdjacent(faceReference, edgeCandidate.reference)) {
if (Number(edgeCandidate.screenDistance) <= priorityDistancePx) {
return edgeCandidate.reference.id;
}
return faceReference.id;
}
return faceReference.id;
}
return edgeCandidate?.reference?.id || faceReference?.id || vertexCandidate?.reference?.id || null;
}
function pickReferenceAtPosition(clientX, clientY, { hover = false } = {}) {
setPointerFromPosition(clientX, clientY);
const modelIntersections = intersectVisibleModelMeshes();
const pickMode = pickModeRef.current;
if (pickMode === EXPLORER_PICK_MODE.PARTS) {
return pickPartReferenceFromIntersections(modelIntersections);
}
if (pickMode === EXPLORER_PICK_MODE.ASSEMBLY) {
return pickPartReferenceFromIntersections(modelIntersections);
}
if (pickMode === EXPLORER_PICK_MODE.AUTO) {
return pickTopologyReference(modelIntersections, clientX, clientY, { hover });
}
return null;
}
function pickActivationReference(clientX, clientY, pointerType = "") {
if (canHoverWithPointer(pointerType)) {
return String(hoverState.hoveredReferenceId || "").trim() || pickReferenceAtPosition(clientX, clientY, { hover: true });
}
return pickReferenceAtPosition(clientX, clientY);
}
function isCoarsePointer(pointerType = "") {
return pointerType === "touch" || pointerType === "pen" || defaultToCoarsePointer;
}
function tapSlopForPointer(pointerType = "") {
return isCoarsePointer(pointerType) ? COARSE_POINTER_TAP_SLOP_PX : FINE_POINTER_TAP_SLOP_PX;
}
function canHoverWithPointer(pointerType = "") {
return !isCoarsePointer(pointerType);
}
function isSceneInteractionTarget(target) {
if (!(target instanceof Node)) {
return false;
}
if (sceneMount?.contains(target)) {
return true;
}
if (drawingCanvas && (target === drawingCanvas || drawingCanvas.contains?.(target))) {
return true;
}
return false;
}
function commitHoverState(referenceId) {
const normalizedReferenceId = referenceId || "";
container.style.cursor = normalizedReferenceId ? "pointer" : "";
if (hoverState.hoveredReferenceId === normalizedReferenceId) {
return;
}
hoverState.hoveredReferenceId = normalizedReferenceId;
onHoverReferenceChangeRef.current?.(normalizedReferenceId);
}
function clearHoverState() {
if (hoverState.rafId) {
window.cancelAnimationFrame(hoverState.rafId);
hoverState.rafId = 0;
}
hoverState.lastX = NaN;
hoverState.lastY = NaN;
container.style.cursor = "";
if (!hoverState.hoveredReferenceId) {
return;
}
hoverState.hoveredReferenceId = "";
onHoverReferenceChangeRef.current?.("");
}
function flushHoverPick() {
hoverState.rafId = 0;
if (runtime.interactionState.active) {
clearHoverState();
return;
}
hoverState.lastX = hoverState.x;
hoverState.lastY = hoverState.y;
commitHoverState(pickReferenceAtPosition(hoverState.x, hoverState.y, { hover: true }));
}
function scheduleHoverPick(clientX, clientY) {
hoverState.x = clientX;
hoverState.y = clientY;
if (
Number.isFinite(hoverState.lastX) &&
Number.isFinite(hoverState.lastY) &&
Math.hypot(clientX - hoverState.lastX, clientY - hoverState.lastY) < HOVER_PICK_MIN_MOVE_PX
) {
return;
}
if (hoverState.rafId) {
return;
}
hoverState.rafId = window.requestAnimationFrame(flushHoverPick);
}
function handlePointerMove(event) {
if (!isSceneInteractionTarget(event.target)) {
clearHoverState();
return;
}
if (runtime.interactionState.active) {
clearHoverState();
return;
}
const tapSlop = tapSlopForPointer(pointerDown.pointerType || event.pointerType);
if (pointerDown.active) {
const moved = Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y);
if (moved > tapSlop) {
pointerDown.active = false;
}
}
if (!canHoverWithPointer(event.pointerType)) {
clearHoverState();
return;
}
scheduleHoverPick(event.clientX, event.clientY);
}
function handlePointerLeave() {
clearHoverState();
pointerDown.active = false;
pointerDown.pointerType = "";
pointerDown.referenceId = "";
}
function handlePointerDown(event) {
if (event.button !== 0) {
return;
}
if (!isSceneInteractionTarget(event.target)) {
return;
}
pointerDown.active = true;
pointerDown.x = event.clientX;
pointerDown.y = event.clientY;
pointerDown.pointerType = event.pointerType || "";
pointerDown.referenceId = String(
canHoverWithPointer(pointerDown.pointerType)
? (hoverState.hoveredReferenceId || pickReferenceAtPosition(event.clientX, event.clientY, { hover: true }) || "")
: (pickReferenceAtPosition(event.clientX, event.clientY) || "")
).trim();
}
function handlePointerUp(event) {
if (event.button !== 0) {
return;
}
if (!pointerDown.active && !isSceneInteractionTarget(event.target)) {
return;
}
const tapSlop = tapSlopForPointer(pointerDown.pointerType || event.pointerType);
const moved = Math.hypot(event.clientX - pointerDown.x, event.clientY - pointerDown.y);
if (!pointerDown.active || moved > tapSlop) {
pointerDown.active = false;
pointerDown.pointerType = "";
pointerDown.referenceId = "";
return;
}
const pointerDownReferenceId = String(pointerDown.referenceId || "").trim();
pointerDown.active = false;
pointerDown.pointerType = "";
pointerDown.referenceId = "";
const referenceId = pointerDownReferenceId || pickActivationReference(event.clientX, event.clientY, event.pointerType || "");
onActivateReferenceRef.current?.(referenceId || "", { multiSelect: !!event.shiftKey });
}
function handleDoubleClick(event) {
if (!isSceneInteractionTarget(event.target)) {
return;
}
const referenceId = pickActivationReference(event.clientX, event.clientY, event.pointerType || "");
onDoubleActivateReferenceRef.current?.(referenceId || "", { multiSelect: !!event.shiftKey });
}
container.addEventListener("pointermove", handlePointerMove);
container.addEventListener("pointerleave", handlePointerLeave);
container.addEventListener("pointerdown", handlePointerDown);
container.addEventListener("pointerup", handlePointerUp);
if (!defaultToCoarsePointer) {
container.addEventListener("dblclick", handleDoubleClick);
}
return () => {
container.removeEventListener("pointermove", handlePointerMove);
container.removeEventListener("pointerleave", handlePointerLeave);
container.removeEventListener("pointerdown", handlePointerDown);
container.removeEventListener("pointerup", handlePointerUp);
if (!defaultToCoarsePointer) {
container.removeEventListener("dblclick", handleDoubleClick);
}
clearHoverState();
container.style.cursor = "";
};
}, [
drawingCanvasRef,
mountRef,
previewMode,
runtimeRef,
sceneMountRef,
explorerReadyTick
]);
}
import assert from "node:assert/strict";
import test from "node:test";
import { screenLimitedPickThreshold, worldUnitsPerPixelAtDistance } from "../../../lib/explorer/pickingThresholds.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);
});
import { useEffect } from "react";
import { isEditableTarget } from "../../../lib/dom";
export function useExplorerRuntime({
mountRef,
runtimeRef,
previewModeRef,
setError,
setExplorerReadyTick,
explorerTheme,
syncDrawingCanvasSize,
renderDrawingOverlay,
emitPerspectiveChange,
setActiveViewPlaneFace,
activeViewPlaneFaceRef,
stepCameraTransition,
stepKeyboardOrbit,
getActiveViewPlaneFaceId,
cancelCameraTransition,
clearKeyboardOrbitState,
isTrackpadLikeWheelEvent,
getKeyboardOrbitCommand,
getKeyboardOrbitAxes,
applyOrbitDelta,
getExplorerThemeValue,
getPixelRatioCap,
applySceneBackground,
applyCameraFrameInsets,
frameInsetsRef,
updateGridHelper,
clearSceneGroup,
disposeSceneObject,
disposeTexture,
syncViewPlaneOrientation,
BASE_EXPLORER_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,
onContextLost,
onContextRestored,
runtimeResetToken = 0
}) {
// Runtime setup/teardown should run once per WebGL runtime epoch.
useEffect(() => {
let cancelled = false;
let cleanup = () => {};
async function initializeExplorer() {
const [
THREE,
{ OrbitControls },
{ LineSegments2 },
{ LineSegmentsGeometry },
{ LineMaterial }
] = await Promise.all([
import("three"),
import("three/examples/jsm/controls/OrbitControls.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 camera = new THREE.PerspectiveCamera(48, width / height, 0.1, 50000);
camera.up.set(0, 0, 1);
camera.position.set(180, -180, 120);
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance",
alpha: true,
preserveDrawingBuffer: false,
logarithmicDepthBuffer: true
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = getExplorerThemeValue(explorerTheme, "toneMappingExposure", DEFAULT_LIGHTING.toneMappingExposure);
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(
getExplorerThemeValue(explorerTheme, "hemisphereSky", DEFAULT_LIGHTING.hemisphereSky),
getExplorerThemeValue(explorerTheme, "hemisphereGround", DEFAULT_LIGHTING.hemisphereGround),
getExplorerThemeValue(explorerTheme, "hemisphereIntensity", DEFAULT_LIGHTING.hemisphereIntensity)
);
scene.add(hemisphereLight);
const ambientLight = new THREE.AmbientLight("#ffffff", 0);
scene.add(ambientLight);
const keyLight = new THREE.DirectionalLight(
getExplorerThemeValue(explorerTheme, "keyLightColor", DEFAULT_LIGHTING.keyLightColor),
getExplorerThemeValue(explorerTheme, "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.012;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(
getExplorerThemeValue(explorerTheme, "fillLightColor", DEFAULT_LIGHTING.fillLightColor),
getExplorerThemeValue(explorerTheme, "fillLightIntensity", DEFAULT_LIGHTING.fillLightIntensity)
);
fillLight.position.set(120, 80, 210);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(
getExplorerThemeValue(explorerTheme, "rimLightColor", DEFAULT_LIGHTING.rimLightColor),
getExplorerThemeValue(explorerTheme, "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,
restoreTimerId: 0
};
const keyboardOrbitState = {
pressedKeys: new Set(),
directionCounts: {
left: 0,
right: 0,
up: 0,
down: 0
},
lastFrameTime: 0
};
const screenSpaceLineMaterials = new Set();
const syncScreenSpaceLineMaterials = () => {
const nextWidth = container.clientWidth || width || 1;
const nextHeight = container.clientHeight || height || 1;
for (const material of screenSpaceLineMaterials) {
material?.resolution?.set?.(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 Explorer...");
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) {
return;
}
interactionState.renderQueued = true;
rafId = window.requestAnimationFrame(renderFrame);
if (runtimeRef.current) {
runtimeRef.current.rafId = rafId;
}
};
function renderFrame(timestamp) {
interactionState.renderQueued = false;
const cameraTransitionActive = stepCameraTransition(runtimeRef.current, timestamp);
const keyboardOrbitMoved = stepKeyboardOrbit(runtimeRef.current, timestamp);
const needsMoreFrames = controls.update();
if (cameraTransitionActive || keyboardOrbitMoved) {
emitPerspectiveChange(runtimeRef.current);
}
const previewOrbitActive = !!runtimeRef.current?.previewOrbitEnabled;
renderer.render(scene, camera);
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(INTERACTION_PIXEL_RATIO_CAP);
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);
camera.aspect = w / h;
camera.updateProjectionMatrix();
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current, { updateProjection: false });
syncScreenSpaceLineMaterials();
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
requestRender();
};
window.addEventListener("resize", onResize);
const resizeObserver = typeof ResizeObserver === "function"
? new ResizeObserver(() => {
onResize();
})
: null;
resizeObserver?.observe(container);
const handleControlsStart = () => {
cancelCameraTransition(runtimeRef.current);
beginInteraction();
};
const handleControlsChange = () => {
emitPerspectiveChange(runtimeRef.current);
requestRender();
};
const handleControlsEnd = () => {
scheduleIdleQuality();
};
const handleWheel = (event) => {
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,
renderer,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
controls,
stageGroup,
modelGroup,
edgesGroup,
facePickGroup,
edgePickGroup,
vertexPickGroup,
facePickMesh: null,
edgePickLines: null,
vertexPickPoints: null,
edgePickObjects: [],
displayRecords: [],
modelBounds: null,
modelRadius: 1,
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,
interactionState,
keyboardOrbitState,
onResize,
resizeObserver,
rafId,
requestRender,
beginInteraction,
scheduleIdleQuality,
registerScreenSpaceLineMaterial,
unregisterScreenSpaceLineMaterial
};
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
applySceneBackground(runtimeRef.current, explorerTheme);
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", clearKeyboardOrbit);
document.addEventListener("visibilitychange", handleVisibilityChange);
requestRender();
updateGridHelper(runtimeRef.current, explorerTheme, defaultGridRadius, 0, sceneScaleMode, floorMode);
setExplorerReadyTick((value) => value + 1);
cleanup = () => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (runtime.interactionState.restoreTimerId) {
window.clearTimeout(runtime.interactionState.restoreTimerId);
}
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;
};
}
initializeExplorer().catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runtimeResetToken]);
}
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]
}
});
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
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(explorerTheme) {
const themePalette = explorerTheme?.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,
compact = false,
activeViewPlaneFace,
viewPlaneFaces,
viewPlaneOrientation,
explorerTheme,
activateViewPlaneFace,
activateDefaultViewPlane
}) {
if (!showViewPlane || previewMode || isLoading || !meshData) {
return null;
}
const orientation = normalizeOrientation(viewPlaneOrientation);
const palette = resolveViewPlanePalette(explorerTheme);
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 viewPlaneSizeClasses = compact ? "h-[5.65rem] w-[5.65rem]" : "h-[7.35rem] w-[7.35rem]";
const normalizedBottomOffset = typeof viewPlaneOffsetBottom === "number"
? `${viewPlaneOffsetBottom}px`
: viewPlaneOffsetBottom;
const renderNode = (node) => {
const active = activeViewPlaneFace === node.id;
return (
<g
key={node.id}
role="button"
tabIndex={0}
aria-label={node.title}
aria-pressed={active}
className="cursor-pointer focus:outline-none"
onPointerDown={(event) => {
event.stopPropagation();
}}
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 + (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}
fill={node.fill}
stroke={active ? "var(--sidebar-foreground)" : node.edge}
strokeWidth={active ? 1.35 : 1}
/>
</g>
);
};
return (
<div
className="pointer-events-none absolute z-20"
style={{ right: `${viewPlaneOffsetRight}px`, bottom: normalizedBottomOffset }}
>
<div className={`cad-glass-surface pointer-events-auto relative rounded-full border border-sidebar-border text-sidebar-foreground shadow-sm transition duration-150 ${viewPlaneSizeClasses}`}>
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100" aria-label="Perspective selector">
<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>
<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="Reset to default isometric view"
className="cursor-pointer focus:outline-none"
onPointerDown={(event) => {
event.stopPropagation();
}}
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="7.3"
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>
);
}
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/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)] transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-0", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({
...props
}) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}) {
return (
<AlertDialogPrimitive.Overlay
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} />
);
}
function AlertDialogContent({
className,
...props
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
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} />
);
}
function AlertDialogTitle({
className,
...props
}) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props} />
);
}
function AlertDialogDescription({
className,
...props
}) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
function AlertDialogAction({
className,
...props
}) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props} />
);
}
function AlertDialogCancel({
className,
...props
}) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} />
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/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 }
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/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",
outline: "text-foreground",
warning: "border-chart-5/35 bg-chart-5/10 text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
...props
}) {
return (
<span
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props} />
);
}
export { Badge, badgeVariants }
import * as React from "react";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/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,
};
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/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",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
function Collapsible({
...props
}) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}) {
return (<CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />);
}
function CollapsibleContent({
...props
}) {
return (<CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
import * as React from "react"
import { cn } from "@/lib/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 }
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}) {
return (
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative", className)} {...props}>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar }
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/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 }
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props} />
);
}
export { Skeleton }
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
...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"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
import { cn } from "../../lib/cn";
export const CAD_WORKSPACE_TOOLBAR_DESKTOP_WIDTH_CLASS = "w-[min(15rem,calc(100vw-2rem))]";
export default function ToolbarShell({ className, children }) {
return (
<div
className={cn(
"cad-glass-surface pointer-events-auto flex min-h-0 flex-col overflow-hidden rounded-md border border-sidebar-border text-sidebar-foreground shadow-sm",
className
)}
>
{children}
</div>
);
}
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
export { cn } from "./utils";