Initial commit

This commit is contained in:
2026-08-19 19:34:30 +08:00
commit beba8744b4
285 changed files with 74293 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Frontend
The frontend is the agent workspace:
- Left pane: conversation, prompts, generation progress, and revision history.
- Right pane: interactive STEP/mesh preview and model state.
- Shared state: current prompt, generated CDSL, build status, preview asset,
validation report, and selected model-library references.
Expected development entrypoint: `npm run dev`.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+27
View File
@@ -0,0 +1,27 @@
import type { NextConfig } from "next";
import path from "node:path";
const root = __dirname;
const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1", "localhost"],
transpilePackages: ["three", "three-mesh-bvh"],
webpack(config) {
config.resolve = config.resolve || {};
config.resolve.alias = {
...(config.resolve.alias || {}),
"@": path.join(root, "src"),
three: path.join(root, "node_modules", "three"),
"three/examples": path.join(root, "node_modules", "three", "examples"),
"three-mesh-bvh": path.join(root, "node_modules", "three-mesh-bvh"),
};
config.resolve.extensions = [
...(config.resolve.extensions || []),
".js",
".mjs",
];
return config;
},
};
export default nextConfig;
+5056
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "cdsl-cad-agent-studio",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"test": "node --test --import tsx src/test.ts"
},
"dependencies": {
"@ai-sdk/react": "^4.0.40",
"@assistant-ui/react": "0.14.28",
"@assistant-ui/react-ai-sdk": "1.4.0",
"@radix-ui/react-collapsible": "^1.1.20",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-slider": "^1.4.7",
"ai": "7.0.37",
"animejs": "^4.5.0",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.6.0",
"three": "0.160.0",
"three-mesh-bvh": "^0.8.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.185.4",
"puppeteer-core": "^25.8.0",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+70
View File
@@ -0,0 +1,70 @@
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
import { messagesForBackend } from "@/lib/cad-messages";
import { backendEventToUiChunk } from "@/lib/cad-stream";
import type { CadUIMessage } from "@/lib/cad-types";
export const runtime = "nodejs";
type BackendEvent = { event: string; data: Record<string, unknown> };
async function* parseSse(response: Response): AsyncGenerator<BackendEvent> {
if (!response.body) return;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
for (;;) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const blocks = buffer.split(/\r?\n\r?\n/);
buffer = done ? "" : blocks.pop() || "";
for (const block of blocks) {
const eventName = /^event:\s*(.+)$/m.exec(block)?.[1]?.trim() || "message";
const dataText = /^data:\s*(.+)$/m.exec(block)?.[1]?.trim() || "{}";
try {
yield { event: eventName, data: JSON.parse(dataText) as Record<string, unknown> };
} catch {
yield { event: "cad_error", data: { stage: "stream", message: "Invalid backend event." } };
}
}
if (done) break;
}
} finally {
reader.releaseLock();
}
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const upstream = await backendFetch("/v1/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
body: JSON.stringify({
conversation_id: body.conversationId || null,
selected_task_id: body.selectedTaskId || null,
provider_id: body.providerId || null,
model_id: body.modelId || null,
messages: messagesForBackend((Array.isArray(body.messages) ? body.messages : []) as CadUIMessage[]),
}),
signal: request.signal,
});
if (!upstream.ok) {
return NextResponse.json({ error: await readBackendError(upstream) }, { status: upstream.status });
}
const stream = createUIMessageStream({
execute: async ({ writer }) => {
const textId = `assistant_${Date.now()}`;
writer.write({ type: "start", messageId: textId });
writer.write({ type: "text-start", id: textId });
for await (const item of parseSse(upstream)) {
const chunk = backendEventToUiChunk(item, textId);
if (chunk) writer.write(chunk);
}
writer.write({ type: "text-end", id: textId });
writer.write({ type: "finish", finishReason: "stop" });
},
});
return createUIMessageStreamResponse({ stream, headers: { "Cache-Control": "no-store" } });
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET() {
const response = await backendFetch("/v1/config");
if (!response.ok) {
return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
}
return NextResponse.json(await response.json());
}
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
const { conversationId } = await context.params;
const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}`);
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
export async function PATCH(request: NextRequest, context: { params: Promise<{ conversationId: string }> }) {
const { conversationId } = await context.params;
const body = await request.json().catch(() => ({}));
const response = await backendFetch(`/v1/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_task_id: body.currentTaskId || null,
attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
}),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function POST() {
const response = await backendFetch("/v1/conversations", { method: "POST" });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_request: NextRequest, context: { params: Promise<{ taskId: string; artifactPath: string[] }> }) {
const { taskId, artifactPath } = await context.params;
const path = artifactPath.map((item) => encodeURIComponent(item)).join("/");
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/artifacts/${path}`, {
headers: { Accept: "*/*" },
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return new NextResponse(await response.arrayBuffer(), {
headers: {
"Content-Type": response.headers.get("content-type") || "application/octet-stream",
"Content-Disposition": response.headers.get("content-disposition") || "",
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/modify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(await request.json()),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/parameters`);
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ taskId: string }> }) {
const { taskId } = await params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}/parameters`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(await request.json()),
});
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function GET(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) {
const { taskId } = await context.params;
const response = await backendFetch(`/v1/tasks/${encodeURIComponent(taskId)}`);
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
+11
View File
@@ -0,0 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { backendFetch, readBackendError } from "@/lib/backend";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const body = await request.formData();
const response = await backendFetch("/v1/uploads", { method: "POST", body });
if (!response.ok) return NextResponse.json({ error: await readBackendError(response) }, { status: response.status });
return NextResponse.json(await response.json());
}
+629
View File
@@ -0,0 +1,629 @@
@import "tailwindcss";
:root {
--radius: 0.625rem;
--ui-glass-blur: 26px;
--ui-glass-saturation: 1.4;
}
:root,
:root[data-ui-theme="light"] {
color-scheme: light;
--background: #f5f7f8;
--foreground: #15191d;
--panel: #ffffff;
--panel-2: #edf2f4;
--muted: #68737d;
--border: #d8e0e5;
--accent: #197f8a;
--accent-2: #946b16;
--danger: #b42335;
--sidebar: #ffffff;
--sidebar-foreground: #15191d;
--sidebar-accent: #edf2f4;
--sidebar-accent-foreground: #101418;
--sidebar-border: #d8e0e5;
--sidebar-ring: #197f8a;
--popover: #ffffff;
--popover-foreground: #15191d;
--primary: #197f8a;
--primary-foreground: #f7fcfd;
--muted-foreground: #68737d;
--ui-app-bg: #f5f7f8;
--ui-header-bg: #ffffff;
--ui-panel: #ffffff;
--ui-panel-muted: #f8fafb;
--ui-panel-raised: #edf2f4;
--ui-popover: #ffffff;
--ui-control-bg: #f1f5f7;
--ui-control-hover: #e6eef2;
--ui-control-pressed: #dbe8ed;
--ui-border: #d8e0e5;
--ui-border-muted: #e6ecef;
--ui-border-strong: #b8c7cf;
--ui-text: #15191d;
--ui-text-strong: #0d1114;
--ui-text-muted: #68737d;
--ui-text-subtle: #87919a;
--ui-text-faint: #a1abb3;
--ui-text-inverse: #f7fcfd;
--ui-accent: #197f8a;
--ui-accent-hover: #146d77;
--ui-accent-soft: rgb(25 127 138 / 12%);
--ui-accent-muted: rgb(25 127 138 / 22%);
--ui-accent-border: rgb(25 127 138 / 34%);
--ui-accent-text: #11646d;
--ui-accent-contrast: #f7fcfd;
--ui-secondary: #946b16;
--ui-secondary-text: #805b11;
--ui-secondary-soft: rgb(148 107 22 / 12%);
--ui-link: #2457a5;
--ui-link-hover: #1c4a8c;
--ui-success: #247a38;
--ui-success-soft: #e7f6e9;
--ui-success-text: #1c6b2e;
--ui-warning: #946b16;
--ui-error: #b42335;
--ui-error-bg: #fff0f1;
--ui-error-border: #f1b9c0;
--ui-error-text: #9f1d2e;
--ui-focus-ring: rgb(25 127 138 / 34%);
--ui-selection-bg: rgb(25 127 138 / 18%);
--ui-viewer-bg: #e9eef2;
--ui-viewer-bg-muted: #dde6eb;
--ui-loading-overlay: rgb(233 238 242 / 70%);
--ui-loading-overlay-strong: rgb(245 247 248 / 88%);
--ui-drag-overlay: rgb(255 255 255 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(15 25 30 / 18%);
--ui-shadow-soft: 0 12px 30px rgb(16 24 32 / 12%);
--ui-shadow-panel: 0 22px 60px rgb(16 24 32 / 14%);
--ui-shadow-popover: 0 18px 46px rgb(16 24 32 / 16%);
--ui-shadow-inset: inset 0 0 0 1px rgb(255 255 255 / 58%);
--ui-glass-surface: rgb(255 255 255 / 82%);
--ui-glass-popover: rgb(255 255 255 / 90%);
--ui-glass-control: rgb(255 255 255 / 72%);
--ui-scrollbar-thumb: #b9c5cc;
--ui-slider-track: rgb(25 127 138 / 16%);
--ui-slider-range: rgb(25 127 138 / 24%);
--ui-slider-range-hover: rgb(25 127 138 / 32%);
--ui-slider-range-active: rgb(25 127 138 / 42%);
--ui-slider-marker: rgb(21 25 29 / 68%);
--ui-slider-marker-hover: rgb(21 25 29 / 86%);
--ui-slider-marker-shadow: rgb(16 24 32 / 18%);
}
:root[data-ui-theme="dark"] {
color-scheme: dark;
--background: #111315;
--foreground: #e8e8e3;
--panel: #171a1d;
--panel-2: #1e2226;
--muted: #969b9f;
--border: #2d3339;
--accent: #7dc8cf;
--accent-2: #d8b66c;
--danger: #e56f72;
--sidebar: #15181b;
--sidebar-foreground: #e8e8e3;
--sidebar-accent: #22272c;
--sidebar-accent-foreground: #f2f4f4;
--sidebar-border: #2d3339;
--sidebar-ring: #7dc8cf;
--popover: #15181b;
--popover-foreground: #e8e8e3;
--primary: #7dc8cf;
--primary-foreground: #101315;
--muted-foreground: #969b9f;
--ui-app-bg: #111315;
--ui-header-bg: #15181b;
--ui-panel: #171a1d;
--ui-panel-muted: #15181b;
--ui-panel-raised: #1e2226;
--ui-popover: #15181b;
--ui-control-bg: #1e2226;
--ui-control-hover: #22272c;
--ui-control-pressed: #273135;
--ui-border: #2d3339;
--ui-border-muted: #252b30;
--ui-border-strong: #39434b;
--ui-text: #e8e8e3;
--ui-text-strong: #f2f4f4;
--ui-text-muted: #969b9f;
--ui-text-subtle: #777e84;
--ui-text-faint: #6d747b;
--ui-text-inverse: #101315;
--ui-accent: #7dc8cf;
--ui-accent-hover: #91d4da;
--ui-accent-soft: rgb(125 200 207 / 13%);
--ui-accent-muted: rgb(125 200 207 / 22%);
--ui-accent-border: rgb(125 200 207 / 40%);
--ui-accent-text: #dff8fa;
--ui-accent-contrast: #101315;
--ui-secondary: #d8b66c;
--ui-secondary-text: #d8b66c;
--ui-secondary-soft: rgb(216 182 108 / 14%);
--ui-link: #8bd5dc;
--ui-link-hover: #b3e6ea;
--ui-success: #9cd67a;
--ui-success-soft: #1d2b1d;
--ui-success-text: #9cd67a;
--ui-warning: #d8b66c;
--ui-error: #e56f72;
--ui-error-bg: #35191c;
--ui-error-border: #7e3d42;
--ui-error-text: #f2b0b2;
--ui-focus-ring: rgb(125 200 207 / 45%);
--ui-selection-bg: rgb(125 200 207 / 24%);
--ui-viewer-bg: #0d0f11;
--ui-viewer-bg-muted: #101316;
--ui-loading-overlay: rgb(13 15 17 / 35%);
--ui-loading-overlay-strong: rgb(13 15 17 / 76%);
--ui-drag-overlay: rgb(16 19 21 / 88%);
--ui-drag-shadow: 0 0 0 999px rgb(17 19 21 / 42%);
--ui-shadow-soft: 0 10px 24px rgb(0 0 0 / 20%);
--ui-shadow-panel: 0 24px 60px rgb(0 0 0 / 26%);
--ui-shadow-popover: 0 18px 48px rgb(0 0 0 / 28%);
--ui-shadow-inset: inset 0 0 0 1px rgb(232 232 227 / 8%);
--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-scrollbar-thumb: #3a424a;
--ui-slider-track: rgb(125 200 207 / 20%);
--ui-slider-range: rgb(125 200 207 / 24%);
--ui-slider-range-hover: rgb(224 247 249 / 50%);
--ui-slider-range-active: rgb(199 239 242 / 50%);
--ui-slider-marker: rgb(255 255 255 / 70%);
--ui-slider-marker-hover: rgb(255 255 255 / 88%);
--ui-slider-marker-shadow: rgb(0 0 0 / 50%);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
background: var(--ui-app-bg);
color: var(--ui-text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
letter-spacing: 0;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
}
::selection {
background: var(--ui-selection-bg);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--ui-scrollbar-thumb) transparent;
}
.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-deepbuild-viewer-bg {
background:
radial-gradient(ellipse at 50% 34%, rgb(255 255 255 / 98%), rgb(255 255 255 / 62%) 42%, transparent 66%),
linear-gradient(115deg, rgb(230 246 240 / 76%) 0%, rgb(253 245 226 / 70%) 33%, rgb(251 224 239 / 66%) 62%, rgb(225 234 252 / 78%) 100%),
linear-gradient(180deg, #fbfbfc 0%, #eef2f6 100%);
isolation: isolate;
}
.cad-deepbuild-viewer-bg::before {
content: "";
position: absolute;
inset: -8%;
z-index: 0;
pointer-events: none;
background:
radial-gradient(ellipse at 74% 24%, rgb(208 214 248 / 56%), transparent 48%),
radial-gradient(ellipse at 22% 75%, rgb(196 232 224 / 50%), transparent 52%),
radial-gradient(ellipse at 58% 76%, rgb(255 211 188 / 38%), transparent 46%);
filter: blur(56px);
opacity: 0.86;
}
.cad-deepbuild-viewer-bg::after {
content: "";
position: absolute;
inset: -3vh -2vw;
z-index: 0;
pointer-events: none;
background:
linear-gradient(135deg, rgb(255 255 255 / 24%), rgb(255 255 255 / 6%) 42%, rgb(255 255 255 / 20%)),
radial-gradient(ellipse at 18% 18%, rgb(255 255 255 / 38%), transparent 34%),
radial-gradient(ellipse at 84% 78%, rgb(255 255 255 / 18%), transparent 42%);
box-shadow:
inset 2px -2px 1px -1px rgb(255 255 255 / 36%),
inset -2px 2px 1px -1px rgb(255 255 255 / 34%),
inset 24px -24px 44px -38px rgb(255 255 255 / 62%),
inset -24px 24px 48px -42px rgb(77 92 117 / 20%),
inset 0 0 1px rgb(27 40 56 / 14%),
inset 0 -34px 90px rgb(92 116 143 / 4.5%);
backdrop-filter: blur(22px) saturate(1.18) brightness(1.03);
-webkit-backdrop-filter: blur(22px) saturate(1.18) brightness(1.03);
opacity: 0.88;
}
.cad-deepbuild-viewer-bg > :first-child {
position: relative;
z-index: 1;
}
.generation-edge-glow {
position: fixed;
inset: 0;
z-index: 100;
pointer-events: none;
isolation: isolate;
contain: paint;
animation: generation-edge-glow-in 480ms ease-out both;
}
.generation-edge-glow__veil,
.generation-edge-glow__band {
position: absolute;
inset: 0;
pointer-events: none;
}
.generation-edge-glow__veil {
background:
radial-gradient(88% 64% at 14% 8%, rgba(60, 220, 255, 0.18), transparent 64%),
radial-gradient(78% 64% at 88% 9%, rgba(255, 80, 210, 0.16), transparent 64%),
radial-gradient(82% 64% at 52% 104%, rgba(132, 102, 255, 0.12), transparent 72%);
opacity: 0;
animation: generation-edge-full-cover 1.05s cubic-bezier(0.22, 1, 0.36, 1) both;
will-change: opacity;
}
.generation-edge-glow__band {
inset: -6px;
background:
linear-gradient(90deg, rgba(48, 219, 255, 0.82), rgba(104, 118, 255, 0.46), rgba(255, 80, 210, 0.82), rgba(255, 142, 105, 0.34), rgba(48, 219, 255, 0.72)) top / 100% 14px no-repeat,
linear-gradient(90deg, rgba(158, 96, 255, 0.7), rgba(84, 132, 255, 0.48), rgba(42, 218, 255, 0.76), rgba(255, 112, 210, 0.36)) bottom / 100% 14px no-repeat,
linear-gradient(180deg, rgba(42, 218, 255, 0.82), rgba(92, 132, 255, 0.44), rgba(160, 98, 255, 0.62)) left / 14px 100% no-repeat,
linear-gradient(180deg, rgba(255, 82, 214, 0.86), rgba(170, 100, 255, 0.5), rgba(48, 214, 255, 0.72)) right / 14px 100% no-repeat;
filter: blur(9px) saturate(1.18);
opacity: 0;
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
padding: 46px;
animation: generation-edge-enter-strong 900ms cubic-bezier(0.22, 1, 0.36, 1) 360ms both;
will-change: opacity;
}
.generation-edge-glow.is-converging {
animation: none;
}
.generation-edge-glow.is-converging .generation-edge-glow__veil,
.generation-edge-glow.is-converging .generation-edge-glow__band {
animation: generation-edge-soft-out 520ms ease-out forwards;
}
@keyframes generation-edge-glow-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes generation-edge-full-cover {
0% {
opacity: 0;
transform: scale(1.03);
}
24% {
opacity: 0.32;
transform: scale(1.01);
}
58% {
opacity: 0.14;
}
100% {
opacity: 0;
transform: scale(1);
}
}
@keyframes generation-edge-enter-strong {
from { opacity: 0; }
to { opacity: 0.74; }
}
@keyframes generation-edge-soft-out { to { opacity: 0; } }
.generation-success {
position: fixed;
inset: 0;
z-index: 101;
display: grid;
place-items: center;
pointer-events: none;
}
.generation-success__glow {
position: absolute;
left: 50%;
top: 50%;
width: 360px;
height: 220px;
margin: -110px 0 0 -180px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(120, 220, 255, 0.24), transparent 70%);
opacity: 0;
animation: generation-success-glow 2100ms ease-out 1300ms both;
}
@keyframes generation-success-glow {
0% { opacity: 0; transform: scale(0.6); }
28% { opacity: 1; }
72% { opacity: 1; }
100% { opacity: 0; transform: scale(1.05); }
}
.generation-success__card {
position: relative;
display: flex;
align-items: center;
gap: 13px;
max-width: min(420px, calc(100vw - 32px));
padding: 14px 22px 14px 15px;
border: 0.5px solid rgba(255, 255, 255, 0.92);
border-radius: 20px;
background: rgba(255, 255, 255, 0.74);
box-shadow:
0 18px 50px rgba(28, 40, 60, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.75);
-webkit-backdrop-filter: blur(30px) saturate(1.7);
backdrop-filter: blur(30px) saturate(1.7);
opacity: 0;
will-change: transform, opacity;
animation:
generation-success-card-in 640ms cubic-bezier(0.34, 1.56, 0.64, 1) 1520ms both,
generation-success-card-out 460ms ease-in 3.5s forwards;
}
@keyframes generation-success-card-in {
0% { opacity: 0; transform: scale(0.8) translateY(6px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes generation-success-card-out {
to { opacity: 0; transform: scale(0.97); }
}
.generation-success__badge {
position: relative;
flex: 0 0 auto;
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 50%;
background: radial-gradient(circle at 50% 38%, rgba(70, 230, 175, 0.26), transparent 70%);
}
.generation-success__badge::before {
content: "";
position: absolute;
inset: -3px;
border-radius: 50%;
background: conic-gradient(from 120deg, #34c759, #2fd6c0, #4ab8ff, #34c759);
opacity: 0;
filter: blur(5px);
animation: generation-success-badge-glow 760ms ease-out 1700ms both;
}
@keyframes generation-success-badge-glow {
0% { opacity: 0; transform: scale(0.7); }
60% { opacity: 0.6; }
100% { opacity: 0.34; transform: scale(1); }
}
.generation-success__check {
position: relative;
z-index: 1;
width: 32px;
height: 32px;
}
.generation-success__check circle {
fill: none;
stroke: #34c759;
stroke-width: 3;
stroke-dasharray: 145;
stroke-dashoffset: 145;
animation: generation-success-check-circle 500ms cubic-bezier(0.65, 0, 0.35, 1) 1760ms forwards;
}
.generation-success__check path {
fill: none;
stroke: #34c759;
stroke-width: 4.2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 36;
stroke-dashoffset: 36;
animation: generation-success-check-path 320ms cubic-bezier(0.65, 0, 0.35, 1) 2080ms forwards;
}
@keyframes generation-success-check-circle { to { stroke-dashoffset: 0; } }
@keyframes generation-success-check-path { to { stroke-dashoffset: 0; } }
.generation-success__copy {
display: grid;
gap: 1px;
min-width: 0;
overflow: hidden;
}
.generation-success__kicker {
color: rgba(118, 138, 165, 0.9);
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
opacity: 0;
animation: generation-success-copy-in 520ms ease-out 1640ms both;
}
.generation-success__copy strong {
position: relative;
color: #18212f;
font-size: 18px;
font-weight: 800;
line-height: 1.18;
letter-spacing: 0;
opacity: 0;
animation: generation-success-copy-in 560ms cubic-bezier(0.2, 1, 0.3, 1) 1740ms both;
}
.generation-success__copy strong::after {
content: attr(data-text);
position: absolute;
left: 0;
top: 0;
color: transparent;
-webkit-text-fill-color: transparent;
background: linear-gradient(100deg, transparent 36%, rgba(74, 168, 255, 0.95) 50%, transparent 64%);
background-size: 260% 100%;
background-position: 130% 0;
-webkit-background-clip: text;
background-clip: text;
animation: generation-success-title-shine 1100ms ease-out 2150ms both;
}
@keyframes generation-success-title-shine { to { background-position: -50% 0; } }
.generation-success__detail {
overflow: hidden;
color: #7b8a9c;
font-size: 12.5px;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0;
animation: generation-success-copy-in 560ms ease-out 1900ms both;
}
@keyframes generation-success-copy-in {
0% { opacity: 0; transform: translateY(5px); filter: blur(3px); }
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
}
.generation-success__spark {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: radial-gradient(circle, #fff 0%, rgba(120, 220, 255, 0.7) 55%, transparent 75%);
opacity: 0;
animation: generation-success-spark 1300ms ease-out calc(2000ms + var(--spark-index) * 230ms) both;
}
.generation-success__spark:nth-of-type(1) { top: -7px; right: 30px; }
.generation-success__spark:nth-of-type(2) { bottom: -5px; left: 46px; }
.generation-success__spark:nth-of-type(3) { top: 12px; right: -7px; }
@keyframes generation-success-spark {
0% { opacity: 0; transform: scale(0); }
42% { opacity: 1; transform: scale(1.25); }
100% { opacity: 0; transform: scale(0.35); }
}
@media (prefers-reduced-motion: reduce) {
.generation-edge-glow,
.generation-edge-glow *,
.generation-edge-glow *::before,
.generation-success,
.generation-success *,
.generation-success *::before,
.generation-success *::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
}
}
/* Local CDSL CAD Studio layout, built on the copied studio theme tokens. */
.studio-app { display: flex; flex-direction: column; height: 100vh; min-height: 0; overflow: hidden; background: var(--ui-app-bg); color: var(--ui-text); }
.app-header { display: flex; height: 48px; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--ui-border); background: var(--ui-header-bg); padding: 0 12px; }
.app-brand, .app-controls { display: flex; min-width: 0; align-items: center; gap: 8px; }
.app-brand { color: var(--ui-text-strong); font-size: 14px; }
.app-brand > svg { color: var(--ui-accent); }
.task-badge { max-width: 260px; overflow: hidden; border: 1px solid var(--ui-border); border-radius: 4px; color: var(--ui-text-muted); font-size: 11px; padding: 4px 7px; text-overflow: ellipsis; white-space: nowrap; }
.app-controls select, .theme-button { height: 30px; max-width: 180px; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 11px; padding: 0 8px; }
.theme-button { display: grid; width: 30px; place-items: center; padding: 0; }
.config-warning { display: flex; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-error-border); background: var(--ui-error-bg); color: var(--ui-error-text); font-size: 12px; padding: 8px 12px; }
.studio-main { display: flex; min-height: 0; flex: 1; }
.agent-pane { display: flex; width: 420px; min-width: 0; min-height: 0; flex: 0 0 auto; flex-direction: column; border-right: 1px solid var(--ui-border); background: var(--ui-panel); }
.preview-pane { min-width: 0; min-height: 0; flex: 1; background: var(--ui-viewer-bg); }
.agent-thread-shell, .thread-root { display: flex; min-height: 0; flex: 1; flex-direction: column; }
.agent-pane-title { display: flex; height: 40px; flex: 0 0 auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-text-strong); font-size: 12px; font-weight: 700; padding: 0 12px; }
.agent-pane-title svg { color: var(--ui-accent); }
.thread-viewport { min-height: 0; flex: 1; overflow-y: auto; padding: 12px; }
.message-list { display: grid; }
.message-row { display: flex; border-bottom: 1px solid var(--ui-border-muted); padding: 10px 2px; }
.user-row { justify-content: flex-end; }
.assistant-row { justify-content: flex-start; }
.message-bubble { max-width: 100%; min-width: 0; }
.user-bubble { max-width: 90%; border-radius: 5px; background: var(--ui-accent); color: var(--ui-accent-contrast); padding: 8px 10px; }
.assistant-bubble { width: 100%; color: var(--ui-text); }
.message-text { margin: 0; font-size: 12px; line-height: 1.65; overflow-wrap: anywhere; white-space: pre-wrap; }
.thread-empty { display: grid; gap: 7px; border: 1px dashed var(--ui-border-strong); border-radius: 5px; color: var(--ui-text-muted); font-size: 12px; line-height: 1.55; padding: 14px; }
.thread-empty strong { color: var(--ui-text-strong); }
.attachment-list { display: grid; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--ui-border-muted); padding-bottom: 10px; }
.attachment-card { display: flex; min-width: 0; align-items: center; gap: 7px; color: var(--ui-text-muted); font-size: 11px; }
.attachment-card svg { flex: 0 0 auto; color: var(--ui-accent); }.attachment-card span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.attachment-card small { margin-left: auto; color: var(--ui-text-subtle); font-size: 10px; white-space: nowrap; }
.composer-shell { flex: 0 0 auto; border-top: 1px solid var(--ui-border); padding: 12px; }
.composer-root { display: grid; gap: 8px; }
.composer-input { min-height: 96px; max-height: 176px; width: 100%; resize: none; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text); font-size: 12px; line-height: 1.55; outline: none; padding: 9px; }
.composer-input:focus { border-color: var(--ui-accent); }.composer-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ui-text-muted); font-size: 11px; }.composer-footer > span, .composer-footer > div { display: flex; align-items: center; gap: 7px; }.composer-action, .composer-send { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ui-border); border-radius: 4px; background: var(--ui-control-bg); color: var(--ui-text-muted); }.composer-send { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-contrast); }
.cad-card { display: flex; gap: 9px; margin: 6px 0; border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-panel-muted); padding: 9px; }.cad-card-icon { display: grid; flex: 0 0 auto; width: 25px; height: 25px; place-items: center; border-radius: 4px; background: var(--ui-accent-soft); color: var(--ui-accent); }.cad-card-body { min-width: 0; }.cad-card-title { color: var(--ui-text-strong); font-size: 12px; font-weight: 700; }.cad-card-copy, .cad-result-meta { color: var(--ui-text-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }.download-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }.download-link { border-bottom: 1px solid currentColor; color: var(--ui-link); font-size: 11px; text-decoration: none; }.cad-error-card { border-color: var(--ui-error-border); background: var(--ui-error-bg); }.cad-error-card .cad-card-icon { background: transparent; color: var(--ui-error-text); }
.viewer-state { display: flex; height: 100%; min-height: 42vh; align-items: center; justify-content: center; gap: 9px; color: var(--ui-text-muted); font-size: 12px; }.viewer-state svg { color: var(--ui-accent); }.viewer-state-error { color: var(--ui-error-text); }.viewer-state-error svg { color: var(--ui-error-text); }
.viewer-loading { position: absolute; z-index: 40; left: 50%; top: 50%; display: flex; align-items: center; gap: 8px; transform: translate(-50%, -50%); border: 1px solid var(--ui-border); border-radius: 5px; background: var(--ui-glass-popover); color: var(--ui-text-muted); font-size: 12px; padding: 9px 12px; box-shadow: var(--ui-shadow-soft); }
.cad-viewer-dark { background: var(--ui-viewer-bg); }
@media (max-width: 767px) { .studio-app { height: auto; min-height: 100vh; overflow: visible; }.app-header { height: auto; min-height: 48px; flex-wrap: wrap; padding: 8px 12px; }.task-badge { display: none; }.app-controls { width: 100%; }.app-controls select { flex: 1; }.studio-main { min-height: 0; flex-direction: column; }.preview-pane { order: -1; min-height: 46vh; }.agent-pane { width: 100%; min-height: 560px; border-top: 1px solid var(--ui-border); border-right: 0; }.thread-viewport { max-height: 480px; }.composer-footer > span { display: none; } }
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "CDSL CAD Studio",
description: "Agent chat for parameterized CDSL CAD generation.",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" data-ui-theme="light">
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AgentStudio } from "@/components/agent-studio";
export default function Page() {
return <AgentStudio />;
}
+484
View File
@@ -0,0 +1,484 @@
"use client";
import { AssistantRuntimeProvider, useAuiState } from "@assistant-ui/react";
import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { AlertCircle, Box, Loader2, Moon, Sun } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { latestSuccessfulResult } from "@/lib/cad-artifacts";
import { normalizeCadMessages } from "@/lib/cad-messages";
import type {
BackendConfig,
CadError,
CadAttachment,
CadResult,
CadUIMessage,
ConversationRecord,
TaskRecord,
} from "@/lib/cad-types";
import { AgentThread } from "./agent-thread";
import { CadViewerPreview } from "./cad-viewer-preview";
import type { AssistantRuntime } from "@assistant-ui/react";
type LoadState = "loading" | "ready" | "error";
export function AgentStudio() {
const [loadState, setLoadState] = useState<LoadState>("loading");
const [conversationId, setConversationId] = useState("");
const [selectedTaskId, setSelectedTaskId] = useState("");
const [initialMessages, setInitialMessages] = useState<CadUIMessage[]>([]);
const [config, setConfig] = useState<BackendConfig | null>(null);
const [cadResult, setCadResult] = useState<CadResult | null>(null);
const [lastError, setLastError] = useState("");
const [attachments, setAttachments] = useState<CadAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [providerId, setProviderId] = useState("");
const [modelId, setModelId] = useState("");
const [theme, setTheme] = useState<"light" | "dark">("light");
const syncUrl = useCallback((conversation: string, task: string) => {
const params = new URLSearchParams(window.location.search);
if (conversation) params.set("conversationId", conversation);
if (task) params.set("taskId", task);
else params.delete("taskId");
window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`);
}, []);
useEffect(() => {
let cancelled = false;
async function boot() {
try {
const params = new URLSearchParams(window.location.search);
let nextConversationId = params.get("conversationId") || "";
const urlTaskId = params.get("taskId") || "";
const [configResponse] = await Promise.all([fetch("/api/config", { cache: "no-store" })]);
if (!configResponse.ok) throw new Error(await configResponse.text());
const nextConfig = (await configResponse.json()) as BackendConfig;
let conversation: ConversationRecord;
if (nextConversationId) {
const response = await fetch(`/api/conversations/${encodeURIComponent(nextConversationId)}`, { cache: "no-store" });
if (!response.ok) throw new Error(await response.text());
conversation = (await response.json()) as ConversationRecord;
} else {
const response = await fetch("/api/conversations", { method: "POST" });
if (!response.ok) throw new Error(await response.text());
conversation = (await response.json()) as ConversationRecord;
nextConversationId = conversation.conversation_id;
}
const taskId = urlTaskId || conversation.current_task_id || "";
let restored: CadResult | null = null;
if (taskId) {
const taskResponse = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, { cache: "no-store" });
if (taskResponse.ok) {
restored = latestSuccessfulResult((await taskResponse.json()) as TaskRecord);
}
}
if (cancelled) return;
setConfig(nextConfig);
setConversationId(nextConversationId);
setSelectedTaskId(taskId);
setInitialMessages(normalizeCadMessages(conversation.messages));
setAttachments(conversation.attachments || []);
const defaultProvider = nextConfig.providers.find((provider) => provider.id === nextConfig.default_provider) ?? nextConfig.providers[0];
setProviderId(defaultProvider?.id || "");
setModelId(defaultProvider?.models.find((model) => model.id === nextConfig.default_model)?.id || defaultProvider?.models[0]?.id || "");
setCadResult(restored);
setLoadState("ready");
syncUrl(nextConversationId, taskId);
} catch (error) {
if (cancelled) return;
setLastError(error instanceof Error ? error.message : "启动失败");
setLoadState("error");
}
}
void boot();
return () => {
cancelled = true;
};
}, [syncUrl]);
useEffect(() => {
const stored = window.localStorage.getItem("cdsl-cad.ui-theme");
const next = stored === "dark" || stored === "light"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
setTheme(next);
document.documentElement.dataset.uiTheme = next;
}, []);
const toggleTheme = useCallback(() => {
setTheme((current) => {
const next = current === "light" ? "dark" : "light";
document.documentElement.dataset.uiTheme = next;
window.localStorage.setItem("cdsl-cad.ui-theme", next);
return next;
});
}, []);
const handleResult = useCallback((result: CadResult) => {
setCadResult(result);
setSelectedTaskId(result.taskId);
setLastError("");
if (conversationId) {
syncUrl(conversationId, result.taskId);
void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentTaskId: result.taskId }),
});
}
}, [conversationId, syncUrl]);
const handleError = useCallback((error: CadError) => {
setLastError(error.message);
}, []);
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
const selectedModel = config?.providers
.find((provider) => provider.id === providerId)
?.models.find((model) => model.id === modelId);
const includesImage = Array.from(files).some((file) => file.type.startsWith("image/") || /\.(png|jpe?g|webp)$/i.test(file.name));
if (includesImage && !selectedModel?.vision) {
setLastError("当前模型不支持图片。请选择标记为 Vision 的 OpenAI 或 Kimi 模型后再上传图片。");
return;
}
setUploading(true);
try {
const uploaded: CadAttachment[] = [];
for (const file of Array.from(files)) {
const form = new FormData();
form.set("file", file);
if (selectedTaskId) form.set("task_id", selectedTaskId);
const response = await fetch("/api/uploads", { method: "POST", body: form });
const payload = await response.json() as CadAttachment & { error?: string };
if (!response.ok) throw new Error(payload.error || `${file.name} 上传失败`);
uploaded.push(payload);
if (!selectedTaskId) setSelectedTaskId(payload.task_id);
}
setAttachments((current) => {
const next = [...current, ...uploaded];
if (conversationId) void fetch(`/api/conversations/${encodeURIComponent(conversationId)}`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentTaskId: selectedTaskId || uploaded[0]?.task_id || null, attachments: next }),
});
return next;
});
setLastError("");
} catch (error) {
setLastError(error instanceof Error ? error.message : "附件上传失败");
} finally {
setUploading(false);
}
}, [config, conversationId, modelId, providerId, selectedTaskId]);
if (loadState === "loading") {
return <StudioLoading />;
}
if (loadState === "error") {
return <StudioError message={lastError} />;
}
return (
<AgentRuntime
key={conversationId}
conversationId={conversationId}
selectedTaskId={selectedTaskId}
providerId={providerId}
modelId={modelId}
initialMessages={initialMessages}
onCadResult={handleResult}
onCadError={handleError}
>
<StudioShell
config={config}
cadResult={cadResult}
lastError={lastError}
attachments={attachments}
uploading={uploading}
onUpload={handleUpload}
theme={theme}
onToggleTheme={toggleTheme}
providerId={providerId}
modelId={modelId}
onProviderChange={setProviderId}
onModelChange={setModelId}
onCadResult={handleResult}
onCadError={handleError}
/>
</AgentRuntime>
);
}
function AgentRuntime({
conversationId,
selectedTaskId,
providerId,
modelId,
initialMessages,
onCadResult,
onCadError,
children,
}: {
conversationId: string;
selectedTaskId: string;
providerId: string;
modelId: string;
initialMessages: CadUIMessage[];
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
children: React.ReactNode;
}) {
const conversationRef = useRef(conversationId);
const taskRef = useRef(selectedTaskId);
const providerRef = useRef(providerId);
const modelRef = useRef(modelId);
const onCadResultRef = useRef(onCadResult);
const onCadErrorRef = useRef(onCadError);
conversationRef.current = conversationId;
taskRef.current = selectedTaskId;
providerRef.current = providerId;
modelRef.current = modelId;
onCadResultRef.current = onCadResult;
onCadErrorRef.current = onCadError;
const transport = useMemo(() => new DefaultChatTransport<CadUIMessage>({
api: "/api/chat",
prepareSendMessagesRequest: (options) => ({
body: {
...options.body,
id: options.id,
messages: options.messages,
conversationId: conversationRef.current,
selectedTaskId: taskRef.current || null,
providerId: providerRef.current || null,
modelId: modelRef.current || null,
trigger: options.trigger,
messageId: options.messageId,
},
}),
}), []);
const chat = useChat<CadUIMessage>({
id: conversationId,
messages: initialMessages,
transport,
onData: (part) => {
if (part.type === "data-cad-result") {
onCadResultRef.current(part.data as CadResult);
taskRef.current = (part.data as CadResult).taskId;
}
if (part.type === "data-cad-error") {
onCadErrorRef.current(part.data as CadError);
}
},
onError: (error) => {
onCadErrorRef.current({ stage: "chat", message: error.message });
},
});
const runtime = useAISDKRuntime<CadUIMessage>(chat, {
joinStrategy: "none",
});
const stableRuntime = useMemo(() => stabilizeAssistantRuntimeSnapshots(runtime), [runtime]);
return <AssistantRuntimeProvider runtime={stableRuntime}>{children}</AssistantRuntimeProvider>;
}
const stableSnapshotSymbol = Symbol.for("cdsl-cad.stableRuntimeSnapshot");
const stableChildRuntimeSymbol = Symbol.for("cdsl-cad.stableChildRuntimeMethods");
type SnapshotRuntime = {
getState: () => unknown;
[stableSnapshotSymbol]?: true;
[stableChildRuntimeSymbol]?: true;
};
function stabilizeAssistantRuntimeSnapshots(runtime: AssistantRuntime) {
stabilizeThreadListRuntime(runtime.threads);
stabilizeThreadRuntime(runtime.thread);
return runtime;
}
function stabilizeThreadListRuntime(runtime: AssistantRuntime["threads"]) {
const threadList = runtime as AssistantRuntime["threads"] & SnapshotRuntime;
stabilizeSnapshot(threadList);
stabilizeThreadRuntime(threadList.main);
stabilizeSnapshotIfRuntime(threadList.mainItem);
if (threadList[stableChildRuntimeSymbol]) return;
wrapRuntimeFactory(threadList, "getById", stabilizeThreadRuntime);
wrapRuntimeFactory(threadList, "getItemById", stabilizeSnapshotIfRuntime);
wrapRuntimeFactory(threadList, "getItemByIndex", stabilizeSnapshotIfRuntime);
wrapRuntimeFactory(threadList, "getArchivedItemByIndex", stabilizeSnapshotIfRuntime);
threadList[stableChildRuntimeSymbol] = true;
}
function stabilizeThreadRuntime(runtime: unknown) {
if (!isSnapshotRuntime(runtime)) return runtime;
stabilizeSnapshot(runtime);
const thread = runtime as SnapshotRuntime & {
composer?: unknown;
getMessageById?: (...args: unknown[]) => unknown;
getMessageByIndex?: (...args: unknown[]) => unknown;
};
stabilizeComposerRuntime(thread.composer);
if (thread[stableChildRuntimeSymbol]) return thread;
wrapRuntimeFactory(thread, "getMessageById", stabilizeMessageRuntime);
wrapRuntimeFactory(thread, "getMessageByIndex", stabilizeMessageRuntime);
thread[stableChildRuntimeSymbol] = true;
return thread;
}
function stabilizeMessageRuntime(runtime: unknown) {
if (!isSnapshotRuntime(runtime)) return runtime;
stabilizeSnapshot(runtime);
const message = runtime as SnapshotRuntime & {
composer?: unknown;
getAttachmentByIndex?: (...args: unknown[]) => unknown;
getMessagePartByIndex?: (...args: unknown[]) => unknown;
getMessagePartByToolCallId?: (...args: unknown[]) => unknown;
};
stabilizeComposerRuntime(message.composer);
if (message[stableChildRuntimeSymbol]) return message;
wrapRuntimeFactory(message, "getAttachmentByIndex", stabilizeSnapshotIfRuntime);
wrapRuntimeFactory(message, "getMessagePartByIndex", stabilizeSnapshotIfRuntime);
wrapRuntimeFactory(message, "getMessagePartByToolCallId", stabilizeSnapshotIfRuntime);
message[stableChildRuntimeSymbol] = true;
return message;
}
function stabilizeComposerRuntime(runtime: unknown) {
if (!isSnapshotRuntime(runtime)) return runtime;
stabilizeSnapshot(runtime);
const composer = runtime as SnapshotRuntime & {
getAttachmentByIndex?: (...args: unknown[]) => unknown;
};
if (composer[stableChildRuntimeSymbol]) return composer;
wrapRuntimeFactory(composer, "getAttachmentByIndex", stabilizeSnapshotIfRuntime);
composer[stableChildRuntimeSymbol] = true;
return composer;
}
function stabilizeSnapshot(runtime: SnapshotRuntime) {
if (runtime[stableSnapshotSymbol]) return;
const getState = runtime.getState.bind(runtime);
let previous: unknown;
runtime.getState = () => {
const next = getState();
if (isShallowSameSnapshot(previous, next)) return previous;
previous = next;
return next;
};
runtime[stableSnapshotSymbol] = true;
}
function stabilizeSnapshotIfRuntime(value: unknown) {
if (isSnapshotRuntime(value)) stabilizeSnapshot(value);
return value;
}
function wrapRuntimeFactory(
runtime: Record<PropertyKey, unknown>,
method: string,
stabilize: (value: unknown) => unknown,
) {
const original = runtime[method];
if (typeof original !== "function") return;
runtime[method] = (...args: unknown[]) => stabilize(original.apply(runtime, args));
}
function isSnapshotRuntime(value: unknown): value is SnapshotRuntime {
return Boolean(value && typeof value === "object" && typeof (value as SnapshotRuntime).getState === "function");
}
function isShallowSameSnapshot(left: unknown, right: unknown) {
if (Object.is(left, right)) return true;
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
if (leftKeys.length !== Object.keys(rightRecord).length) return false;
return leftKeys.every((key) => Object.is(leftRecord[key], rightRecord[key]));
}
function StudioShell({
config,
cadResult,
lastError,
attachments,
uploading,
onUpload,
theme,
onToggleTheme,
providerId,
modelId,
onProviderChange,
onModelChange,
onCadResult,
onCadError,
}: {
config: BackendConfig | null;
cadResult: CadResult | null;
lastError: string;
attachments: CadAttachment[];
uploading: boolean;
onUpload: (files: FileList | null) => void;
theme: "light" | "dark";
onToggleTheme: () => void;
providerId: string;
modelId: string;
onProviderChange: (id: string) => void;
onModelChange: (id: string) => void;
onCadResult: (result: CadResult) => void;
onCadError: (error: CadError) => void;
}) {
const running = useAuiState((state) => state.thread.isRunning);
const provider = config?.providers.find((item) => item.id === providerId);
return (
<main className="studio-app">
<header className="app-header">
<div className="app-brand"><Box size={16} /><strong>CDSL CAD Studio</strong>{cadResult ? <span className="task-badge">{cadResult.taskId}</span> : null}</div>
<div className="app-controls">
<select aria-label="模型提供商" value={providerId} onChange={(event) => { const id = event.target.value; onProviderChange(id); onModelChange(config?.providers.find((item) => item.id === id)?.models[0]?.id || ""); }}>
{config?.providers.map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}
</select>
<select aria-label="模型" value={modelId} onChange={(event) => onModelChange(event.target.value)}>
{provider?.models.map((model) => <option key={model.id} value={model.id}>{model.id}{model.vision ? " · Vision" : ""}</option>)}
</select>
<button className="theme-button" type="button" title="切换亮暗主题" onClick={onToggleTheme}>{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}</button>
</div>
</header>
{!config?.configured ? <div className="config-warning"><AlertCircle size={16} /><span>未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。</span></div> : null}
<div className="studio-main">
<aside className="agent-pane"><AgentThread attachments={attachments} uploading={uploading} onUpload={onUpload} /></aside>
<section className="preview-pane"><CadViewerPreview result={cadResult} isGenerating={running} lastError={lastError} theme={theme} onResult={onCadResult} onError={(message) => onCadError({ stage: "viewer", message })} /></section>
</div>
</main>
);
}
function StudioLoading() {
return (
<main className="boot-screen">
<Loader2 className="spin" size={22} />
<span>启动 CDSL CAD Studio</span>
</main>
);
}
function StudioError({ message }: { message: string }) {
return (
<main className="boot-screen boot-error">
<AlertCircle size={22} />
<span>{message}</span>
</main>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { Check, FileImage, FileText, Loader2, MessageSquare, Paperclip, Send, Square } from "lucide-react";
import { useRef } from "react";
import { ComposerPrimitive, MessagePrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import type { CadAttachment } from "@/lib/cad-types";
import { CadErrorPart, CadProgressPart, CadResultPart, TextPart } from "./cad-message-parts";
export function AgentThread({ attachments, uploading, onUpload }: {
attachments: CadAttachment[];
uploading: boolean;
onUpload: (files: FileList | null) => void;
}) {
const fileInput = useRef<HTMLInputElement>(null);
return (
<div className="agent-thread-shell">
<div className="agent-pane-title"><MessageSquare size={16} /><span>Agent</span></div>
<ThreadPrimitive.Root className="thread-root">
<ThreadPrimitive.Viewport className="thread-viewport scrollbar-thin" autoScroll>
{attachments.length ? <div className="attachment-list">{attachments.map((attachment) => <AttachmentCard key={attachment.id} attachment={attachment} />)}</div> : null}
<ThreadPrimitive.Empty>
<div className="thread-empty"><strong>描述要生成或修改的 CAD 模型</strong><span>Agent 会检索本地 CDSL 样本并生成可编辑的 CDSL 模型。</span></div>
</ThreadPrimitive.Empty>
<div className="message-list"><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div>
</ThreadPrimitive.Viewport>
<Composer fileInput={fileInput} uploading={uploading} onUpload={onUpload} />
</ThreadPrimitive.Root>
</div>
);
}
function AttachmentCard({ attachment }: { attachment: CadAttachment }) {
const Icon = attachment.kind === "image" ? FileImage : FileText;
return <div className="attachment-card"><Icon size={14} /><span>{attachment.name}</span><small>{attachment.kind === "image" ? "视觉参考" : "文本参考"}</small></div>;
}
function UserMessage() {
return <MessagePrimitive.Root className="message-row user-row"><div className="message-bubble user-bubble"><MessagePrimitive.Parts components={{ Text: TextPart }} /></div></MessagePrimitive.Root>;
}
function AssistantMessage() {
return (
<MessagePrimitive.Root className="message-row assistant-row">
<div className="message-bubble assistant-bubble"><MessagePrimitive.Parts components={{ Text: TextPart, data: { by_name: { "cad-progress": CadProgressPart, "cad-result": CadResultPart, "cad-error": CadErrorPart } } }} /></div>
</MessagePrimitive.Root>
);
}
function Composer({ fileInput, uploading, onUpload }: { fileInput: React.RefObject<HTMLInputElement | null>; uploading: boolean; onUpload: (files: FileList | null) => void }) {
const running = useAuiState((state) => state.thread.isRunning);
return (
<div className="composer-shell">
<input ref={fileInput} className="hidden" type="file" accept=".png,.jpg,.jpeg,.webp,.txt,.md,.csv,.json" multiple onChange={(event) => onUpload(event.target.files)} />
<ComposerPrimitive.Root className="composer-root">
<ComposerPrimitive.Input className="composer-input" placeholder="描述要生成或修改的 CAD 模型..." submitMode="enter" rows={4} />
<div className="composer-footer"><span><Check size={14} /> Enter 发送,Shift + Enter 换行</span><div>{running ? <ComposerPrimitive.Cancel className="composer-action" title="停止生成"><Square size={15} /></ComposerPrimitive.Cancel> : <button type="button" className="composer-action" title="上传图片或文档" disabled={uploading} onClick={() => fileInput.current?.click()}>{uploading ? <Loader2 className="spin" size={15} /> : <Paperclip size={15} />}</button>}<ComposerPrimitive.Send className="composer-send" title="发送"><Send size={16} /></ComposerPrimitive.Send></div></div>
</ComposerPrimitive.Root>
</div>
);
}
@@ -0,0 +1,71 @@
"use client";
import { AlertTriangle, CheckCircle2, Download, Loader2 } from "lucide-react";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import type { CadError, CadProgress, CadResult } from "@/lib/cad-types";
export function TextPart({ text }: { text: string }) {
if (!text.trim()) return null;
return <p className="message-text">{text}</p>;
}
export function CadProgressPart({ data }: { data: CadProgress }) {
const running = data.status === "running";
return (
<div className="cad-card cad-progress-card">
<div className="cad-card-icon" data-status={data.status}>
{running ? <Loader2 className="spin" size={16} /> : <CheckCircle2 size={16} />}
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.label || data.step}</div>
{data.message ? <div className="cad-card-copy">{data.message}</div> : null}
</div>
</div>
);
}
export function CadResultPart({ data }: { data: CadResult }) {
const downloads = [
["STEP", data.stepPath],
["CDSL", data.cdslPath],
["GLB", data.glbPath],
["REPORT", data.reportPath],
] as const;
return (
<div className="cad-card cad-result-card">
<div className="cad-card-icon success">
<CheckCircle2 size={16} />
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.summary || "生成完成"}</div>
<div className="cad-result-meta">
<span>{data.engine}</span>
<span>{data.revisionId}</span>
<span>{data.referenceIds.length} references</span>
</div>
<div className="download-row">
{downloads.map(([label, path]) => (
<a key={label} className="download-link" href={encodeArtifactUrl(data.taskId, path)} download>
<Download size={14} />
{label}
</a>
))}
</div>
</div>
</div>
);
}
export function CadErrorPart({ data }: { data: CadError }) {
return (
<div className="cad-card cad-error-card">
<div className="cad-card-icon error">
<AlertTriangle size={16} />
</div>
<div className="cad-card-body">
<div className="cad-card-title">{data.stage || "生成失败"}</div>
<div className="cad-card-copy">{data.message}</div>
</div>
</div>
);
}
@@ -0,0 +1,524 @@
"use client";
import { AlertTriangle, Box, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import CadViewer from "@/viewer-port/components/CadViewer";
import { RENDER_FORMAT } from "@/viewer-port/workbench/constants";
import { loadRenderGlb, loadRenderJson } from "@/viewer-runtime/lib/renderAssetClient";
import { cloneThemePresetSettings } from "@/viewer-runtime/lib/themeSettings";
import { encodeArtifactUrl } from "@/lib/cad-artifacts";
import { buildCdslSelectorRuntime } from "@/lib/cdsl-selector-runtime";
import { cadEditToolForOperation, cadEditToolNextPickKind, cadEditToolPickComplete, defaultCadEditParameters, type AiSelectionMode } from "@/lib/cad-edit-tools";
import type { CadResult } from "@/lib/cad-types";
import { EmbeddedCadEditToolbar, EmbeddedCadViewToolbar } from "./embedded-cad-toolbar";
import { GenerationEdgeGlow } from "./generation-edge-glow";
import { GenerationSuccessReveal } from "./generation-success-reveal";
import { ParameterPanel } from "./parameter-panel";
type Props = {
result: CadResult | null;
isGenerating: boolean;
lastError?: string;
theme: "light" | "dark";
onResult: (result: CadResult) => void;
onError: (message: string) => void;
};
type SelectorRuntime = ReturnType<typeof buildCdslSelectorRuntime>;
type LoadState = { kind: "empty" | "loading" | "error" } | { kind: "ready"; meshData: unknown; selectorRuntime: SelectorRuntime | null };
type AiSelectionDraft = {
active?: boolean;
points?: Array<{ x: number; y: number }>;
} | null;
const VIEWER_THEME = (() => {
const base = cloneThemePresetSettings("workbench") as Record<string, unknown>;
return {
...base,
materials: { ...(base.materials as Record<string, unknown>), defaultColor: "#d7dce2", roughness: 0.36, metalness: 0.18 },
edges: { ...(base.edges as Record<string, unknown>), enabled: true, color: "#768493", opacity: 0.22, thickness: 1 },
floor: { ...(base.floor as Record<string, unknown>), enabled: false, mode: "none" },
environment: { ...(base.environment as Record<string, unknown>), enabled: false },
};
})();
function resultFromBackend(payload: Record<string, unknown>): CadResult {
return {
taskId: String(payload.task_id), revisionId: String(payload.revision_id),
cdslPath: String(payload.cdsl_path), stepPath: String(payload.step_path),
glbPath: String(payload.glb_path), reportPath: String(payload.report_path),
parametersPath: typeof payload.parameters_path === "string" ? payload.parameters_path : undefined,
selectorPath: typeof payload.selector_path === "string" ? payload.selector_path : undefined,
edgesPath: typeof payload.edges_path === "string" ? payload.edges_path : undefined,
summary: String(payload.summary || "Updated CDSL model"),
referenceIds: Array.isArray(payload.reference_ids) ? payload.reference_ids.map(String) : [],
engine: String(payload.engine || "cdsl_only"),
};
}
function finiteClientPoint(pick: Record<string, unknown> | null) {
const x = Number(pick?.clientX);
const y = Number(pick?.clientY);
return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
}
function editToolPickMarkerLabel(activeToolId: string, index: number) {
if (activeToolId === "add_slot") return index === 0 ? "起点" : "终点";
if (activeToolId === "add_hole_pattern") return "中心";
return "位置";
}
function editParameter(parameters: Record<string, string | number>, name: string, fallback: number) {
const value = Number(parameters[name]);
return Number.isFinite(value) ? value : fallback;
}
function previewPixels(valueMm: number, scale = 5, min = 12, max = 180) {
const value = Math.abs(valueMm);
return Number.isFinite(value) && value > 0 ? Math.min(Math.max(value * scale, min), max) : min;
}
function AiSelectionOverlay({ draft }: { draft: AiSelectionDraft }) {
const points = draft?.active && Array.isArray(draft.points) ? draft.points : [];
const polylinePoints = points.map((point) => `${Number(point.x)},${Number(point.y)}`).join(" ");
if (!polylinePoints) return null;
return (
<div className="pointer-events-none fixed inset-0 z-[26]" aria-hidden="true">
<svg className="fixed inset-0 size-full overflow-visible">
<polyline
points={polylinePoints}
fill="none"
className="stroke-[var(--ui-accent)] opacity-[0.85]"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function EditToolPickOverlay({
activeToolId,
picks,
hoverPick,
parameters,
}: {
activeToolId: string;
picks: Record<string, unknown>[];
hoverPick: Record<string, unknown> | null;
parameters: Record<string, string | number>;
}) {
if (!activeToolId) return null;
const selected = picks.flatMap((pick, index) => {
const point = finiteClientPoint(pick);
return point ? [{ pick, index, point, hover: false }] : [];
});
const hoverPoint = finiteClientPoint(hoverPick);
const hover = hoverPoint && !selected.some((entry) => Math.hypot(entry.point.x - hoverPoint.x, entry.point.y - hoverPoint.y) < 1)
? { pick: hoverPick || {}, index: selected.length, point: hoverPoint, hover: true }
: null;
const points = hover ? [...selected, hover] : selected;
if (!points.length) return null;
const slot = activeToolId === "add_slot" && points.length >= 2
? { start: points[0].point, end: points[1].point, preview: points[1].hover }
: null;
const anchor = activeToolId === "add_slot" ? null : (selected.at(-1)?.point || hover?.point || null);
const tool = activeToolId === "add_counterbore" ? "double" : activeToolId;
const circle = anchor && ["add_hole", "double", "add_countersink", "add_circular_pocket"].includes(tool)
? {
point: anchor,
inner: previewPixels(editParameter(parameters, tool === "add_circular_pocket" ? "diameter" : "holeDiameter", tool === "add_circular_pocket" ? 8 : 3)),
outer: tool === "double"
? previewPixels(editParameter(parameters, "counterboreDiameter", 6))
: tool === "add_countersink"
? previewPixels(editParameter(parameters, "countersinkDiameter", 6))
: 0,
dashed: tool === "add_countersink",
}
: null;
const pocket = anchor && activeToolId === "add_pocket"
? { point: anchor, width: previewPixels(editParameter(parameters, "width", 10), 4, 24, 240), height: previewPixels(editParameter(parameters, "height", 6), 4, 18, 180) }
: null;
const pattern = anchor && activeToolId === "add_hole_pattern"
? {
point: anchor,
rows: Math.min(Math.max(Math.round(editParameter(parameters, "rows", 2)), 1), 8),
columns: Math.min(Math.max(Math.round(editParameter(parameters, "columns", 2)), 1), 8),
pitchX: previewPixels(editParameter(parameters, "pitchX", 8), 3, 18, 90),
pitchY: previewPixels(editParameter(parameters, "pitchY", 8), 3, 18, 90),
diameter: previewPixels(editParameter(parameters, "holeDiameter", 2), 4, 8, 48),
}
: null;
const slotWidth = slot ? previewPixels(editParameter(parameters, "slotWidth", 2), 5, 12, 80) : 0;
return (
<div className="pointer-events-none fixed inset-0 z-[25]" aria-hidden="true">
{(slot || circle || pocket || pattern) ? (
<svg className="fixed inset-0 size-full overflow-visible">
{slot ? <>
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className="stroke-[var(--ui-accent-soft)]" strokeWidth={slotWidth} strokeLinecap="round" />
<line x1={slot.start.x} y1={slot.start.y} x2={slot.end.x} y2={slot.end.y} className={slot.preview ? "stroke-[var(--ui-accent)] opacity-65" : "stroke-[var(--ui-accent)] opacity-90"} strokeWidth="2.5" strokeLinecap="round" strokeDasharray={slot.preview ? "7 5" : "none"} />
</> : null}
{circle ? <>
{circle.outer ? <circle cx={circle.point.x} cy={circle.point.y} r={circle.outer / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.dashed ? "7 4" : "none"} /> : null}
<circle cx={circle.point.x} cy={circle.point.y} r={circle.inner / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray={circle.outer ? "4 4" : "5 4"} />
</> : null}
{pocket ? <rect x={pocket.point.x - pocket.width / 2} y={pocket.point.y - pocket.height / 2} width={pocket.width} height={pocket.height} rx="6" className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="2" strokeDasharray="6 4" /> : null}
{pattern ? Array.from({ length: pattern.rows * pattern.columns }, (_, index) => {
const row = Math.floor(index / pattern.columns);
const column = index % pattern.columns;
return <circle key={index} cx={pattern.point.x + (column - (pattern.columns - 1) / 2) * pattern.pitchX} cy={pattern.point.y + (row - (pattern.rows - 1) / 2) * pattern.pitchY} r={pattern.diameter / 2} className="fill-[var(--ui-accent-soft)] stroke-[var(--ui-accent)]" strokeWidth="1.5" strokeDasharray="4 3" />;
}) : null}
</svg>
) : null}
{points.map(({ index, point, hover: isHover }) => (
<div key={`${index}:${point.x}:${point.y}`} className="fixed" style={{ left: `${point.x}px`, top: `${point.y}px`, transform: "translate(-50%, -50%)" }}>
<span className={`absolute left-1/2 top-1/2 h-12 w-px -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
<span className={`absolute left-1/2 top-1/2 h-px w-12 -translate-x-1/2 -translate-y-1/2 bg-[var(--ui-accent-muted)] ${isHover ? "opacity-60" : "opacity-100"}`} />
<span className={`absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-[var(--ui-accent-soft)] shadow-[0_0_22px_var(--ui-accent-muted)] ${isHover ? "border-[var(--ui-accent-border)] border-dashed opacity-75" : "border-[var(--ui-accent-border)]"}`} />
<span className={`relative block rounded-full border border-[var(--ui-text-inverse)] bg-[var(--ui-accent)] shadow-[var(--ui-shadow-soft)] ${isHover ? "size-2 opacity-70" : "size-3"}`} />
<span className="absolute left-4 top-3 whitespace-nowrap rounded-full border border-[var(--ui-accent-border)] bg-[var(--ui-glass-popover)] px-2 py-0.5 text-[10px] font-semibold text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)]">{isHover ? "预选" : editToolPickMarkerLabel(activeToolId, index)}</span>
</div>
))}
</div>
);
}
export function CadViewerPreview({ result, isGenerating, lastError, theme, onResult, onError }: Props) {
const viewerRef = useRef<{ captureScreenshot?: (options?: unknown) => Promise<void>; zoomToFit?: () => void } | null>(null);
// A task restored from the URL should appear quietly after a page refresh.
// Subsequent revisions in this mounted workspace still receive completion feedback.
const suppressInitialReveal = useRef(Boolean(result));
const [loadState, setLoadState] = useState<LoadState>({ kind: "empty" });
const [activeTool, setActiveTool] = useState("");
const [editPicks, setEditPicks] = useState<Record<string, unknown>[]>([]);
const [editParameters, setEditParameters] = useState<Record<string, string | number>>({});
const [editHoverPick, setEditHoverPick] = useState<Record<string, unknown> | null>(null);
const [editSelectionReady, setEditSelectionReady] = useState(false);
const [aiSelectionDraft, setAiSelectionDraft] = useState<AiSelectionDraft>(null);
const [hoveredReferenceId, setHoveredReferenceId] = useState("");
const [selectedReferenceIds, setSelectedReferenceIds] = useState<string[]>([]);
const [selectionMode, setSelectionMode] = useState<AiSelectionMode>("point");
const [editPending, setEditPending] = useState(false);
const [reveal, setReveal] = useState(0);
const [showParameters, setShowParameters] = useState(false);
const [parameters, setParameters] = useState<Record<string, unknown>[]>([]);
const [parameterPending, setParameterPending] = useState("");
const [parameterError, setParameterError] = useState("");
useEffect(() => {
if (!result) {
setLoadState((current) => current.kind === "ready" ? current : { kind: "empty" });
return;
}
const controller = new AbortController();
setLoadState((current) => current.kind === "ready" ? current : { kind: "loading" });
const glbUrl = encodeArtifactUrl(result.taskId, result.glbPath);
const selectorUrl = result.selectorPath ? encodeArtifactUrl(result.taskId, result.selectorPath) : "";
void Promise.all([
loadRenderGlb(glbUrl),
selectorUrl ? loadRenderJson(selectorUrl).catch(() => null) : Promise.resolve(null),
])
.then(([meshData, selectorSidecar]) => {
if (controller.signal.aborted) return;
const selectorRuntime = selectorSidecar && typeof selectorSidecar === "object"
? buildCdslSelectorRuntime(selectorSidecar, meshData)
: null;
setLoadState({ kind: "ready", meshData, selectorRuntime });
setHoveredReferenceId("");
setSelectedReferenceIds([]);
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
setAiSelectionDraft(null);
if (suppressInitialReveal.current) {
suppressInitialReveal.current = false;
} else {
setReveal((value) => value + 1);
}
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setLoadState((current) => current.kind === "ready" ? current : { kind: "error" });
onError(error instanceof Error ? error.message : "CAD Viewer asset loading failed");
});
return () => controller.abort();
}, [onError, result?.glbPath, result?.revisionId, result?.selectorPath, result?.taskId]);
useEffect(() => {
if (!reveal) return;
const timer = window.setTimeout(() => setReveal(0), 3600);
return () => window.clearTimeout(timer);
}, [reveal]);
useEffect(() => {
if (!result) {
setParameters([]);
setShowParameters(false);
return;
}
const controller = new AbortController();
setParameterError("");
void fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, { signal: controller.signal })
.then(async (response) => {
if (response.status === 404) return [];
if (!response.ok) throw new Error(await response.text());
const payload = await response.json() as { parameters?: Record<string, unknown>[] };
return Array.isArray(payload.parameters) ? payload.parameters : [];
})
.then((next) => { if (!controller.signal.aborted) setParameters(next); })
.catch((error: unknown) => {
if (!controller.signal.aborted) setParameterError(error instanceof Error ? error.message : "无法读取参数");
});
return () => controller.abort();
}, [result?.revisionId, result?.taskId]);
const submitEdit = useCallback(async (operation: string, picks: Record<string, unknown>[]) => {
if (!result || !operation || !picks.length) return;
setEditPending(true);
try {
const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/modify`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ operation, selection: { pick: picks[0], picks }, parameters: editParameters }),
});
const payload = await response.json() as Record<string, unknown> & { error?: string };
if (!response.ok) throw new Error(payload.error || "Direct CAD edit failed");
onResult(resultFromBackend(payload));
setActiveTool("");
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
} catch (error) {
onError(error instanceof Error ? error.message : "Direct CAD edit failed");
} finally {
setEditPending(false);
}
}, [editParameters, onError, onResult, result]);
const onEditPick = useCallback((pick: Record<string, unknown> | null) => {
if (!activeTool || editPending) return;
if (!pick) {
onError("未命中可编辑平面,请在模型实体表面点击。");
return;
}
setEditPicks((current) => {
if (cadEditToolPickComplete(activeTool, current)) return current;
const next = [...current, pick];
const referenceId = typeof pick.referenceId === "string" ? pick.referenceId : "";
if (referenceId) setSelectedReferenceIds([referenceId]);
setEditHoverPick(null);
setEditSelectionReady(cadEditToolPickComplete(activeTool, next));
return next;
});
}, [activeTool, editPending, onError]);
const handleAiSelectionDraftChange = useCallback((draft: AiSelectionDraft) => {
setAiSelectionDraft((current) => {
const currentPoints = current?.points || [];
const nextPoints = draft?.points || [];
if (current?.active === draft?.active && currentPoints.length === nextPoints.length && currentPoints.every((point, index) => point.x === nextPoints[index]?.x && point.y === nextPoints[index]?.y)) {
return current;
}
return draft;
});
}, []);
const cancelEdit = useCallback(() => {
setActiveTool("");
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
setSelectedReferenceIds([]);
setSelectionMode("point");
}, []);
const onAiSelectionComplete = useCallback((selection: { referenceIds?: unknown } | null) => {
const referenceIds = Array.isArray(selection?.referenceIds)
? selection.referenceIds.filter((value): value is string => typeof value === "string" && value.length > 0)
: [];
setSelectedReferenceIds(referenceIds);
}, []);
const commitParameters = useCallback(async (values: Record<string, number>) => {
if (!result || !Object.keys(values).length) return;
const parameterId = Object.keys(values)[0];
setParameterPending(parameterId);
setParameterError("");
try {
const response = await fetch(`/api/tasks/${encodeURIComponent(result.taskId)}/parameters`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values }),
});
const payload = await response.json() as Record<string, unknown> & { error?: string };
if (!response.ok) throw new Error(payload.error || "参数更新失败");
onResult(resultFromBackend(payload));
} catch (error) {
setParameterError(error instanceof Error ? error.message : "参数更新失败");
} finally {
setParameterPending("");
}
}, [onResult, result]);
const viewerTheme = useMemo(() => ({ ...VIEWER_THEME, colorMode: theme }), [theme]);
const activeToolDefinition = activeTool ? cadEditToolForOperation(activeTool) : null;
const pickableFaces = useMemo(
() => loadState.kind === "ready" ? loadState.selectorRuntime?.references.filter((reference) => reference.selectorType === "face") || [] : [],
[loadState]
);
if (loadState.kind === "empty") return <ViewerState icon={<Box size={20} />} text="3D 预览等待模型" />;
if (loadState.kind === "loading") return <ViewerState icon={<Loader2 className="spin" size={20} />} text="加载 CAD Viewer 资产..." />;
if (loadState.kind === "error") return <ViewerState icon={<AlertTriangle size={20} />} text={lastError || "CAD Viewer 资产加载失败"} error />;
if (loadState.kind !== "ready") return null;
return (
<div className={`cad-deepbuild-viewer-bg relative h-full min-h-0 overflow-hidden ${theme === "dark" ? "cad-viewer-dark" : ""}`}>
<CadViewer
ref={viewerRef}
meshData={loadState.meshData}
modelKey={`${result?.taskId}:${result?.revisionId}`}
renderFormat={RENDER_FORMAT.STEP}
showEdges
recomputeNormals={false}
themeSettings={viewerTheme}
theme={theme === "dark" ? { sceneBackground: "#0d0f11" } : { sceneBackground: "#fbfbfc" }}
showViewPlane
generationIntroKey={`${result?.taskId}:${reveal}`}
// The enclosing studio owns completion feedback. Enabling CadViewer's
// shader reveal here would play a second, separate animation.
generationIntroEnabled={false}
hoveredReferenceId={hoveredReferenceId}
selectedReferenceIds={selectedReferenceIds}
selectorRuntime={loadState.selectorRuntime}
pickableFaces={pickableFaces}
pickableEdges={[]}
onHoverReferenceChange={setHoveredReferenceId}
onActivateReference={(referenceId: string) => setSelectedReferenceIds(referenceId ? [referenceId] : [])}
editPointPickEnabled={Boolean(activeTool)}
activeEditToolId={activeTool}
editToolPickKind={cadEditToolNextPickKind(activeTool, editPicks.length)}
editToolPicks={editPicks}
editToolHoverPick={editHoverPick}
editToolParameters={editParameters}
onEditToolPick={onEditPick}
onEditToolHover={setEditHoverPick}
aiSelectionMode={selectionMode === "none" ? "" : selectionMode}
onAiSelectionDraftChange={handleAiSelectionDraftChange}
onAiSelectionComplete={onAiSelectionComplete}
/>
<AiSelectionOverlay draft={aiSelectionDraft} />
<EditToolPickOverlay
activeToolId={activeTool}
picks={editPicks}
hoverPick={editHoverPick}
parameters={editParameters}
/>
<EmbeddedCadEditToolbar
activeToolId={activeTool}
aiSelectionMode={selectionMode}
disabled={!result || editPending}
unavailableToolIds={["add_chamfer", "add_fillet"]}
onSelectTool={(tool) => {
setActiveTool(tool);
setEditPicks([]);
setEditHoverPick(null);
setEditSelectionReady(false);
setAiSelectionDraft(null);
setHoveredReferenceId("");
setSelectedReferenceIds([]);
setEditParameters(tool ? defaultCadEditParameters(tool) : {});
setSelectionMode(tool ? "none" : "point");
}}
onSelectionModeChange={(mode) => {
setSelectionMode(mode);
if (mode !== "lasso") setAiSelectionDraft(null);
}}
/>
<EmbeddedCadViewToolbar
disabled={!result}
onResetView={() => viewerRef.current?.zoomToFit?.()}
onScreenshot={() => void viewerRef.current?.captureScreenshot?.({ filename: "cdsl-cad.png" })}
onParameters={() => setShowParameters(true)}
/>
{editPending || isGenerating ? <div className="viewer-loading"><Loader2 className="spin" size={16} /><span>{editPending ? "正在应用 CDSL 编辑..." : "正在生成 CDSL 模型..."}</span></div> : null}
{activeToolDefinition ? (
<div className="absolute bottom-3 left-3 z-30 w-[236px] border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-soft)] backdrop-blur" data-viewer-interaction-overlay="true">
<div className="mb-2 text-xs font-semibold">{activeToolDefinition.label}</div>
<div className="grid gap-2">
{activeToolDefinition.parameterFields.map((field) => (
<label className="grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 text-[11px] text-[var(--ui-text-muted)]" key={field.name}>
<span>{field.label}</span>
{field.type === "select" ? (
<select
className="h-7 min-w-0 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
value={String(editParameters[field.name] ?? "")}
onChange={(event) => setEditParameters((current) => ({ ...current, [field.name]: event.target.value }))}
>
{(field.options || []).map((option) => <option key={option} value={option}>{option}</option>)}
</select>
) : (
<div className="flex min-w-0 items-center gap-1">
<input
className="h-7 min-w-0 flex-1 border border-[var(--ui-border)] bg-[var(--ui-control-bg)] px-2 text-[11px] text-[var(--ui-text-strong)]"
type={field.type === "number" ? "number" : "text"}
min={field.min}
step={field.step}
value={String(editParameters[field.name] ?? "")}
onChange={(event) => setEditParameters((current) => ({
...current,
[field.name]: field.type === "number" ? Number(event.target.value) : event.target.value,
}))}
/>
{field.unit ? <span className="w-5 text-[10px]">{field.unit}</span> : null}
</div>
)}
</label>
))}
</div>
<div className="mt-2 text-[10px] text-[var(--ui-text-subtle)]">{editPicks.length}/{activeToolDefinition.pickKinds.length} 个几何点已选择</div>
<div className="mt-3 flex justify-end gap-2 border-t border-[var(--ui-border)] pt-2">
<button
type="button"
className="h-7 border border-[var(--ui-border-strong)] px-2 text-[11px] text-[var(--ui-text-muted)] hover:bg-[var(--ui-control-hover)]"
onClick={cancelEdit}
>
取消
</button>
<button
type="button"
disabled={!editSelectionReady || editPending}
className="h-7 border border-[var(--ui-accent-border)] bg-[var(--ui-accent-soft)] px-2 text-[11px] text-[var(--ui-accent-text)] transition hover:bg-[var(--ui-accent-muted)] disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void submitEdit(activeTool, editPicks)}
>
应用
</button>
</div>
</div>
) : null}
{reveal > 0 ? <GenerationEdgeGlow /> : null}
{reveal > 0 ? <GenerationSuccessReveal title="生成成功" detail={result?.summary || "CDSL 模型已完成"} /> : null}
{showParameters && result ? (
<div className="absolute inset-y-0 right-0 z-40 w-full max-w-[360px] shadow-[var(--ui-shadow-panel)]">
<ParameterPanel
parameters={parameters}
pendingParameter={parameterPending}
error={parameterError}
onClose={() => setShowParameters(false)}
onCommit={(id, value) => void commitParameters({ [id]: value })}
onReset={(values) => void commitParameters(values)}
downloads={[
{ label: "STEP", description: "CAD exchange", url: encodeArtifactUrl(result.taskId, result.stepPath) },
{ label: "CDSL", description: "Editable model", url: encodeArtifactUrl(result.taskId, result.cdslPath) },
{ label: "GLB", description: "Preview mesh", url: encodeArtifactUrl(result.taskId, result.glbPath) },
{ label: "REPORT", description: "Rebuild validation", url: encodeArtifactUrl(result.taskId, result.reportPath) },
]}
/>
</div>
) : null}
</div>
);
}
function ViewerState({ icon, text, error = false }: { icon: React.ReactNode; text: string; error?: boolean }) {
return <div className={`viewer-state ${error ? "viewer-state-error" : ""}`}>{icon}<span>{text}</span></div>;
}
@@ -0,0 +1,178 @@
"use client";
import type { ReactNode } from "react";
import {
CircleDot,
CircleDotDashed,
CornerDownRight,
Disc,
Drill,
Focus,
Grid2X2Plus,
LassoSelect,
MousePointerClick,
Orbit,
Radius,
SlidersHorizontal,
SquareDashed,
SquareSplitHorizontal,
} from "lucide-react";
import { AiSelectionMode, CAD_EDIT_TOOLS } from "@/lib/cad-edit-tools";
const EDIT_TOOL_ICONS = {
add_hole: Drill,
add_counterbore: CircleDot,
add_countersink: CircleDotDashed,
add_slot: SquareSplitHorizontal,
add_pocket: SquareDashed,
add_circular_pocket: Disc,
add_hole_pattern: Grid2X2Plus,
add_chamfer: CornerDownRight,
add_fillet: Radius,
};
function ToolbarButton({
label,
active = false,
disabled = false,
children,
onClick,
}: {
label: string;
active?: boolean;
disabled?: boolean;
children: ReactNode;
onClick?: () => void;
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
title={label}
disabled={disabled}
onClick={onClick}
className={[
"grid size-7 place-items-center rounded-md border text-[var(--ui-text-muted)] transition",
active
? "border-[var(--ui-border-strong)] bg-[var(--ui-control-hover)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-inset)]"
: "border-transparent hover:border-[var(--ui-border-strong)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
disabled ? "cursor-not-allowed opacity-40 hover:border-transparent hover:bg-transparent" : "",
].join(" ")}
>
{children}
</button>
);
}
function Divider() {
return <span className="mx-0.5 h-5 w-px shrink-0 bg-[var(--ui-border-strong)]" aria-hidden="true" />;
}
export function EmbeddedCadEditToolbar({
activeToolId,
aiSelectionMode,
disabled = false,
unavailableToolIds = [],
onSelectTool,
onSelectionModeChange,
}: {
activeToolId: string;
aiSelectionMode: AiSelectionMode;
disabled?: boolean;
unavailableToolIds?: string[];
onSelectTool: (toolId: string) => void;
onSelectionModeChange: (mode: AiSelectionMode) => void;
}) {
return (
<div
data-viewer-interaction-overlay="true"
className="pointer-events-auto absolute left-3 top-3 z-30 inline-flex h-10 max-w-[calc(100vw-1.5rem)] flex-nowrap items-center gap-1 overflow-x-auto rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-1.5 text-[var(--ui-text-muted)] shadow-[var(--ui-shadow-soft)] backdrop-blur"
role="toolbar"
aria-label="CAD 编辑工具"
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarButton
label="点选几何"
active={aiSelectionMode === "point"}
disabled={disabled}
onClick={() => {
onSelectTool("");
onSelectionModeChange("point");
}}
>
<MousePointerClick className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
label="圈选几何"
active={aiSelectionMode === "lasso"}
disabled={disabled}
onClick={() => {
onSelectTool("");
onSelectionModeChange("lasso");
}}
>
<LassoSelect className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<Divider />
{CAD_EDIT_TOOLS.map((tool) => {
const Icon = EDIT_TOOL_ICONS[tool.id as keyof typeof EDIT_TOOL_ICONS] || Drill;
const active = activeToolId === tool.id;
const unavailable = unavailableToolIds.includes(tool.id);
return (
<ToolbarButton
key={tool.id}
label={unavailable ? `${tool.label}(当前 engine 尚不支持)` : tool.label}
active={active}
disabled={disabled || unavailable}
onClick={() => {
if (active) {
onSelectTool("");
onSelectionModeChange("point");
return;
}
onSelectTool(tool.id);
onSelectionModeChange("none");
}}
>
<Icon className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
);
})}
</div>
);
}
export function EmbeddedCadViewToolbar({
disabled = false,
onResetView,
onScreenshot,
onParameters,
}: {
disabled?: boolean;
onResetView: () => void;
onScreenshot?: () => void;
onParameters?: () => void;
}) {
return (
<div
data-viewer-interaction-overlay="true"
className="pointer-events-auto absolute right-3 top-3 z-30 inline-flex min-h-10 items-center gap-1 rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-1.5 text-[var(--ui-text-muted)] shadow-[var(--ui-shadow-soft)] backdrop-blur"
role="toolbar"
aria-label="CAD 预览工具"
onPointerDown={(event) => event.stopPropagation()}
>
<ToolbarButton label="Orbit" disabled={disabled} onClick={onResetView}>
<Orbit className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton label="Copy screenshot" disabled={disabled || !onScreenshot} onClick={onScreenshot}>
<Focus className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
<ToolbarButton label="Parameters" disabled={disabled || !onParameters} onClick={onParameters}>
<SlidersHorizontal className="size-3.5" strokeWidth={2} aria-hidden="true" />
</ToolbarButton>
</div>
);
}
@@ -0,0 +1,15 @@
"use client";
import { createPortal } from "react-dom";
export function GenerationEdgeGlow({ exiting = false }: { exiting?: boolean }) {
if (typeof document === "undefined") return null;
return createPortal(
<div className={`generation-edge-glow${exiting ? " is-converging" : ""}`} aria-hidden="true">
<div className="generation-edge-glow__veil" />
<div className="generation-edge-glow__band" />
</div>,
document.body,
);
}
@@ -0,0 +1,44 @@
"use client";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
type SparkStyle = CSSProperties & { "--spark-index": number };
export function GenerationSuccessReveal({
title,
detail,
}: {
title: string;
detail: string;
}) {
if (typeof document === "undefined") return null;
return createPortal(
<div className="generation-success" role="status">
<span className="generation-success__glow" aria-hidden="true" />
<div className="generation-success__card">
<span className="generation-success__badge" aria-hidden="true">
<svg className="generation-success__check" viewBox="0 0 52 52">
<circle cx="26" cy="26" r="23" />
<path d="M15 27 l7.5 7.5 L37 18" />
</svg>
</span>
<span className="generation-success__copy">
<span className="generation-success__kicker">LINGXIN · 几何内核</span>
<strong data-text={title}>{title}</strong>
<span className="generation-success__detail">{detail}</span>
</span>
{[0, 1, 2].map((sparkIndex) => (
<i
className="generation-success__spark"
style={{ "--spark-index": sparkIndex } as SparkStyle}
aria-hidden="true"
key={sparkIndex}
/>
))}
</div>
</div>,
document.body,
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import * as Collapsible from "@radix-ui/react-collapsible";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDown, ChevronUp, Download, Loader2, RefreshCcw, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CadamSlider } from "@/components/ui/cadam-slider";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
type EditableParameter = Record<string, unknown>;
type NormalizedParameter = {
id: string;
name: string;
displayName: string;
group: string;
groupDisplayName: string;
value: number;
defaultValue: number;
min: number | null;
max: number | null;
step: number;
precision: number;
unit: string;
editable: boolean;
editState: string;
};
type DownloadFormat = {
label: string;
description: string;
url: string;
};
function numberValue(value: unknown) {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function parameterToView(parameter: EditableParameter): NormalizedParameter | null {
const id = String(parameter.id || parameter.name || "").trim();
const name = String(parameter.name || id).trim();
const value = numberValue(parameter.value);
const declaredRange = Array.isArray(parameter.range) ? parameter.range : [];
const min = numberValue(parameter.min ?? parameter.minimum ?? declaredRange[0]);
const max = numberValue(parameter.max ?? parameter.maximum ?? declaredRange[1]);
if (!name || value === null) return null;
return {
id,
name,
displayName: String(parameter.display_name || parameter.displayName || parameter.label || name),
group: String(parameter.group || "dimensions"),
groupDisplayName: String(parameter.group_display_name || parameter.groupDisplayName || "尺寸"),
value,
defaultValue: numberValue(parameter.default_value ?? parameter.defaultValue) ?? value,
min,
max,
step: numberValue(parameter.step) ?? 1,
precision: Math.max(0, numberValue(parameter.precision) ?? 2),
unit: String(parameter.unit || ""),
editable: parameter.editable === true || parameter.edit_state === "declared_unvalidated",
editState: String(parameter.edit_state || "unvalidated"),
};
}
function formatValue(value: number, precision: number) {
if (!Number.isFinite(value)) return "";
if (Number.isInteger(value) && precision === 0) return String(value);
return Number(value.toFixed(precision)).toString();
}
function clampValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function visualRange(parameter: NormalizedParameter) {
if (parameter.min !== null && parameter.max !== null && parameter.max > parameter.min) {
return { min: parameter.min, max: parameter.max };
}
const span = Math.max(Math.abs(parameter.value), 1);
return {
min: Math.max(0, parameter.value - span),
max: parameter.value + span,
};
}
function sectionDisplayName(group: { id: string; displayName: string }) {
return group.id === "dimensions" || group.displayName === "尺寸"
? "Dimensions"
: group.displayName;
}
export function ParameterPanel({
parameters,
downloads = [],
pendingParameter,
error,
onClose,
onCommit,
onReset,
}: {
parameters: EditableParameter[];
downloads?: DownloadFormat[];
pendingParameter?: string;
error?: string;
onClose: () => void;
onCommit: (parameter: string, value: number) => void;
onReset: (values: Record<string, number>) => void;
}) {
const normalizedParameters = useMemo(
() => parameters.map(parameterToView).filter((value): value is NormalizedParameter => Boolean(value)),
[parameters],
);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
const [selectedDownload, setSelectedDownload] = useState(downloads[0]?.label || "");
useEffect(() => {
setDrafts(Object.fromEntries(
normalizedParameters.map((parameter) => [
parameter.name,
formatValue(parameter.value, parameter.precision),
]),
));
setOpenGroups((current) => {
const next = { ...current };
for (const parameter of normalizedParameters) {
if (next[parameter.group] === undefined) next[parameter.group] = true;
}
return next;
});
}, [normalizedParameters]);
useEffect(() => {
if (!downloads.some((download) => download.label === selectedDownload)) {
setSelectedDownload(downloads[0]?.label || "");
}
}, [downloads, selectedDownload]);
const grouped = useMemo(() => {
const groups = new Map<string, { id: string; displayName: string; parameters: NormalizedParameter[] }>();
for (const parameter of normalizedParameters) {
const group = groups.get(parameter.group) || {
id: parameter.group,
displayName: parameter.groupDisplayName,
parameters: [],
};
group.parameters.push(parameter);
groups.set(parameter.group, group);
}
return Array.from(groups.values());
}, [normalizedParameters]);
const resetParameters = () => {
const values = Object.fromEntries(
normalizedParameters
.filter((parameter) => parameter.editable && parameter.value !== parameter.defaultValue)
.map((parameter) => [parameter.id, parameter.defaultValue]),
);
if (Object.keys(values).length) {
onReset(values);
return;
}
setDrafts(Object.fromEntries(
normalizedParameters.map((parameter) => [
parameter.name,
formatValue(parameter.defaultValue, parameter.precision),
]),
));
};
const selectedDownloadItem = downloads.find((download) => download.label === selectedDownload) || downloads[0] || null;
const commitValue = (parameter: NormalizedParameter, rawValue: string | number) => {
const numeric = numberValue(rawValue);
if (!parameter.editable) {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
return;
}
if (numeric === null) {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
return;
}
const clamped = parameter.min !== null && parameter.max !== null
? clampValue(numeric, parameter.min, parameter.max)
: numeric;
const formatted = formatValue(clamped, parameter.precision);
setDrafts((current) => ({ ...current, [parameter.name]: formatted }));
if (Number(formatted) !== parameter.value) {
onCommit(parameter.id, Number(formatted));
}
};
return (
<aside
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-l border-[var(--ui-border)] bg-[var(--ui-panel)] text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-panel)]"
data-viewer-interaction-overlay="true"
role="dialog"
aria-label="可编辑参数"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-[var(--ui-border-strong)] bg-[var(--ui-panel)] px-6">
<div className="flex items-center gap-2">
<div className="text-lg font-semibold tracking-tight text-[var(--ui-text-strong)]">Parameters</div>
</div>
<div className="flex items-center gap-1">
<Button
aria-label="恢复全部默认参数"
size="icon-sm"
variant="ghost"
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
disabled={Boolean(pendingParameter) || !normalizedParameters.length}
onClick={resetParameters}
>
<RefreshCcw className="size-4" />
</Button>
<Button
aria-label="隐藏参数面板"
size="icon-sm"
variant="ghost"
className="size-8 rounded-full text-[var(--ui-text-strong)] hover:bg-[var(--ui-control-hover)]"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto px-6 py-6 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{error ? (
<div className="mb-3 rounded border border-[var(--ui-error-border)] bg-[var(--ui-error-bg)] px-3 py-2 text-[11px] leading-4 text-[var(--ui-error-text)]">
{error}
</div>
) : null}
{normalizedParameters.length ? (
<div className="flex flex-col gap-3">
{grouped.map((group) => (
<Collapsible.Root
key={group.id}
open={openGroups[group.id] ?? true}
onOpenChange={(open) => setOpenGroups((current) => ({ ...current, [group.id]: open }))}
>
<Collapsible.Trigger className="group flex w-full items-center justify-between gap-2 rounded-md py-1 text-left text-xs font-semibold text-[var(--ui-text-strong)] transition-colors focus:outline-none">
<span className="flex items-center gap-2">
{sectionDisplayName(group)}
<span className="text-[10px] text-[var(--ui-text-subtle)]">{group.parameters.length}</span>
</span>
<ChevronDown
className={cn(
"size-3.5 text-[var(--ui-text-subtle)] transition-all duration-200 group-hover:text-[var(--ui-text-strong)]",
openGroups[group.id] !== false && "rotate-180",
)}
/>
</Collapsible.Trigger>
<Collapsible.Content className="mt-3 flex flex-col gap-3">
{group.parameters.map((parameter) => {
const draft = drafts[parameter.name] ?? formatValue(parameter.value, parameter.precision);
const numericDraft = numberValue(draft) ?? parameter.value;
const disabled = Boolean(pendingParameter) || !parameter.editable;
const range = visualRange(parameter);
const pending = pendingParameter === parameter.id;
return (
<div
className="grid w-full grid-cols-[80px_minmax(0,1fr)] items-center gap-3"
key={parameter.name}
>
<label
className="min-w-0 overflow-hidden text-ellipsis text-xs font-normal leading-4 text-[var(--ui-text-muted)]"
htmlFor={`parameter-${parameter.name}`}
title={parameter.displayName}
>
<span className="block truncate">{parameter.displayName}</span>
</label>
<div className="flex w-full min-w-0 items-center gap-3">
<CadamSlider
id={`${parameter.name}-slider`}
name={parameter.name}
min={range.min}
max={range.max}
step={parameter.step}
value={[clampValue(numericDraft, range.min, range.max)]}
defaultValue={[clampValue(parameter.defaultValue, range.min, range.max)]}
disabled={disabled}
visualOnly={false}
defaultMarkerStyle="line"
onValueChange={([nextValue]) => {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(nextValue, parameter.precision),
}));
}}
onValueCommit={([nextValue]) => commitValue(parameter, nextValue)}
/>
<div className="flex shrink-0 items-center gap-2">
<div className="relative">
<Input
id={`parameter-${parameter.name}`}
autoComplete="off"
className="h-6 w-14 rounded-lg border-0 bg-[var(--ui-control-bg)] px-2 pr-2 text-left text-xs text-[var(--ui-text-strong)] transition-colors selection:bg-[var(--ui-selection-bg)] selection:text-[var(--ui-text-inverse)] focus-visible:ring-0 hover:bg-[var(--ui-control-hover)]"
disabled={disabled}
inputMode="decimal"
max={range.max}
min={range.min}
step={parameter.step}
type="number"
value={draft}
onBlur={() => commitValue(parameter, draft)}
onChange={(event) => setDrafts((current) => ({
...current,
[parameter.name]: event.target.value,
}))}
onFocus={(event) => event.target.select()}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
setDrafts((current) => ({
...current,
[parameter.name]: formatValue(parameter.value, parameter.precision),
}));
}
}}
/>
{pending ? (
<Loader2 className="absolute right-1.5 top-1.5 size-3 animate-spin text-[var(--ui-accent)]" />
) : null}
</div>
<span className="ml-1 w-6 text-left text-xs text-[var(--ui-text-muted)]">
{parameter.unit}
</span>
</div>
</div>
</div>
);
})}
</Collapsible.Content>
</Collapsible.Root>
))}
</div>
) : (
<div className="rounded border border-[var(--ui-border-strong)] bg-[var(--ui-panel-raised)] px-3 py-3 text-xs leading-5 text-[var(--ui-text-muted)]">
当前模型没有可直接编辑的参数。
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-4 border-t border-[var(--ui-border-strong)] px-6 py-6">
<div className="flex">
<a
aria-disabled={!selectedDownloadItem}
className={cn(
"inline-flex h-12 flex-1 items-center justify-center rounded-l-lg rounded-r-none bg-[var(--ui-accent)] text-sm font-semibold text-[var(--ui-accent-contrast)] transition-colors hover:bg-[var(--ui-accent-hover)]",
!selectedDownloadItem && "pointer-events-none opacity-50",
)}
href={selectedDownloadItem?.url || "#"}
download
>
<Download className="mr-2 size-4" />
{selectedDownloadItem?.label || "STEP"}
</a>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button
aria-label="选择下载格式"
className="h-12 w-12 rounded-l-none rounded-r-lg border-l border-[var(--ui-border-muted)] bg-[var(--ui-accent)] p-0 text-[var(--ui-accent-contrast)] hover:bg-[var(--ui-accent-hover)]"
disabled={!downloads.length}
>
<ChevronUp className="size-4" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="end"
className="z-50 w-64 rounded-md border border-[var(--ui-border)] bg-[var(--ui-popover)] p-1 shadow-[var(--ui-shadow-popover)]"
>
{downloads.map((download) => (
<DropdownMenu.Item
key={download.label}
className="flex cursor-pointer items-center rounded px-3 py-2 text-[var(--ui-text-strong)] outline-none hover:bg-[var(--ui-control-hover)]"
onSelect={() => setSelectedDownload(download.label)}
>
<span className="text-sm">.{download.label}</span>
<span className="ml-3 text-xs text-[var(--ui-text-muted)]">{download.description}</span>
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</div>
</aside>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
type ButtonVariant = "primary" | "outline" | "ghost";
type ButtonSize = "sm" | "icon-sm";
const variantClassNames: Record<ButtonVariant, string> = {
primary: "border-[var(--ui-accent-border)] bg-[var(--ui-accent-soft)] text-[var(--ui-accent-text)] hover:bg-[var(--ui-accent-muted)]",
outline: "border-[var(--ui-border-strong)] text-[var(--ui-text-muted)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
ghost: "border-transparent text-[var(--ui-text-muted)] hover:border-[var(--ui-border-strong)] hover:bg-[var(--ui-control-hover)] hover:text-[var(--ui-text-strong)]",
};
const sizeClassNames: Record<ButtonSize, string> = {
sm: "h-8 px-3 text-xs",
"icon-sm": "size-6 p-0 text-sm",
};
export function Button({
className,
variant = "outline",
size = "sm",
type = "button",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
}) {
return (
<button
type={type}
className={cn(
"inline-flex items-center justify-center rounded-md border font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-[var(--ui-focus-ring)] disabled:pointer-events-none disabled:opacity-50",
variantClassNames[variant],
sizeClassNames[size],
className,
)}
{...props}
/>
);
}
+147
View File
@@ -0,0 +1,147 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
type CadamSliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
defaultMarkerStyle?: "dot" | "line";
hideDefaultMarker?: boolean;
visualOnly?: boolean;
};
function roundToStep(value: number, step: number) {
if (step <= 0) return value;
const decimals = step >= 1 ? 0 : Math.max(0, -Math.floor(Math.log10(step)));
const snapped = Math.round(value / step) * step;
return Math.round(snapped * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
const CadamSlider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
CadamSliderProps
>(({
className,
value,
defaultValue,
min = 0,
max = 100,
step = 1,
disabled,
visualOnly = false,
onValueChange,
onValueCommit,
defaultMarkerStyle = "line",
hideDefaultMarker = false,
...props
}, ref) => {
const trackRef = React.useRef<HTMLDivElement>(null);
const lastValueRef = React.useRef(Array.isArray(value) ? value[0] ?? min : min);
const [isDragging, setIsDragging] = React.useState(false);
const currentValue = Array.isArray(value) ? value[0] ?? min : min;
const defaultVal = Array.isArray(defaultValue) ? defaultValue[0] ?? currentValue : currentValue;
const range = Math.max(max - min, 1);
const currentPosition = ((currentValue - min) / range) * 100;
const defaultPosition = ((defaultVal - min) / range) * 100;
React.useEffect(() => {
lastValueRef.current = currentValue;
}, [currentValue]);
const valueFromPointer = React.useCallback((clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return currentValue;
const ratio = rect.width > 0
? (Math.min(Math.max(clientX, rect.left), rect.right) - rect.left) / rect.width
: 0;
const nextValue = min + ratio * (max - min);
return Math.min(Math.max(roundToStep(nextValue, step), min), max);
}, [currentValue, max, min, step]);
const publish = React.useCallback((nextValue: number, commit = false) => {
lastValueRef.current = nextValue;
onValueChange?.([nextValue]);
if (commit) onValueCommit?.([nextValue]);
}, [onValueChange, onValueCommit]);
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || visualOnly) return;
setIsDragging(true);
(event.currentTarget as Element).setPointerCapture(event.pointerId);
publish(valueFromPointer(event.clientX));
};
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || visualOnly || !isDragging) return;
publish(valueFromPointer(event.clientX));
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || visualOnly || !isDragging) return;
setIsDragging(false);
publish(lastValueRef.current, true);
(event.currentTarget as Element).releasePointerCapture(event.pointerId);
};
const handleDefaultMarkerClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (disabled || visualOnly) return;
event.stopPropagation();
publish(defaultVal, true);
};
return (
<SliderPrimitive.Root
ref={ref}
className={cn("group relative flex h-8 w-full touch-none select-none items-center", className)}
disabled={disabled}
min={min}
max={max}
step={step}
value={[currentValue]}
onValueChange={() => {}}
{...props}
>
<SliderPrimitive.Track
ref={trackRef}
className={cn(
"relative h-6 w-full grow cursor-pointer overflow-hidden rounded-lg bg-[var(--ui-slider-track)] transition-all",
isDragging && "h-7",
(disabled || visualOnly) && "cursor-default",
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={() => setIsDragging(false)}
>
<SliderPrimitive.Range
className={cn(
"absolute h-full rounded-l-lg bg-[var(--ui-slider-range)] transition-colors",
!disabled && "group-hover:bg-[var(--ui-slider-range-hover)]",
isDragging && "!bg-[var(--ui-slider-range-active)]",
)}
style={{ width: `${Math.min(Math.max(currentPosition, 0), 100)}%` }}
/>
{!hideDefaultMarker && currentValue !== defaultVal ? (
defaultMarkerStyle === "dot" ? (
<div
className="absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full bg-[var(--ui-slider-marker)] shadow-[0_0_8px_var(--ui-slider-marker-shadow)] transition-all hover:h-2.5 hover:w-2.5 hover:bg-[var(--ui-slider-marker-hover)]"
style={{ left: `${Math.min(Math.max(defaultPosition, 0), 100)}%` }}
onClick={handleDefaultMarkerClick}
/>
) : (
<div
className="absolute bottom-[2px] top-[2px] w-[2px] -translate-x-1/2 cursor-pointer rounded-full bg-[var(--ui-slider-marker)] shadow-[0_0_8px_var(--ui-slider-marker-shadow)] transition-all hover:w-1 hover:bg-[var(--ui-slider-marker-hover)]"
style={{ left: `${Math.min(Math.max(defaultPosition, 0), 100)}%` }}
onClick={handleDefaultMarkerClick}
/>
)
) : null}
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="hidden" />
</SliderPrimitive.Root>
);
});
CadamSlider.displayName = "CadamSlider";
export { CadamSlider };
+16
View File
@@ -0,0 +1,16 @@
"use client";
import type { InputHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"h-8 rounded-md border border-[var(--ui-border-strong)] bg-[var(--ui-control-bg)] px-2 text-xs text-[var(--ui-text-strong)] outline-none placeholder:text-[var(--ui-text-faint)] focus-visible:ring-2 focus-visible:ring-[var(--ui-focus-ring)]",
className,
)}
{...props}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function FloatingPanel({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"pointer-events-auto rounded-md border border-[var(--ui-border)] bg-[var(--ui-glass-popover)] p-3 text-[var(--ui-text-strong)] shadow-[var(--ui-shadow-popover)] backdrop-blur",
className,
)}
{...props}
/>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import type { SelectHTMLAttributes } from "react";
import { cn } from "@/lib/utils";
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
"h-8 rounded-md border border-[var(--ui-border-strong)] bg-[var(--ui-control-bg)] px-2 text-xs text-[var(--ui-text-strong)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--ui-focus-ring)]",
className,
)}
{...props}
/>
);
}
+22
View File
@@ -0,0 +1,22 @@
const backendUrl = () => process.env.CDSL_BACKEND_URL || "http://127.0.0.1:8000";
export async function backendFetch(path: string, init?: RequestInit) {
return fetch(`${backendUrl()}${path}`, {
...init,
headers: {
...(init?.headers || {}),
Accept: init?.headers && new Headers(init.headers).get("Accept") || "application/json",
},
cache: "no-store",
});
}
export async function readBackendError(response: Response) {
const text = await response.text();
try {
const parsed = JSON.parse(text) as { detail?: string; error?: string };
return parsed.detail || parsed.error || text || `Backend request failed (${response.status})`;
} catch {
return text || `Backend request failed (${response.status})`;
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { CadResult, TaskRecord } from "./cad-types";
export function encodeArtifactUrl(taskId: string, artifactPath: string) {
const encodedTask = encodeURIComponent(taskId);
const encodedPath = artifactPath.split("/").map(encodeURIComponent).join("/");
return `/api/tasks/${encodedTask}/artifacts/${encodedPath}`;
}
export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null {
if (!task) return null;
const current =
task.revisions.find((revision) => revision.revision_id === task.current_revision) ??
[...task.revisions].reverse().find((revision) => revision.status === "success");
if (
!current ||
current.status !== "success" ||
!current.cdsl_path ||
!current.step_path ||
!current.glb_path ||
!current.report_path
) {
return null;
}
return {
taskId: task.task_id,
revisionId: current.revision_id,
cdslPath: current.cdsl_path,
stepPath: current.step_path,
glbPath: current.glb_path,
reportPath: current.report_path,
parametersPath: current.parameters_path,
selectorPath: current.selector_path,
edgesPath: current.edges_path,
summary: current.summary || "CDSL CAD model",
referenceIds: current.reference_ids || [],
engine: current.engine || "cdsl_only",
};
}
+218
View File
@@ -0,0 +1,218 @@
export type AiSelectionMode = "none" | "point" | "lasso";
export type CadEditTool = {
id: string;
label: string;
pickKinds: Array<"surface_point" | "edge">;
parameters: Record<string, string | number>;
parameterFields: Array<{
name: string;
label: string;
type: "number" | "select" | "string";
unit?: string;
min?: number;
step?: number;
options?: string[];
placeholder?: string;
}>;
};
export const CAD_EDIT_TOOLS: CadEditTool[] = [
{
id: "add_hole",
label: "打孔",
pickKinds: ["surface_point"],
parameters: { holeDiameter: 2, depth: "through", direction: "face_normal" },
parameterFields: [
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_counterbore",
label: "沉孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 3,
counterboreDiameter: 6,
counterboreDepth: 2,
depth: "through",
direction: "face_normal",
},
parameterFields: [
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "counterboreDiameter", label: "沉孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "counterboreDepth", label: "沉孔深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_countersink",
label: "锥沉孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 3,
countersinkDiameter: 6,
countersinkAngleDeg: 90,
depth: "through",
direction: "face_normal",
},
parameterFields: [
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "countersinkDiameter", label: "锥口直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "countersinkAngleDeg", label: "锥角", type: "number", unit: "deg", min: 1, step: 1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_slot",
label: "开槽",
pickKinds: ["surface_point", "surface_point"],
parameters: { slotWidth: 2, depth: "through", direction: "face_normal" },
parameterFields: [
{ name: "slotWidth", label: "槽宽", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_pocket",
label: "铣型腔",
pickKinds: ["surface_point"],
parameters: { shape: "rectangle", width: 10, height: 6, depth: 1, direction: "face_normal", orientation: "surface_xy" },
parameterFields: [
{ name: "width", label: "宽度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "height", label: "高度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_circular_pocket",
label: "圆形型腔",
pickKinds: ["surface_point"],
parameters: { diameter: 8, depth: 1, direction: "face_normal" },
parameterFields: [
{ name: "diameter", label: "直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_hole_pattern",
label: "阵列孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 2,
rows: 2,
columns: 2,
pitchX: 8,
pitchY: 8,
depth: "through",
direction: "face_normal",
orientation: "surface_xy",
},
parameterFields: [
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "rows", label: "行数", type: "number", min: 1, step: 1 },
{ name: "columns", label: "列数", type: "number", min: 1, step: 1 },
{ name: "pitchX", label: "X 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "pitchY", label: "Y 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_chamfer",
label: "倒角",
pickKinds: ["edge"],
parameters: { distance: 1 },
parameterFields: [
{ name: "distance", label: "倒角距离", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_fillet",
label: "圆角",
pickKinds: ["edge"],
parameters: { radius: 1 },
parameterFields: [
{ name: "radius", label: "圆角半径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
];
export function cadEditToolForOperation(operation: string) {
return CAD_EDIT_TOOLS.find((tool) => tool.id === operation) || CAD_EDIT_TOOLS[0];
}
export function cadEditToolPickKind(operation: string) {
return cadEditToolForOperation(operation).pickKinds[0] || "surface_point";
}
export function cadEditToolNextPickKind(operation: string, pickCount: number) {
const pickKinds = cadEditToolForOperation(operation).pickKinds;
return pickKinds[Math.min(Math.max(Math.floor(pickCount), 0), pickKinds.length - 1)] || "surface_point";
}
export function cadEditToolPickComplete(operation: string, picks: unknown[]) {
return picks.length >= cadEditToolForOperation(operation).pickKinds.length;
}
export function defaultCadEditParameters(operation: string) {
return { ...cadEditToolForOperation(operation).parameters };
}
export function buildEmbeddedCadEditIntent({
operation,
pick,
parameters,
artifactPath,
taskId,
}: {
operation: string;
pick: Record<string, unknown>;
parameters?: Record<string, unknown>;
artifactPath: string;
taskId: string;
}) {
const tool = cadEditToolForOperation(operation);
return {
schema: "cad-edit-intent.v1",
operation: tool.id,
source: {
entryFile: artifactPath,
stepPath: artifactPath,
sourcePath: "",
backend: "agent-studio",
units: "mm",
taskId,
},
selection: pick,
parameters: {
...defaultCadEditParameters(tool.id),
...(parameters || {}),
},
};
}
export function buildEmbeddedGeometrySelectionIntent({
selection,
artifactPath,
taskId,
}: {
selection: Record<string, unknown>;
artifactPath: string;
taskId: string;
}) {
return {
schema: "cad-ai-geometry-selection.v1",
intent: "modify_selected_geometry",
source: {
entryFile: artifactPath,
stepPath: artifactPath,
sourcePath: "",
backend: "agent-studio",
units: "mm",
taskId,
},
selection,
instruction: "用户通过 CAD Agent Studio 内置预览指定了要修改的几何区域。请结合 selection 中的屏幕坐标、近似模型坐标和当前视角理解位置,再修改可编辑源文件并重新生成 STEP 与预览资产。",
};
}
+55
View File
@@ -0,0 +1,55 @@
import type { CadError, CadProgress, CadResult, CadUIMessage } from "./cad-types";
type AnyPart = { type?: unknown; text?: unknown; data?: unknown; id?: unknown };
type AnyMessage = { id?: unknown; role?: unknown; parts?: unknown };
type CadPart = CadUIMessage["parts"][number];
export function normalizeCadMessages(input: unknown): CadUIMessage[] {
if (!Array.isArray(input)) return [];
return input.flatMap((message): CadUIMessage[] => {
const candidate = message as AnyMessage;
if (candidate.role !== "user" && candidate.role !== "assistant") return [];
const parts = Array.isArray(candidate.parts) ? candidate.parts : [];
const normalizedParts = parts.flatMap((part): CadPart[] => {
const item = part as AnyPart;
if (item.type === "text") {
return [{ type: "text", text: String(item.text || "") }];
}
if (item.type === "data-cad-progress") {
return [{ type: "data-cad-progress", id: stringId(item.id), data: item.data as CadProgress }];
}
if (item.type === "data-cad-result") {
return [{ type: "data-cad-result", id: stringId(item.id), data: item.data as CadResult }];
}
if (item.type === "data-cad-error") {
return [{ type: "data-cad-error", id: stringId(item.id), data: item.data as CadError }];
}
return [];
});
return [{
id: typeof candidate.id === "string" ? candidate.id : crypto.randomUUID(),
role: candidate.role,
parts: normalizedParts,
}];
});
}
export function messagesForBackend(messages: CadUIMessage[]) {
return messages
.filter((message) => message.role === "user" || message.role === "assistant")
.map((message) => ({
id: message.id,
role: message.role,
parts: message.parts.flatMap((part): { type: string; text?: string; data?: unknown }[] => {
if (part.type === "text") return [{ type: "text", text: part.text }];
if (part.type.startsWith("data-cad-") && "data" in part) {
return [{ type: part.type, data: part.data }];
}
return [];
}),
}));
}
function stringId(value: unknown) {
return typeof value === "string" ? value : undefined;
}
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import { backendEventToUiChunk } from "./cad-stream";
import { latestSuccessfulResult } from "./cad-artifacts";
import { messagesForBackend } from "./cad-messages";
import type { CadUIMessage } from "./cad-types";
test("maps backend cad_result SSE into an AI SDK data part", () => {
const chunk = backendEventToUiChunk({
event: "cad_result",
data: { taskId: "cad_abc", revisionId: "rev_001" },
}, "text_1");
assert.equal(chunk?.type, "data-cad-result");
assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" });
});
test("restores the latest successful task revision for the viewer", () => {
const result = latestSuccessfulResult({
task_id: "cad_abc",
current_revision: "rev_002",
revisions: [
{ revision_id: "rev_001", status: "success", cdsl_path: "a", step_path: "b", glb_path: "c", report_path: "d" },
{ revision_id: "rev_002", status: "success", cdsl_path: "aa", step_path: "bb", glb_path: "cc", report_path: "dd", summary: "done" },
],
});
assert.equal(result?.glbPath, "cc");
assert.equal(result?.summary, "done");
});
test("strips non-text and non-CAD parts before sending to FastAPI", () => {
const messages: CadUIMessage[] = [{
id: "m1",
role: "assistant",
parts: [
{ type: "text", text: "hello" },
{ type: "data-cad-progress", data: { step: "s", label: "S", status: "running" } },
{ type: "file", mediaType: "model/gltf-binary", url: "/x.glb" },
],
}];
assert.deepEqual(messagesForBackend(messages)[0].parts, [
{ type: "text", text: "hello" },
{ type: "data-cad-progress", data: { step: "s", label: "S", status: "running" } },
]);
});
+37
View File
@@ -0,0 +1,37 @@
import type { UIMessageChunk } from "ai";
export type BackendSseEvent = {
event: string;
data: Record<string, unknown>;
};
export function backendEventToUiChunk(
item: BackendSseEvent,
textId: string,
): UIMessageChunk | null {
if (item.event === "text_delta") {
return { type: "text-delta", id: textId, delta: String(item.data.text || "") };
}
if (item.event === "progress") {
return {
type: "data-cad-progress",
id: `progress_${String(item.data.step || Date.now())}`,
data: item.data,
};
}
if (item.event === "cad_result") {
return {
type: "data-cad-result",
id: `result_${String(item.data.taskId || Date.now())}`,
data: item.data,
};
}
if (item.event === "cad_error") {
return {
type: "data-cad-error",
id: `error_${Date.now()}`,
data: item.data,
};
}
return null;
}
+90
View File
@@ -0,0 +1,90 @@
import type { UIMessage } from "ai";
export type CadProgress = {
step: string;
label: string;
status: "running" | "success" | "error" | string;
message?: string;
};
export type CadResult = {
taskId: string;
revisionId: string;
cdslPath: string;
stepPath: string;
glbPath: string;
reportPath: string;
parametersPath?: string;
selectorPath?: string;
edgesPath?: string;
summary: string;
referenceIds: string[];
engine: string;
};
export type CadError = {
stage: string;
message: string;
};
export type CadDataParts = {
"cad-progress": CadProgress;
"cad-result": CadResult;
"cad-error": CadError;
};
export type CadUIMessage = UIMessage<unknown, CadDataParts>;
export type ConversationRecord = {
conversation_id: string;
current_task_id: string;
messages: CadUIMessage[];
attachments?: CadAttachment[];
};
export type CadAttachment = {
id: string;
task_id: string;
name: string;
kind: "image" | "document";
path: string;
mime: string;
size: number;
sha256: string;
extracted_path?: string;
};
export type TaskRevision = {
revision_id: string;
status: "success" | "failed" | string;
cdsl_path?: string;
step_path?: string;
glb_path?: string;
report_path?: string;
parameters_path?: string;
selector_path?: string;
edges_path?: string;
summary?: string;
reference_ids?: string[];
engine?: string;
error?: string;
};
export type TaskRecord = {
task_id: string;
current_revision: string;
revisions: TaskRevision[];
};
export type BackendConfig = {
default_provider: string;
default_model: string;
providers: Array<{
id: string;
label: string;
models: Array<{ id: string; vision: boolean }>;
}>;
model: string;
configured: boolean;
library_samples: number;
};
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCdslSelectorRuntime } from "./cdsl-selector-runtime";
test("maps CDSL selector planes onto preview triangles", () => {
const runtime = buildCdslSelectorRuntime({
references: [{
id: "face_top",
selectorType: "face",
label: "top",
center: [0, 0, 10],
normal: [0, 0, 1],
frame: { origin_mm: [0, 0, 10], normal: [0, 0, 1], x_dir: [1, 0, 0], y_dir: [0, 1, 0] },
bbox: { min: [-10, -10, 0], max: [10, 10, 10] },
}],
}, {
// One source XY triangle at z=10, expressed in the preview's X/-Z/Y basis.
vertices: new Float32Array([0, -10000, 0, 10000, -10000, 0, 0, -10000, 10000]),
indices: new Uint32Array([0, 1, 2]),
bounds: { min: [0, -10000, 0], max: [10000, -10000, 10000] },
});
assert.equal(runtime.references.length, 1);
assert.equal(runtime.proxy.faceIds.length, 1);
assert.equal(runtime.proxy.faceIds[0], 0);
assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0]);
assert.equal(runtime.faceReferenceByRowIndex.get(0)?.id, "face_top");
});
test("uses backend face triangle ranges when they are available", () => {
const runtime = buildCdslSelectorRuntime({
references: [
{ id: "face_000", selectorType: "face", center: [0, 0, 0], normal: [0, 0, 1], frame: { origin_mm: [0, 0, 0], normal: [0, 0, 1] }, triangle_start: 0, triangle_count: 1 },
{ id: "face_001", selectorType: "face", center: [0, 0, 1], normal: [0, 0, -1], frame: { origin_mm: [0, 0, 1], normal: [0, 0, -1] }, triangle_start: 1, triangle_count: 1 },
],
}, {
vertices: new Float32Array(18),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
});
assert.deepEqual([...runtime.proxy.faceIds], [0, 1]);
assert.deepEqual([...runtime.proxy.faceRuns], [0, 0, 0, 1, 0, 0, 0, 1, 1, 1]);
});
+226
View File
@@ -0,0 +1,226 @@
type Vector3 = [number, number, number];
type SidecarReference = {
id?: string;
selectorType?: string;
label?: string;
center?: unknown;
normal?: unknown;
frame?: Record<string, unknown>;
bbox?: unknown;
surface_type?: string;
triangle_start?: number;
triangle_count?: number;
};
type Sidecar = { references?: SidecarReference[] };
const GLB_CAD_UNIT_SCALE = 1000;
const FACE_ID_NONE = 0xffffffff;
function vector(value: unknown): Vector3 | null {
if (!Array.isArray(value) || value.length < 3) return null;
const result = value.slice(0, 3).map(Number) as Vector3;
return result.every(Number.isFinite) ? result : null;
}
// The local preview runtime converts build123d's Y-up GLB positions into this
// CAD coordinate convention. Sidecar coordinates remain native CDSL mm.
function previewVector(value: Vector3): Vector3 {
return [value[0] * GLB_CAD_UNIT_SCALE, -value[2] * GLB_CAD_UNIT_SCALE, value[1] * GLB_CAD_UNIT_SCALE];
}
function normalized(value: Vector3): Vector3 | null {
const length = Math.hypot(value[0], value[1], value[2]);
return length > 1e-9 ? [value[0] / length, value[1] / length, value[2] / length] : null;
}
function dot(left: Vector3, right: Vector3) {
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
}
function subtract(left: Vector3, right: Vector3): Vector3 {
return [left[0] - right[0], left[1] - right[1], left[2] - right[2]];
}
function cross(left: Vector3, right: Vector3): Vector3 {
return [
left[1] * right[2] - left[2] * right[1],
left[2] * right[0] - left[0] * right[2],
left[0] * right[1] - left[1] * right[0],
];
}
function mappedBBox(value: unknown) {
const bbox = value as { min?: unknown; max?: unknown } | null;
const min = vector(bbox?.min);
const max = vector(bbox?.max);
if (!min || !max) return null;
const points: Vector3[] = [];
for (const x of [min[0], max[0]]) {
for (const y of [min[1], max[1]]) {
for (const z of [min[2], max[2]]) points.push(previewVector([x, y, z]));
}
}
return {
min: [Math.min(...points.map((point) => point[0])), Math.min(...points.map((point) => point[1])), Math.min(...points.map((point) => point[2]))],
max: [Math.max(...points.map((point) => point[0])), Math.max(...points.map((point) => point[1])), Math.max(...points.map((point) => point[2]))],
};
}
function sourceFrame(reference: SidecarReference) {
const frame = reference.frame || {};
const origin = vector(frame.origin_mm) || vector(reference.center);
const normal = vector(frame.normal) || vector(reference.normal);
const xDir = vector(frame.x_dir) || [1, 0, 0] as Vector3;
const yDir = vector(frame.y_dir) || [0, 1, 0] as Vector3;
return origin && normal ? { origin_mm: origin, normal, x_dir: xDir, y_dir: yDir } : null;
}
function triangleRuns(faceIds: Uint32Array, occurrenceRow: number) {
const rows: number[] = [];
let index = 0;
while (index < faceIds.length) {
const faceRow = faceIds[index];
if (faceRow === FACE_ID_NONE) {
index += 1;
continue;
}
const start = index;
while (index < faceIds.length && faceIds[index] === faceRow) index += 1;
rows.push(occurrenceRow, 0, start, index - start, faceRow);
}
return new Uint32Array(rows);
}
export function buildCdslSelectorRuntime(sidecar: Sidecar, meshData: any) {
const sourceReferences = Array.isArray(sidecar?.references) ? sidecar.references : [];
const faces = sourceReferences
.filter((reference) => String(reference?.selectorType || "").toLowerCase() === "face")
.map((reference, rowIndex) => {
const frame = sourceFrame(reference);
if (!frame) return null;
const previewCenter = previewVector(frame.origin_mm);
const previewNormal = normalized(previewVector(frame.normal));
if (!previewNormal) return null;
const displaySelector = String(reference.id || `face_${rowIndex}`);
return {
id: displaySelector,
selectorType: "face",
normalizedSelector: displaySelector,
displaySelector,
label: String(reference.label || displaySelector),
summary: String(reference.label || displaySelector),
shortSummary: String(reference.label || displaySelector),
partId: "glb:0",
rowIndex,
pickData: {
selectorType: "face",
rowIndex,
bbox: mappedBBox(reference.bbox),
center: previewCenter,
normal: previewNormal,
surfaceType: String(reference.surface_type || "plane"),
surface: {
type: String(reference.surface_type || "plane"),
origin: previewCenter,
normal: previewNormal,
xDir: normalized(previewVector(frame.x_dir)) || [1, 0, 0],
yDir: normalized(previewVector(frame.y_dir)) || [0, 1, 0],
},
cdslFrame: frame,
cdslCoordinateSystem: "build123d_y_up_glb",
// Keep native CDSL millimetres for the deterministic edit compiler.
frame,
triangleStart: Math.max(0, Math.floor(Number(reference.triangle_start) || 0)),
triangleCount: Math.max(0, Math.floor(Number(reference.triangle_count) || 0)),
adjacentSelectors: [],
},
};
})
.filter(Boolean) as any[];
const vertices = meshData?.vertices;
const indices = meshData?.indices;
const triangleCount = Math.floor((indices?.length || 0) / 3);
const faceIds = new Uint32Array(triangleCount);
faceIds.fill(FACE_ID_NONE);
const explicitRanges = faces.some((face) => face.pickData.triangleCount > 0);
if (explicitRanges) {
for (const face of faces) {
const start = Math.min(face.pickData.triangleStart, triangleCount);
const end = Math.min(start + face.pickData.triangleCount, triangleCount);
faceIds.fill(face.rowIndex, start, end);
}
} else if (vertices && indices) {
const planeTolerance = 0.8 * GLB_CAD_UNIT_SCALE;
for (let triangleIndex = 0; triangleIndex < triangleCount; triangleIndex += 1) {
const positions = [0, 1, 2].map((corner) => {
const offset = Number(indices[(triangleIndex * 3) + corner]) * 3;
return [Number(vertices[offset]), Number(vertices[offset + 1]), Number(vertices[offset + 2])] as Vector3;
});
if (!positions.flat().every(Number.isFinite)) continue;
const center: Vector3 = [
(positions[0][0] + positions[1][0] + positions[2][0]) / 3,
(positions[0][1] + positions[1][1] + positions[2][1]) / 3,
(positions[0][2] + positions[1][2] + positions[2][2]) / 3,
];
const triangleNormal = normalized(cross(subtract(positions[1], positions[0]), subtract(positions[2], positions[0])));
let bestRow = FACE_ID_NONE;
let bestScore = Infinity;
for (const face of faces) {
const normal = face.pickData.normal as Vector3;
const distance = Math.abs(dot(subtract(center, face.pickData.center as Vector3), normal));
if (distance > planeTolerance) continue;
const alignment = triangleNormal ? Math.abs(dot(triangleNormal, normal)) : 1;
if (alignment < 0.7) continue;
const score = distance + ((1 - alignment) * planeTolerance);
if (score < bestScore) {
bestScore = score;
bestRow = face.rowIndex;
}
}
faceIds[triangleIndex] = bestRow;
}
}
const faceRuns = triangleRuns(faceIds, 0);
const references = faces;
return {
schemaVersion: 1,
surfaceEdgeRendering: false,
capabilities: null,
cadPath: "",
stepHash: "",
bbox: meshData?.bounds || null,
occurrences: [{ id: "glb:0" }],
shapes: [],
faces: references,
edges: [],
vertices: [],
references,
referenceMap: new Map(references.map((reference) => [reference.id, reference])),
referenceByNormalizedSelector: new Map(references.map((reference) => [reference.normalizedSelector, reference])),
referenceByDisplaySelector: new Map(references.map((reference) => [reference.displaySelector, reference])),
faceReferenceByRowIndex: new Map(references.map((reference) => [reference.rowIndex, reference])),
edgeReferenceByRowIndex: new Map(),
vertexReferenceByRowIndex: new Map(),
occurrenceIdByRowIndex: new Map([[0, "glb:0"]]),
faceReferenceMap: new Map(references.map((reference) => [reference.id, reference])),
edgeReferenceMap: new Map(),
vertexReferenceMap: new Map(),
singleOccurrenceId: "glb:0",
proxy: {
facePositions: new Float32Array(0),
faceIndices: new Uint32Array(0),
faceIds,
faceRuns,
faceRunColumns: ["occurrenceRow", "primitiveIndex", "triangleStart", "triangleCount", "faceRow"],
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0),
edgeIds: new Uint32Array(0),
faceEdgeRows: [],
edgeFaceRows: [],
},
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+22
View File
@@ -0,0 +1,22 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
async function collectTests(root: string): Promise<string[]> {
const entries = await readdir(root, { withFileTypes: true });
const collected: string[] = [];
for (const entry of entries) {
const full = path.join(root, entry.name);
if (entry.isDirectory()) {
collected.push(...await collectTests(full));
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
collected.push(full);
}
}
return collected;
}
const srcRoot = path.dirname(fileURLToPath(import.meta.url));
for (const testFile of (await collectTests(srcRoot)).sort()) {
await import(pathToFileURL(testFile).href);
}
+4
View File
@@ -0,0 +1,4 @@
declare module "@/viewer-port/components/CadViewer" {
const CadViewer: any;
export default CadViewer;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
import {
Contrast,
Eye,
EyeOff,
Layers,
Paintbrush,
Spline,
SquareDashed
} from "lucide-react";
import { CAD_DISPLAY_MODE } from "@/viewer-runtime/lib/displaySettings";
export const DISPLAY_MODE_OPTIONS = Object.freeze([
Object.freeze({ value: CAD_DISPLAY_MODE.SOLID, label: "Solid", title: "Shaded with CAD edges", Icon: Layers }),
Object.freeze({ value: CAD_DISPLAY_MODE.RENDERED, label: "Rendered", title: "Shaded material appearance without edge overlay", Icon: Paintbrush }),
Object.freeze({ value: CAD_DISPLAY_MODE.TRANSPARENT, label: "X-Ray", title: "Transparent solids with visible CAD edges", Icon: Eye }),
Object.freeze({ value: CAD_DISPLAY_MODE.HIDDEN_EDGES, label: "Hidden", title: "Shaded with hidden edges visible", Icon: EyeOff }),
Object.freeze({ value: CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED, label: "Lines", title: "Visible lines with hidden lines removed", Icon: SquareDashed }),
Object.freeze({ value: CAD_DISPLAY_MODE.UNSHADED, label: "Flat", title: "Unshaded flat color", Icon: Contrast }),
Object.freeze({ value: CAD_DISPLAY_MODE.WIREFRAME, label: "Wire", title: "Full wireframe", Icon: Spline })
]);
export function displayModeOptionForValue(value) {
return DISPLAY_MODE_OPTIONS.find((option) => option.value === value) || DISPLAY_MODE_OPTIONS[0];
}
@@ -0,0 +1,142 @@
"use client";
import { Check } from "lucide-react";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "@/viewer-runtime/lib/perspective";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "../ui/dropdown-menu";
import {
OrthographicProjectionIcon,
PerspectiveProjectionIcon
} from "./ProjectionModeIcons";
import {
DISPLAY_MODE_OPTIONS,
displayModeOptionForValue
} from "./DisplayModeOptions";
const PROJECTION_MODE_OPTIONS = Object.freeze([
Object.freeze({
value: CAMERA_PROJECTION.ORTHOGRAPHIC,
label: "Orthographic projection",
title: "Switch to orthographic projection",
Icon: OrthographicProjectionIcon
}),
Object.freeze({
value: CAMERA_PROJECTION.PERSPECTIVE,
label: "Perspective projection",
title: "Switch to perspective projection",
Icon: PerspectiveProjectionIcon
})
]);
export function projectionOptionForValue(value) {
const normalizedProjection = normalizeCameraProjection(value);
return PROJECTION_MODE_OPTIONS.find((option) => option.value === normalizedProjection) || PROJECTION_MODE_OPTIONS[0];
}
export function projectionMenuLabel(option) {
return String(option?.label || "").replace(/\s+projection$/i, "") || "Perspective";
}
export function DisplayProjectionControl({
displayMode,
onDisplayModeChange,
projection,
onProjectionChange,
open = false,
onOpenChange,
triggerClassName,
iconClassName = "size-3",
contentAlign = "start",
contentSide = "bottom",
contentSideOffset = 6
}) {
const showDisplayModeSection = typeof onDisplayModeChange === "function";
const showProjectionSection = typeof onProjectionChange === "function";
const selectedOption = displayModeOptionForValue(displayMode);
const selectedProjectionOption = projectionOptionForValue(projection);
const SelectedProjectionIcon = selectedProjectionOption.Icon || PerspectiveProjectionIcon;
const label = `Display and projection: ${selectedOption?.label || "Solid"}, ${projectionMenuLabel(selectedProjectionOption)}`;
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={label}
className={triggerClassName}
onPointerDown={(event) => {
event.stopPropagation();
}}
>
<SelectedProjectionIcon className={iconClassName} strokeWidth={2} aria-hidden="true" />
<span className="sr-only">{label}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={contentAlign} side={contentSide} sideOffset={contentSideOffset} className="w-44">
{showProjectionSection ? (
<>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Projection
</DropdownMenuLabel>
{PROJECTION_MODE_OPTIONS.map((option) => {
const Icon = option.Icon;
const selected = selectedProjectionOption.value === option.value;
return (
<DropdownMenuItem
key={option.value}
className="text-xs"
onSelect={() => {
onProjectionChange?.(option.value);
}}
>
<Icon className="size-3 text-muted-foreground" strokeWidth={2} aria-hidden="true" />
{projectionMenuLabel(option)}
{selected ? (
<Check className="ml-auto size-3.5 text-popover-foreground" strokeWidth={2} aria-hidden="true" />
) : (
<span className="ml-auto size-3.5" aria-hidden="true" />
)}
</DropdownMenuItem>
);
})}
</>
) : null}
{showDisplayModeSection && showProjectionSection ? <DropdownMenuSeparator /> : null}
{showDisplayModeSection ? (
<>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Display
</DropdownMenuLabel>
{DISPLAY_MODE_OPTIONS.map((option) => {
const selected = selectedOption.value === option.value;
return (
<DropdownMenuItem
key={option.value}
className="text-xs"
onSelect={() => {
onDisplayModeChange?.(option.value);
}}
>
{option.label}
{selected ? (
<Check className="ml-auto size-3.5 text-popover-foreground" strokeWidth={2} aria-hidden="true" />
) : (
<span className="ml-auto size-3.5" aria-hidden="true" />
)}
</DropdownMenuItem>
);
})}
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,39 @@
export function OrthographicProjectionIcon({ className, strokeWidth = 1.45 }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path
d="M12 3.5 20 8v8l-8 4.5L4 16V8l8-4.5Z"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
/>
<path
d="M4 8l8 4.5L20 8M12 12.5v8"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function PerspectiveProjectionIcon({ className, strokeWidth = 1.45 }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
<path
d="M12 4.6 19.6 6.1l-2 10.1-5.6 4.2-5.6-4.2-2-10.1L12 4.6Z"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="round"
/>
<path
d="M4.4 6.1 12 9.2l7.6-3.1M12 9.2v11.2"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,479 @@
import { useState } from "react";
const ORIENTATION_FALLBACK = Object.freeze({
x: [1, 0, 0],
y: [0, 1, 0],
z: [0, 0, 1]
});
const DEFAULT_VIEW_PLANE_PALETTE = Object.freeze({
axis: {
x: {
front: [250, 88, 79],
back: [122, 32, 28]
},
y: {
front: [92, 233, 123],
back: [30, 99, 46]
},
z: {
front: [84, 131, 255],
back: [30, 53, 126]
}
},
center: {
fill: [252, 215, 74],
stroke: [255, 235, 153]
}
});
const DEFAULT_VIEW_PLANE_SIZE = "6.71875rem";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeCssLength(value, fallback = "") {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return `${value}px`;
}
const text = String(value || "").trim();
return text || fallback;
}
function toRgb(value) {
const [r = 0, g = 0, b = 0] = Array.isArray(value) ? value : [0, 0, 0];
return [Math.round(clamp(r, 0, 255)), Math.round(clamp(g, 0, 255)), Math.round(clamp(b, 0, 255))];
}
function mixRgb(from, to, amount) {
const ratio = clamp(amount, 0, 1);
const left = toRgb(from);
const right = toRgb(to);
return [
left[0] + (right[0] - left[0]) * ratio,
left[1] + (right[1] - left[1]) * ratio,
left[2] + (right[2] - left[2]) * ratio
];
}
function rgbToCss(value, alpha = 1) {
const [r, g, b] = toRgb(value);
return `rgba(${r}, ${g}, ${b}, ${clamp(alpha, 0, 1)})`;
}
function normalizeRgbTriplet(value, fallback) {
if (!Array.isArray(value) || value.length < 3) {
return [...fallback];
}
const r = Number(value[0]);
const g = Number(value[1]);
const b = Number(value[2]);
if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b)) {
return [...fallback];
}
return [clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255)];
}
function resolveViewPlanePalette(viewerTheme) {
const themePalette = viewerTheme?.viewPlanePalette || {};
const themeAxisPalette = themePalette?.axis || {};
const axis = {};
for (const axisId of ["x", "y", "z"]) {
const fallback = DEFAULT_VIEW_PLANE_PALETTE.axis[axisId];
const custom = themeAxisPalette?.[axisId] || {};
axis[axisId] = {
front: normalizeRgbTriplet(custom.front, fallback.front),
back: normalizeRgbTriplet(custom.back, fallback.back)
};
}
return {
axis,
center: {
fill: normalizeRgbTriplet(themePalette?.center?.fill, DEFAULT_VIEW_PLANE_PALETTE.center.fill),
stroke: normalizeRgbTriplet(themePalette?.center?.stroke, DEFAULT_VIEW_PLANE_PALETTE.center.stroke)
}
};
}
function normalizeAxis(axis, fallback) {
if (!Array.isArray(axis) || axis.length !== 3) {
return [...fallback];
}
const x = Number(axis[0]);
const y = Number(axis[1]);
const z = Number(axis[2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return [...fallback];
}
const magnitude = Math.hypot(x, y, z);
if (magnitude < 1e-6) {
return [...fallback];
}
return [x / magnitude, y / magnitude, z / magnitude];
}
function normalizeOrientation(orientation) {
return {
x: normalizeAxis(orientation?.x, ORIENTATION_FALLBACK.x),
y: normalizeAxis(orientation?.y, ORIENTATION_FALLBACK.y),
z: normalizeAxis(orientation?.z, ORIENTATION_FALLBACK.z)
};
}
function projectDirection(orientation, direction) {
const [dx = 0, dy = 0, dz = 0] = Array.isArray(direction) ? direction : [0, 0, 0];
return [
orientation.x[0] * dx + orientation.y[0] * dy + orientation.z[0] * dz,
orientation.x[1] * dx + orientation.y[1] * dy + orientation.z[1] * dz,
orientation.x[2] * dx + orientation.y[2] * dy + orientation.z[2] * dz
];
}
function getAxisId(face) {
const id = String(face?.id || "");
return id.startsWith("x") ? "x" : id.startsWith("y") ? "y" : "z";
}
export default function ViewPlaneControl({
showViewPlane,
previewMode,
isLoading,
meshData,
viewPlaneOffsetRight,
viewPlaneOffsetBottom = 16,
activeViewPlaneFace,
viewPlaneFaces,
viewPlaneOrientation,
viewerTheme,
compact = false,
variant = "3d",
viewPlaneSize,
viewPlaneHeader = null,
activateViewPlaneFace,
activateDefaultViewPlane
}) {
const [hoveredNodeId, setHoveredNodeId] = useState("");
if (!showViewPlane || previewMode || isLoading || !meshData) {
return null;
}
const orientation = normalizeOrientation(viewPlaneOrientation);
const palette = resolveViewPlanePalette(viewerTheme);
const faces = Array.isArray(viewPlaneFaces) ? viewPlaneFaces : [];
const projectedNodes = faces
.map((face) => {
const axisId = getAxisId(face);
const axisPalette = palette.axis[axisId] || palette.axis.z;
const [x, y, z] = projectDirection(orientation, face.direction);
const depth = clamp((z + 1) / 2, 0, 1);
const fillColor = mixRgb(axisPalette.back, axisPalette.front, depth);
const edgeColor = mixRgb([10, 16, 28], axisPalette.front, depth * 0.82 + 0.08);
return {
id: face.id,
title: face.title,
x: 50 + x * 28,
y: 50 - y * 28,
z,
depth,
radius: 4.95 + depth * 1.05,
fill: rgbToCss(fillColor),
edge: rgbToCss(edgeColor),
stem: rgbToCss(fillColor, 0.32 + depth * 0.48)
};
})
.sort((left, right) => left.z - right.z);
const backNodes = projectedNodes.filter((node) => node.z < 0);
const frontNodes = projectedNodes.filter((node) => node.z >= 0);
const is2d = variant === "2d";
const customViewPlaneSize = !compact && !is2d
? normalizeCssLength(viewPlaneSize, DEFAULT_VIEW_PLANE_SIZE)
: "";
const viewPlaneSizeClasses = compact || is2d ? "h-20 w-20" : "";
const viewPlaneSizeStyle = customViewPlaneSize
? { width: customViewPlaneSize, height: customViewPlaneSize }
: undefined;
const viewPlaneSurfaceClasses = is2d
? "cad-glass-surface pointer-events-auto relative rounded-md border border-sidebar-border text-sidebar-foreground shadow-sm transition duration-150"
: "pointer-events-auto relative text-sidebar-foreground transition duration-150";
const viewPlaneLabel = is2d ? "2D view selector" : "Perspective selector";
const normalizedBottomOffset = typeof viewPlaneOffsetBottom === "number"
? `${viewPlaneOffsetBottom}px`
: viewPlaneOffsetBottom;
const centerHovered = hoveredNodeId === "__default__";
const activateNearestViewNode = (event) => {
const svg = event.currentTarget;
const rect = svg.getBoundingClientRect?.();
if (!rect?.width || !rect?.height) {
return;
}
const x = ((event.clientX - rect.left) / rect.width) * 100;
const y = ((event.clientY - rect.top) / rect.height) * 100;
const centerDistance = Math.hypot(x - 50, y - 50);
event.preventDefault();
event.stopPropagation();
if (centerDistance <= 13) {
setHoveredNodeId("__default__");
activateDefaultViewPlane?.();
return;
}
const nearest = projectedNodes
.map((node) => ({
node,
distance: Math.hypot(x - node.x, y - node.y),
}))
.sort((left, right) => left.distance - right.distance)[0];
if (nearest?.node && centerDistance <= 48) {
setHoveredNodeId(nearest.node.id);
activateViewPlaneFace?.(nearest.node.id);
}
};
const renderNode = (node) => {
const active = activeViewPlaneFace === node.id;
const hovered = hoveredNodeId === node.id;
return (
<g
key={node.id}
role="button"
tabIndex={0}
aria-label={node.title}
aria-pressed={active}
className="group cursor-pointer focus:outline-none"
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
setHoveredNodeId(node.id);
activateViewPlaneFace(node.id);
}}
onPointerEnter={() => {
setHoveredNodeId(node.id);
}}
onPointerMove={() => {
setHoveredNodeId(node.id);
}}
onPointerLeave={() => {
setHoveredNodeId((current) => (current === node.id ? "" : current));
}}
onMouseEnter={() => {
setHoveredNodeId(node.id);
}}
onMouseMove={() => {
setHoveredNodeId(node.id);
}}
onMouseLeave={() => {
setHoveredNodeId((current) => (current === node.id ? "" : current));
}}
onFocus={() => {
setHoveredNodeId(node.id);
}}
onBlur={() => {
setHoveredNodeId((current) => (current === node.id ? "" : current));
}}
onClick={(event) => {
event.stopPropagation();
activateViewPlaneFace(node.id);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
event.stopPropagation();
activateViewPlaneFace(node.id);
}}
>
<circle
cx={node.x}
cy={node.y}
r={node.radius + 4.4}
className="transition-opacity duration-150"
opacity={hovered ? 1 : 0}
fill={node.fill}
fillOpacity="0.12"
stroke="var(--sidebar-foreground)"
strokeOpacity="0.72"
strokeWidth="1.1"
/>
<circle
cx={node.x}
cy={node.y}
r={node.radius + (active ? 2.1 : 0)}
fill="none"
stroke={active ? "var(--sidebar-foreground)" : "transparent"}
strokeWidth={active ? 1.6 : 0}
/>
<circle
cx={node.x}
cy={node.y}
r={node.radius}
className="transition-transform duration-150 ease-out"
style={{
transform: hovered ? "scale(1.1)" : "scale(1)",
transformBox: "fill-box",
transformOrigin: "center"
}}
fill={node.fill}
stroke={active ? "var(--sidebar-foreground)" : node.edge}
strokeWidth={active ? 1.35 : 1}
/>
</g>
);
};
return (
<div
data-viewer-interaction-overlay="true"
className="pointer-events-none absolute z-30 flex flex-col items-end gap-1"
style={{ right: `${viewPlaneOffsetRight}px`, bottom: normalizedBottomOffset }}
>
{viewPlaneHeader ? (
<div
className="pointer-events-auto"
onPointerDown={(event) => {
event.stopPropagation();
}}
>
{viewPlaneHeader}
</div>
) : null}
<div
className={`${viewPlaneSurfaceClasses} ${viewPlaneSizeClasses}`}
style={viewPlaneSizeStyle}
onPointerDown={(event) => {
event.stopPropagation();
}}
>
<svg
className="absolute inset-0 h-full w-full"
viewBox="0 0 100 100"
aria-label={viewPlaneLabel}
onPointerDown={activateNearestViewNode}
>
<defs>
<radialGradient id="view-sphere-shell" cx="34%" cy="28%" r="74%">
<stop offset="0%" stopColor="var(--sidebar)" />
<stop offset="100%" stopColor="var(--sidebar)" />
</radialGradient>
</defs>
{is2d ? (
<>
<rect x="15" y="15" width="70" height="70" rx="8" fill="url(#view-sphere-shell)" stroke="var(--sidebar-border)" strokeWidth="0.75" />
<line x1="22" y1="50" x2="78" y2="50" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 18%, transparent)" strokeWidth="1" strokeLinecap="round" />
<line x1="50" y1="22" x2="50" y2="78" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 18%, transparent)" strokeWidth="1" strokeLinecap="round" />
</>
) : (
<>
<circle cx="50" cy="50" r="44" fill="url(#view-sphere-shell)" stroke="var(--sidebar-border)" strokeWidth="0.75" />
<ellipse cx="50" cy="50" rx="30" ry="11.8" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 12%, transparent)" strokeWidth="0.8" />
<ellipse cx="50" cy="50" rx="14" ry="30" fill="none" stroke="color-mix(in oklch, var(--sidebar-foreground) 12%, transparent)" strokeWidth="0.8" />
</>
)}
{backNodes.map((node) => (
<line
key={`${node.id}-stem`}
x1="50"
y1="50"
x2={node.x}
y2={node.y}
stroke={node.stem}
strokeWidth={1.6 + node.depth * 0.8}
strokeLinecap="round"
pointerEvents="none"
/>
))}
{backNodes.map((node) => renderNode(node))}
<g
role="button"
tabIndex={0}
aria-label={is2d ? "Fit 2D view" : "Reset to default isometric view"}
className="group cursor-pointer focus:outline-none"
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
setHoveredNodeId("__default__");
activateDefaultViewPlane?.();
}}
onPointerEnter={() => {
setHoveredNodeId("__default__");
}}
onPointerMove={() => {
setHoveredNodeId("__default__");
}}
onPointerLeave={() => {
setHoveredNodeId((current) => (current === "__default__" ? "" : current));
}}
onMouseEnter={() => {
setHoveredNodeId("__default__");
}}
onMouseMove={() => {
setHoveredNodeId("__default__");
}}
onMouseLeave={() => {
setHoveredNodeId((current) => (current === "__default__" ? "" : current));
}}
onFocus={() => {
setHoveredNodeId("__default__");
}}
onBlur={() => {
setHoveredNodeId((current) => (current === "__default__" ? "" : current));
}}
onClick={(event) => {
event.stopPropagation();
activateDefaultViewPlane?.();
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
event.stopPropagation();
activateDefaultViewPlane?.();
}}
>
<circle cx="50" cy="50" r="10.2" fill="transparent" stroke="none" />
<circle
cx="50"
cy="50"
r="11.4"
className="transition-opacity duration-150"
opacity={centerHovered ? 1 : 0}
fill={rgbToCss(palette.center.fill, 0.16)}
stroke="var(--sidebar-foreground)"
strokeOpacity="0.72"
strokeWidth="1.1"
/>
<circle
cx="50"
cy="50"
r="7.3"
className="transition-transform duration-150 ease-out"
style={{
transform: centerHovered ? "scale(1.1)" : "scale(1)",
transformBox: "fill-box",
transformOrigin: "center"
}}
fill={rgbToCss(palette.center.fill, 0.95)}
stroke={rgbToCss(palette.center.stroke, 0.72)}
strokeWidth="1.05"
/>
</g>
{frontNodes.map((node) => (
<line
key={`${node.id}-stem-front`}
x1="50"
y1="50"
x2={node.x}
y2={node.y}
stroke={node.stem}
strokeWidth={1.6 + node.depth * 0.8}
strokeLinecap="round"
pointerEvents="none"
/>
))}
{frontNodes.map((node) => renderNode(node))}
</svg>
</div>
</div>
);
}
@@ -0,0 +1,455 @@
import { MeshSurfaceSampler } from "three/examples/jsm/math/MeshSurfaceSampler.js";
const DEFAULT_DURATION_MS = 2600;
const LAYER_COUNT = 22;
const PARTICLE_TOTAL = 520;
const GRAD_BOTTOM = 0x2f7df2;
const GRAD_TOP = 0x59c7ff;
const SCAN_COLOR = 0x6ee4ff;
const PARTICLE_COLOR = 0x62c9ff;
const HOLO_VERT = `
varying vec3 vWPos;
varying vec3 vWN;
varying vec3 vView;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
vWPos = wp.xyz;
vWN = normalize(mat3(modelMatrix) * normal);
vView = normalize(cameraPosition - wp.xyz);
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const HOLO_FRAG = `
uniform float uScanZ, uMinZ, uRange, uBand, uCell, uLineThickness, uTime, uOpacity;
uniform vec3 uColorA, uColorB, uScanColor;
varying vec3 vWPos;
varying vec3 vWN;
varying vec3 vView;
void main() {
if (vWPos.z > uScanZ) discard;
vec3 g = vWPos / uCell;
vec3 gw = fwidth(g);
vec3 a = min(fract(g), 1.0 - fract(g));
vec3 t = (uLineThickness * 0.5) * gw;
vec3 lv = vec3(1.0) - smoothstep(t, t + gw, a);
float res = clamp(1.0 - max(max(gw.x, gw.y), gw.z), 0.0, 1.0);
float line = max(max(lv.x, lv.y), lv.z) * res;
float fres = pow(1.0 - abs(dot(normalize(vWN), normalize(vView))), 2.2);
float h = clamp((vWPos.z - uMinZ) / uRange, 0.0, 1.0);
vec3 gridCol = mix(uColorA, uColorB, h);
float d = uScanZ - vWPos.z;
float scan = smoothstep(uBand, 0.0, abs(d));
float scanCore = smoothstep(uBand * 0.35, 0.0, abs(d));
vec3 lineCol = mix(gridCol, uScanColor, scan);
float lineGlow = clamp(line * (0.9 + 1.9 * scan), 0.0, 1.0);
float fill = 0.5;
vec3 col = mix(gridCol * 0.8, lineCol, lineGlow);
col = mix(col, uScanColor, scanCore * 0.9);
float alpha = clamp(fill + lineGlow * 0.5 + scan * 0.6 + scanCore * 0.6 + fres * 0.25, 0.0, 1.0) * uOpacity;
if (alpha < 0.004) discard;
gl_FragColor = vec4(col, alpha);
}
`;
const PARTICLE_VERT = `
attribute float aRand;
attribute float aType;
uniform float uScanZ, uBand, uTime, uPixelRatio, uSize, uOpacity;
varying float vA;
varying float vType;
void main() {
vType = aType;
vec3 p = position;
float T = uTime * 0.7;
p += vec3(
sin(T + aRand * 30.0),
cos(T * 0.9 + aRand * 22.0),
sin(T * 1.1 + aRand * 17.0)
) * (uBand * 0.3);
vec4 world = modelMatrix * vec4(p, 1.0);
float d = uScanZ - world.z;
float built = step(-uBand * 0.4, d);
float w = uBand * 1.8;
float band = exp(-0.5 * d * d / (w * w));
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mv;
vA = built * band * uOpacity;
float sz = uSize * (0.55 + aRand * 1.0);
float ps = sz * uPixelRatio * (120.0 / -mv.z);
gl_PointSize = vA > 0.02 ? clamp(ps, 1.5, 16.0) : 0.0;
}
`;
const PARTICLE_FRAG = `
uniform vec3 uColor;
uniform float uTime;
varying float vA;
varying float vType;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float dist = length(uv);
if (dist > 1.0) discard;
vec3 col = uColor;
float a;
if (vType < 0.5) {
float core = smoothstep(0.45, 0.22, dist);
float glow = pow(max(0.0, 1.0 - dist), 3.0) * 0.5;
a = core * 0.95 + glow;
col = mix(uColor, vec3(0.85, 0.95, 1.0), core * 0.6);
} else if (vType < 1.5) {
float rw = 0.15;
float rc = 0.6;
float ring = exp(-pow(dist - rc, 2.0) / (2.0 * rw * rw));
a = smoothstep(0.1, 0.6, ring);
a += pow(max(0.0, 1.0 - dist), 3.0) * 0.18;
} else {
float pulse = sin(dist * 5.0 - uTime * 2.0) * 0.1 + 0.9;
float core = smoothstep(0.4, 0.18, dist);
a = pow(max(0.0, 1.0 - dist), 2.5) * pulse * 0.7 + core * 0.5;
col = mix(uColor, vec3(0.85, 0.95, 1.0), core * 0.5);
}
a = clamp(a, 0.0, 1.0) * clamp(vA, 0.0, 1.0);
if (a < 0.01) discard;
gl_FragColor = vec4(col, a);
}
`;
function reducedMotionPreferred() {
return typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function nowMs() {
return typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function easeInOutQuad(value) {
return value < 0.5 ? 2 * value * value : 1 - Math.pow(-2 * value + 2, 2) / 2;
}
function smoothstep(value, edge0, edge1) {
const t = clamp((value - edge0) / Math.max(edge1 - edge0, 1e-6), 0, 1);
return t * t * (3 - 2 * t);
}
function finiteDisplayRecords(runtime) {
return (Array.isArray(runtime?.displayRecords) ? runtime.displayRecords : [])
.filter((record) => record?.mesh?.geometry?.getAttribute?.("position"));
}
function copyObjectMatrix(target, source) {
target.matrixAutoUpdate = false;
target.matrix.copy(source.matrix);
target.matrixWorldNeedsUpdate = true;
}
function materialList(material) {
return (Array.isArray(material) ? material : [material]).filter(Boolean);
}
function rememberMaterial(materialStates, material) {
if (!material || materialStates.has(material)) {
return;
}
materialStates.set(material, {
opacity: material.opacity,
transparent: material.transparent,
depthWrite: material.depthWrite,
colorWrite: material.colorWrite
});
}
function setRecordMaterialOpacity(record, materialStates, opacityScale) {
for (const material of materialList(record?.mesh?.material)) {
const state = materialStates.get(material);
if (!state) {
continue;
}
material.opacity = state.opacity * opacityScale;
material.transparent = true;
material.depthWrite = false;
material.colorWrite = opacityScale > 0.001;
material.needsUpdate = true;
}
}
function restoreMaterials(materialStates) {
for (const [material, state] of materialStates.entries()) {
material.opacity = state.opacity;
material.transparent = state.transparent;
material.depthWrite = state.depthWrite;
material.colorWrite = state.colorWrite;
material.needsUpdate = true;
}
}
function rememberVisibility(visibilityStates, object) {
if (!object || visibilityStates.has(object)) {
return;
}
visibilityStates.set(object, object.visible);
}
function restoreVisibility(visibilityStates) {
for (const [object, visible] of visibilityStates.entries()) {
object.visible = visible;
}
}
function disposeOverlay(objects, materials) {
for (const object of objects) {
object.parent?.remove(object);
if (object.userData?.generationRevealOwnsGeometry === true) {
object.geometry?.dispose?.();
}
}
for (const material of materials) {
material.dispose?.();
}
}
function createParticleCloud(THREE, record, count, particleMaterial) {
const geometry = record?.mesh?.geometry;
const sampleCount = Math.max(0, Math.floor(count));
if (!geometry || sampleCount <= 0) {
return null;
}
const sampler = new MeshSurfaceSampler(record.mesh).build();
const tmp = new THREE.Vector3();
const positions = new Float32Array(sampleCount * 3);
const rand = new Float32Array(sampleCount);
const types = new Float32Array(sampleCount);
for (let index = 0; index < sampleCount; index += 1) {
sampler.sample(tmp);
positions[index * 3] = tmp.x;
positions[index * 3 + 1] = tmp.y;
positions[index * 3 + 2] = tmp.z;
rand[index] = Math.random();
types[index] = Math.floor(Math.random() * 3);
}
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
pointsGeometry.setAttribute("aRand", new THREE.BufferAttribute(rand, 1));
pointsGeometry.setAttribute("aType", new THREE.BufferAttribute(types, 1));
const points = new THREE.Points(pointsGeometry, particleMaterial);
copyObjectMatrix(points, record.mesh);
points.frustumCulled = false;
points.renderOrder = 25;
points.userData.generationRevealOwnsGeometry = true;
record.mesh.parent?.add(points);
return points;
}
export function startGenerationReveal(runtime, {
durationMs = DEFAULT_DURATION_MS,
onReveal,
onPlayingChange
} = {}) {
const THREE = runtime?.THREE;
const records = finiteDisplayRecords(runtime);
if (!THREE || !runtime?.modelGroup || !records.length || reducedMotionPreferred()) {
onPlayingChange?.(false);
return null;
}
runtime.generationReveal?.dispose?.();
const box = new THREE.Box3().setFromObject(runtime.modelGroup);
if (box.isEmpty()) {
onPlayingChange?.(false);
return null;
}
const size = box.getSize(new THREE.Vector3());
const zRange = Math.max(size.z, 0.0001);
const maxAxis = Math.max(size.x, size.y, size.z, 0.0001);
const layerHeight = zRange / LAYER_COUNT;
const uniforms = {
uScanZ: { value: box.min.z },
uMinZ: { value: box.min.z },
uRange: { value: zRange },
uBand: { value: layerHeight * 1.3 },
uCell: { value: layerHeight },
uLineThickness: { value: 1.3 },
uTime: { value: 0 },
uOpacity: { value: 1 },
uColorA: { value: new THREE.Color(GRAD_BOTTOM) },
uColorB: { value: new THREE.Color(GRAD_TOP) },
uScanColor: { value: new THREE.Color(SCAN_COLOR) }
};
const particleUniforms = {
uScanZ: uniforms.uScanZ,
uBand: uniforms.uBand,
uTime: uniforms.uTime,
uOpacity: uniforms.uOpacity,
uPixelRatio: { value: runtime.renderer?.getPixelRatio?.() || 1 },
// Deepbuild normalizes its largest model axis to 3.2 before using uSize=0.42.
uSize: { value: maxAxis * (0.42 / 3.2) },
uColor: { value: new THREE.Color(PARTICLE_COLOR) }
};
const holoMaterial = new THREE.ShaderMaterial({
uniforms,
vertexShader: HOLO_VERT,
fragmentShader: HOLO_FRAG,
transparent: true,
depthWrite: false,
side: THREE.FrontSide,
blending: THREE.NormalBlending
});
const particleMaterial = new THREE.ShaderMaterial({
uniforms: particleUniforms,
vertexShader: PARTICLE_VERT,
fragmentShader: PARTICLE_FRAG,
transparent: true,
depthWrite: false,
depthTest: false,
blending: THREE.AdditiveBlending
});
const overlayObjects = [];
const overlayMaterials = new Set([holoMaterial, particleMaterial]);
const visibilityStates = new Map();
const materialStates = new Map();
rememberVisibility(visibilityStates, runtime.edgesGroup);
for (const record of records) {
rememberVisibility(visibilityStates, record.mesh);
for (const material of materialList(record.mesh.material)) {
rememberMaterial(materialStates, material);
}
const holo = new THREE.Mesh(record.mesh.geometry, holoMaterial);
copyObjectMatrix(holo, record.mesh);
holo.frustumCulled = false;
holo.renderOrder = 24;
record.mesh.parent?.add(holo);
overlayObjects.push(holo);
}
const syncHiddenState = () => {
if (runtime.edgesGroup) {
runtime.edgesGroup.visible = false;
}
for (const record of records) {
record.mesh.visible = false;
setRecordMaterialOpacity(record, materialStates, 0);
}
};
syncHiddenState();
const particlesPerRecord = Math.max(1, Math.floor(PARTICLE_TOTAL / records.length));
window.requestAnimationFrame(() => {
if (controller.disposed) {
return;
}
for (const record of records) {
try {
const cloud = createParticleCloud(THREE, record, particlesPerRecord, particleMaterial);
if (cloud) {
overlayObjects.push(cloud);
}
} catch {
// Particle sampling is cosmetic; keep the scan reveal even if a mesh is degenerate.
}
}
runtime.requestRender?.();
});
let rafId = 0;
let playing = true;
let revealNotified = false;
const startedAt = nowMs();
onPlayingChange?.(true);
const finish = ({ restore = true, completed = false } = {}) => {
if (controller.disposed) {
return;
}
controller.disposed = true;
controller.finished = completed === true;
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (restore) {
restoreVisibility(visibilityStates);
restoreMaterials(materialStates);
}
disposeOverlay(overlayObjects, overlayMaterials);
if (runtime.generationReveal === controller) {
runtime.generationReveal = null;
}
if (playing) {
playing = false;
onPlayingChange?.(false);
}
runtime.requestRender?.();
};
const step = (timestamp) => {
if (controller.disposed) {
return;
}
const elapsed = (Number.isFinite(Number(timestamp)) ? Number(timestamp) : nowMs()) - startedAt;
const progress = clamp(elapsed / durationMs, 0, 1);
if (!revealNotified && progress >= 0.42) {
revealNotified = true;
onReveal?.();
}
if (progress >= 1) {
finish({ completed: true });
return;
}
const revealProgress = easeInOutQuad(Math.min(1, progress / 0.85));
uniforms.uTime.value = elapsed / 1000;
uniforms.uScanZ.value = box.min.z + revealProgress * zRange * 1.02;
uniforms.uOpacity.value = 1 - smoothstep(progress, 0.82, 0.92);
particleUniforms.uPixelRatio.value = runtime.renderer?.getPixelRatio?.() || 1;
const solidOpacity = smoothstep(progress, 0.84, 0.94);
for (const record of records) {
record.mesh.visible = solidOpacity > 0.001;
setRecordMaterialOpacity(record, materialStates, solidOpacity);
}
runtime.requestRender?.();
rafId = window.requestAnimationFrame(step);
};
const controller = {
disposed: false,
finished: false,
syncHiddenState: () => {
if (!controller.disposed) {
syncHiddenState();
runtime.requestRender?.();
}
},
dispose: () => finish(),
cancel: () => finish()
};
runtime.generationReveal = controller;
rafId = window.requestAnimationFrame(step);
return controller;
}
@@ -0,0 +1,146 @@
function finiteVector3(value) {
const raw = Array.isArray(value)
? value
: value && typeof value === "object"
? [value.x, value.y, value.z]
: null;
if (!Array.isArray(raw) || raw.length < 3) {
return null;
}
const vector = raw.slice(0, 3).map((component) => Number(component));
return vector.every(Number.isFinite) ? vector : null;
}
function finiteIntegerOrNull(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const numericValue = Number(value);
return Number.isInteger(numericValue) ? numericValue : null;
}
function normalizeSurfaceForEditIntent(surface) {
if (!surface || typeof surface !== "object") {
return null;
}
const normalized = {
type: String(surface.type || surface.surfaceType || "").trim() || null,
origin: finiteVector3(surface.origin),
xDir: finiteVector3(surface.xDir),
yDir: finiteVector3(surface.yDir),
normal: finiteVector3(surface.normal),
axis: finiteVector3(surface.axis),
radius: Number.isFinite(Number(surface.radius)) ? Number(surface.radius) : null
};
return Object.fromEntries(Object.entries(normalized).filter(([, value]) => value !== null));
}
function cdslPointFromPreview(value) {
const vector = finiteVector3(value);
if (!vector) {
return null;
}
return [vector[0] / 1000, vector[2] / 1000, -vector[1] / 1000];
}
function cdslDirectionFromPreview(value) {
const vector = finiteVector3(value);
return vector ? [vector[0], vector[2], -vector[1]] : null;
}
function cdslFrameForPick(pickData, pointModel) {
if (pickData?.cdslCoordinateSystem !== "build123d_y_up_glb") {
return null;
}
const origin = cdslPointFromPreview(pointModel);
const normal = cdslDirectionFromPreview(pickData.normal || pickData.surface?.normal);
const xDir = cdslDirectionFromPreview(pickData.surface?.xDir);
const yDir = cdslDirectionFromPreview(pickData.surface?.yDir);
if (!origin || !normal || !xDir || !yDir) {
return pickData.cdslFrame || null;
}
return { origin_mm: origin, normal, x_dir: xDir, y_dir: yDir };
}
export function referenceSelectorType(reference) {
return String(reference?.selectorType || reference?.pickData?.selectorType || "").trim();
}
export function buildSurfacePointPickPayload({
reference,
intersection,
localPoint,
clientX,
clientY
}) {
if (!reference || !intersection?.point || !localPoint) {
return null;
}
const pointWorld = finiteVector3(intersection.point);
const pointModel = finiteVector3(localPoint);
if (!pointWorld || !pointModel) {
return null;
}
const pickData = reference.pickData || {};
const frame = cdslFrameForPick(pickData, pointModel);
return {
kind: "surface_point",
clientX: Number(clientX) || 0,
clientY: Number(clientY) || 0,
referenceId: String(reference.id || "").trim(),
selector: String(reference.displaySelector || reference.normalizedSelector || "").trim(),
normalizedSelector: String(reference.normalizedSelector || reference.displaySelector || "").trim(),
partId: String(reference.partId || "").trim(),
entityType: String(reference.entityType || "").trim(),
selectorType: referenceSelectorType(reference),
faceIndex: finiteIntegerOrNull(intersection.faceIndex),
rowIndex: finiteIntegerOrNull(reference.rowIndex ?? pickData.rowIndex),
pointWorld,
pointModel,
normal: finiteVector3(pickData.normal || pickData.surface?.normal),
surface: normalizeSurfaceForEditIntent(pickData.surface || {
type: pickData.surfaceType,
normal: pickData.normal
}),
...(frame ? { frame } : {})
};
}
export function buildEdgePickPayload({
reference,
intersection,
localPoint,
clientX,
clientY
}) {
if (!reference) {
return null;
}
const referenceId = String(reference.id || "").trim();
if (!referenceId) {
return null;
}
const pickData = reference.pickData || {};
const pointWorld = finiteVector3(intersection?.point);
const pointModel = finiteVector3(localPoint);
return {
kind: "edge",
clientX: Number(clientX) || 0,
clientY: Number(clientY) || 0,
referenceId,
selector: String(reference.displaySelector || reference.normalizedSelector || "").trim(),
normalizedSelector: String(reference.normalizedSelector || reference.displaySelector || "").trim(),
partId: String(reference.partId || "").trim(),
entityType: String(reference.entityType || "").trim(),
selectorType: referenceSelectorType(reference) || "edge",
rowIndex: finiteIntegerOrNull(reference.rowIndex ?? pickData.rowIndex),
pointWorld,
pointModel,
curve: pickData.curve && typeof pickData.curve === "object" ? pickData.curve : null,
adjacentFaces: Array.isArray(pickData.adjacentFaces)
? pickData.adjacentFaces
: Array.isArray(pickData.adjacentFaceSelectors)
? pickData.adjacentFaceSelectors
: []
};
}
@@ -0,0 +1,263 @@
import { useEffect } from "react";
import { DRAWING_TOOL } from "../../../workbench/constants";
export function useViewerDrawingOverlay({
drawingCanvasRef,
drawingDraftRef,
drawingStrokesRef,
drawingChangeRef,
drawingIdRef,
drawingEnabled,
drawingTool,
meshData,
previewMode,
viewerReadyTick,
renderDrawingOverlay,
redrawDrawingCanvas,
buildDrawingPoint,
distanceToStrokeInPixels,
strokeLengthInPixels,
drawingToolNeedsTwoPoints,
buildFillStrokeAtPoint,
buildSurfaceLineAnchor,
updateSurfaceLineAnchor,
drawingEraseThresholdPx,
drawingMinPointDistancePx,
drawingMinStrokeLengthPx
}) {
useEffect(() => {
const canvas = drawingCanvasRef.current;
if (!canvas) {
return undefined;
}
if (!drawingEnabled || previewMode || !meshData) {
drawingDraftRef.current = null;
renderDrawingOverlay();
return undefined;
}
const interaction = {
pointerId: null,
erasing: false
};
const commitStrokes = (nextStrokes) => {
drawingStrokesRef.current = nextStrokes;
drawingChangeRef.current?.(nextStrokes);
redrawDrawingCanvas(canvas, nextStrokes, drawingDraftRef.current);
};
const commitSingleStroke = (stroke) => {
if (!stroke) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
return;
}
drawingIdRef.current += 1;
commitStrokes([
...drawingStrokesRef.current,
{
id: `stroke-${drawingIdRef.current}`,
...stroke
}
]);
};
const eraseStrokeAt = (point) => {
const width = canvas.width || 1;
const height = canvas.height || 1;
let bestIndex = -1;
let bestDistance = Infinity;
for (let index = 0; index < drawingStrokesRef.current.length; index += 1) {
const distance = distanceToStrokeInPixels(point, drawingStrokesRef.current[index], width, height);
if (distance < bestDistance) {
bestDistance = distance;
bestIndex = index;
}
}
if (bestIndex === -1 || bestDistance > drawingEraseThresholdPx) {
return;
}
const nextStrokes = drawingStrokesRef.current.filter((_, index) => index !== bestIndex);
commitStrokes(nextStrokes);
};
const beginDraft = (point) => {
drawingDraftRef.current = {
id: "__draft__",
tool: drawingTool,
points: [point]
};
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
};
const updateDraft = (point) => {
const draft = drawingDraftRef.current;
if (!draft) {
return;
}
if (drawingTool === DRAWING_TOOL.FREEHAND) {
const lastPoint = draft.points[draft.points.length - 1];
const dx = (point.x - lastPoint.x) * (canvas.width || 1);
const dy = (point.y - lastPoint.y) * (canvas.height || 1);
if (Math.hypot(dx, dy) >= drawingMinPointDistancePx) {
draft.points = [...draft.points, point];
}
} else {
draft.points = [draft.points[0], point];
}
redrawDrawingCanvas(canvas, drawingStrokesRef.current, draft);
};
const endDraft = () => {
const draft = drawingDraftRef.current;
if (!draft) {
return;
}
drawingDraftRef.current = null;
const strokeLength = strokeLengthInPixels(draft, canvas.width || 1, canvas.height || 1);
if (drawingToolNeedsTwoPoints(draft.tool) && (draft.points.length < 2 || strokeLength < drawingMinStrokeLengthPx)) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
if (strokeLength < drawingMinStrokeLengthPx && draft.points.length > 1) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
commitSingleStroke({
tool: draft.tool,
points: draft.points.map((point) => ({ x: point.x, y: point.y })),
surfaceLine: draft.surfaceLine || null
});
};
const handlePointerDown = (event) => {
if (event.button !== 0) {
return;
}
interaction.erasing = drawingTool === DRAWING_TOOL.ERASE;
const point = buildDrawingPoint(event, canvas);
if (interaction.erasing) {
event.preventDefault();
event.stopPropagation();
interaction.pointerId = event.pointerId;
canvas.setPointerCapture?.(event.pointerId);
eraseStrokeAt(point);
return;
}
if (drawingTool === DRAWING_TOOL.FILL) {
event.preventDefault();
event.stopPropagation();
commitSingleStroke(buildFillStrokeAtPoint(point, drawingStrokesRef.current, canvas));
return;
}
if (drawingTool === DRAWING_TOOL.SURFACE_LINE) {
const anchor = buildSurfaceLineAnchor?.(event, canvas);
if (!anchor) {
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
event.preventDefault();
event.stopPropagation();
interaction.pointerId = event.pointerId;
canvas.setPointerCapture?.(event.pointerId);
drawingDraftRef.current = {
id: "__draft__",
tool: drawingTool,
points: [anchor.screenPoint, anchor.screenPoint],
surfaceLine: anchor.surfaceLine
};
redrawDrawingCanvas(canvas, drawingStrokesRef.current, drawingDraftRef.current);
return;
}
event.preventDefault();
event.stopPropagation();
interaction.pointerId = event.pointerId;
canvas.setPointerCapture?.(event.pointerId);
beginDraft(point);
};
const handlePointerMove = (event) => {
if (interaction.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
const point = buildDrawingPoint(event, canvas);
if (interaction.erasing) {
eraseStrokeAt(point);
return;
}
if (drawingTool === DRAWING_TOOL.SURFACE_LINE) {
const draft = drawingDraftRef.current;
if (!draft?.surfaceLine?.referenceId) {
return;
}
const nextAnchor = updateSurfaceLineAnchor?.(event, canvas, draft.surfaceLine);
if (!nextAnchor) {
return;
}
draft.points = [draft.points[0], nextAnchor.screenPoint];
draft.surfaceLine = nextAnchor.surfaceLine;
redrawDrawingCanvas(canvas, drawingStrokesRef.current, draft);
return;
}
updateDraft(point);
};
const handlePointerEnd = (event) => {
if (interaction.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
canvas.releasePointerCapture?.(event.pointerId);
interaction.pointerId = null;
if (interaction.erasing) {
interaction.erasing = false;
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
return;
}
endDraft();
};
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerEnd);
canvas.addEventListener("pointercancel", handlePointerEnd);
return () => {
interaction.pointerId = null;
interaction.erasing = false;
drawingDraftRef.current = null;
redrawDrawingCanvas(canvas, drawingStrokesRef.current, null);
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerEnd);
canvas.removeEventListener("pointercancel", handlePointerEnd);
};
}, [
buildDrawingPoint,
buildFillStrokeAtPoint,
buildSurfaceLineAnchor,
distanceToStrokeInPixels,
drawingCanvasRef,
drawingChangeRef,
drawingDraftRef,
drawingEnabled,
drawingEraseThresholdPx,
drawingIdRef,
drawingMinPointDistancePx,
drawingMinStrokeLengthPx,
drawingStrokesRef,
drawingTool,
drawingToolNeedsTwoPoints,
meshData,
previewMode,
redrawDrawingCanvas,
renderDrawingOverlay,
strokeLengthInPixels,
updateSurfaceLineAnchor,
viewerReadyTick
]);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { screenLimitedPickThreshold, worldUnitsPerPixelAtDistance } from "@/viewer-runtime/lib/viewer/pickingThresholds.js";
import {
createViewerContextMenuGestureState,
VIEWER_CONTEXT_MENU_SUPPRESSION_MS
} from "./viewerContextMenuGesture.js";
import {
buildEdgePickPayload,
buildSurfacePointPickPayload
} from "./editPointPickPayload.js";
test("worldUnitsPerPixelAtDistance converts perspective depth to screen scale", () => {
const camera = {
isPerspectiveCamera: true,
fov: 60
};
const unitsPerPixel = worldUnitsPerPixelAtDistance(camera, 600, 300);
assert.ok(Number.isFinite(unitsPerPixel));
assert.ok(Math.abs(unitsPerPixel - 0.5773502691896257) < 1e-9);
});
test("screenLimitedPickThreshold preserves the base threshold until zoom would make it too wide on screen", () => {
const camera = {
isPerspectiveCamera: true,
fov: 60
};
const farThreshold = screenLimitedPickThreshold({
baseThreshold: 1.5,
thresholdScale: 1,
maxScreenDistancePx: 10,
camera,
viewportHeightPx: 600,
distance: 300
});
const nearThreshold = screenLimitedPickThreshold({
baseThreshold: 1.5,
thresholdScale: 1,
maxScreenDistancePx: 10,
camera,
viewportHeightPx: 600,
distance: 30
});
assert.equal(farThreshold, 1.5);
assert.ok(nearThreshold < farThreshold);
assert.ok(Math.abs(nearThreshold - 0.5773502691896257) < 1e-9);
});
test("screenLimitedPickThreshold falls back to the scaled base threshold when screen scaling is unavailable", () => {
const threshold = screenLimitedPickThreshold({
baseThreshold: 0.9,
thresholdScale: 0.5,
maxScreenDistancePx: 5,
camera: null,
viewportHeightPx: 600,
distance: 30
});
assert.equal(threshold, 0.45);
});
test("surface point pick payload captures face selector and model coordinates", () => {
const payload = buildSurfacePointPickPayload({
clientX: 120,
clientY: 240,
reference: {
id: "topology|part-a|face|o1.f3",
selectorType: "face",
displaySelector: "o1.f3",
normalizedSelector: "o1.f3",
partId: "part-a",
entityType: "ADVANCED_FACE",
rowIndex: 3,
pickData: {
normal: [0, 0, 1],
surface: {
type: "PLANE",
origin: [0, 0, 25],
xDir: [1, 0, 0],
yDir: [0, 1, 0],
normal: [0, 0, 1]
}
}
},
intersection: {
faceIndex: 7,
point: { x: 12.4, y: 8.2, z: 25 }
},
localPoint: [12.4, 8.2, 25]
});
assert.equal(payload.referenceId, "topology|part-a|face|o1.f3");
assert.equal(payload.selector, "o1.f3");
assert.equal(payload.faceIndex, 7);
assert.deepEqual(payload.pointModel, [12.4, 8.2, 25]);
assert.deepEqual(payload.normal, [0, 0, 1]);
assert.equal(payload.surface.type, "PLANE");
});
test("edge pick payload captures edge selector", () => {
const payload = buildEdgePickPayload({
clientX: 44,
clientY: 55,
reference: {
id: "topology|part-a|edge|e12",
selectorType: "edge",
displaySelector: "e12",
normalizedSelector: "e12",
partId: "part-a",
entityType: "EDGE_CURVE",
rowIndex: 12,
pickData: {
adjacentFaceSelectors: ["f1", "f2"]
}
}
});
assert.equal(payload.kind, "edge");
assert.equal(payload.referenceId, "topology|part-a|edge|e12");
assert.equal(payload.selector, "e12");
assert.equal(payload.selectorType, "edge");
assert.equal(payload.rowIndex, 12);
assert.deepEqual(payload.adjacentFaces, ["f1", "f2"]);
});
test("viewer context menu gesture suppression blocks one menu event", () => {
let time = 1000;
const gesture = createViewerContextMenuGestureState({
now: () => time
});
gesture.suppressNextContextMenu();
assert.equal(gesture.isSuppressed(), true);
assert.equal(gesture.consumeSuppression(), true);
assert.equal(gesture.isSuppressed(), false);
assert.equal(gesture.consumeSuppression(), false);
});
test("viewer context menu gesture suppression expires", () => {
let time = 2000;
const gesture = createViewerContextMenuGestureState({
now: () => time
});
gesture.suppressNextContextMenu();
assert.equal(gesture.isSuppressed(), true);
time += VIEWER_CONTEXT_MENU_SUPPRESSION_MS + 1;
assert.equal(gesture.isSuppressed(), false);
assert.equal(gesture.consumeSuppression(), false);
});
@@ -0,0 +1,796 @@
import { useEffect } from "react";
import { isEditableTarget } from "../../../ui/dom";
import {
isWebGlContextCreationError,
runtimeErrorMessage
} from "@/viewer-runtime/lib/viewer/webglSupport";
import {
createCadWebGlRenderer
} from "@/viewer-runtime/common/webglRenderer";
import {
resolveInteractionPixelRatioCap
} from "@/viewer-runtime/lib/viewer/renderQuality";
import {
updateOrbitControls,
wheelZoomDistanceMultiplier
} from "../orbitControls.js";
function createWebGlRenderer(THREE, viewerTheme) {
return createCadWebGlRenderer(THREE, {
allowFallback: true,
isRecoverableError: isWebGlContextCreationError,
powerPreference: viewerTheme?.rendererPowerPreference || "high-performance",
logarithmicDepthBuffer: viewerTheme?.logarithmicDepthBuffer !== false
});
}
export function useViewerRuntime({
mountRef,
runtimeRef,
previewModeRef,
setError,
setViewerReadyTick,
viewerTheme,
syncDrawingCanvasSize,
renderDrawingOverlay,
emitPerspectiveChange,
setActiveViewPlaneFace,
activeViewPlaneFaceRef,
stepCameraTransition,
stepKeyboardOrbit,
getActiveViewPlaneFaceId,
cancelCameraTransition,
clearKeyboardOrbitState,
getKeyboardOrbitCommand,
getKeyboardOrbitAxes,
applyOrbitDelta,
getViewerThemeValue,
getPixelRatioCap,
applySceneBackground,
applyCameraFrameInsets,
frameInsetsRef,
applyInitialPerspective,
updateGridHelper,
clearSceneGroup,
disposeSceneObject,
disposeTexture,
syncViewPlaneOrientation,
BASE_VIEWER_THEME,
DEFAULT_LIGHTING,
DEFAULT_DAMPING_FACTOR,
DEFAULT_ZOOM_SPEED,
COARSE_POINTER_ZOOM_SPEED,
INTERACTION_PIXEL_RATIO_CAP,
IDLE_PIXEL_RATIO_CAP,
INTERACTION_IDLE_DELAY_MS,
KEYBOARD_ORBIT_NUDGE_RAD,
defaultGridRadius,
sceneScaleMode,
floorMode,
onManualCameraInteraction,
onViewportResize,
onContextLost,
onContextRestored,
onInitializationError,
preserveInteractionPixelRatio = false,
runtimeResetToken = 0
}) {
useEffect(() => {
if (runtimeRef.current) {
runtimeRef.current.preserveInteractionPixelRatio = preserveInteractionPixelRatio === true;
}
}, [preserveInteractionPixelRatio, runtimeRef, runtimeResetToken]);
// Runtime setup/teardown should run once per WebGL runtime epoch.
useEffect(() => {
let cancelled = false;
let cleanup = () => {};
async function initializeViewer() {
const [
THREE,
{ OrbitControls },
{ Line2 },
{ LineGeometry },
{ LineSegments2 },
{ LineSegmentsGeometry },
{ LineMaterial },
meshBvh
] = await Promise.all([
import("three"),
import("three/examples/jsm/controls/OrbitControls.js"),
import("three/examples/jsm/lines/Line2.js"),
import("three/examples/jsm/lines/LineGeometry.js"),
import("three/examples/jsm/lines/LineSegments2.js"),
import("three/examples/jsm/lines/LineSegmentsGeometry.js"),
import("three/examples/jsm/lines/LineMaterial.js"),
import("three-mesh-bvh")
]);
if (cancelled || !mountRef.current) {
return;
}
const container = mountRef.current;
if (meshBvh?.acceleratedRaycast && THREE.Mesh.prototype.raycast !== meshBvh.acceleratedRaycast) {
THREE.Mesh.prototype.raycast = meshBvh.acceleratedRaycast;
}
if (meshBvh?.computeBoundsTree && !THREE.BufferGeometry.prototype.computeBoundsTree) {
THREE.BufferGeometry.prototype.computeBoundsTree = meshBvh.computeBoundsTree;
}
if (meshBvh?.disposeBoundsTree && !THREE.BufferGeometry.prototype.disposeBoundsTree) {
THREE.BufferGeometry.prototype.disposeBoundsTree = meshBvh.disposeBoundsTree;
}
const coarsePointerQuery = typeof window.matchMedia === "function"
? window.matchMedia("(pointer: coarse)")
: null;
const prefersCoarsePointer = coarsePointerQuery?.matches ?? false;
const getDefaultZoomSpeed = () => (prefersCoarsePointer ? COARSE_POINTER_ZOOM_SPEED : DEFAULT_ZOOM_SPEED);
const width = container.clientWidth || 800;
const height = container.clientHeight || 640;
const scene = new THREE.Scene();
const syncCameraViewport = (targetCamera, nextWidth = width, nextHeight = height) => {
if (!targetCamera) {
return;
}
const aspect = Math.max(nextWidth, 1) / Math.max(nextHeight, 1);
if (targetCamera.isPerspectiveCamera) {
targetCamera.aspect = aspect;
} else if (targetCamera.isOrthographicCamera) {
const halfHeight = Math.max(Number(targetCamera.userData?.cadHalfHeight) || 120, 1e-3);
targetCamera.left = -halfHeight * aspect;
targetCamera.right = halfHeight * aspect;
targetCamera.top = halfHeight;
targetCamera.bottom = -halfHeight;
}
targetCamera.updateProjectionMatrix?.();
};
const perspectiveCamera = new THREE.PerspectiveCamera(48, width / height, 0.1, 50000);
const orthographicCamera = new THREE.OrthographicCamera(-120, 120, 120, -120, 0.1, 50000);
orthographicCamera.userData.cadHalfHeight = 120;
const camera = perspectiveCamera;
camera.up.set(0, 0, 1);
camera.position.set(180, -180, 120);
orthographicCamera.up.copy(camera.up);
orthographicCamera.position.copy(camera.position);
syncCameraViewport(perspectiveCamera, width, height);
syncCameraViewport(orthographicCamera, width, height);
const renderer = createWebGlRenderer(THREE, viewerTheme);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = viewerTheme?.toneMapping === "none"
? THREE.NoToneMapping
: THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = getViewerThemeValue(viewerTheme, "toneMappingExposure", DEFAULT_LIGHTING.toneMappingExposure);
renderer.localClippingEnabled = true;
renderer.shadowMap.enabled = viewerTheme?.shadowsEnabled !== false;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(getPixelRatioCap(IDLE_PIXEL_RATIO_CAP));
// Keep the canvas coupled to its flex container. ResizeObserver updates the
// drawing buffer while CSS keeps the visible canvas at the current layout size.
renderer.setSize(width, height, false);
renderer.domElement.style.display = "block";
renderer.domElement.style.width = "100%";
renderer.domElement.style.height = "100%";
container.innerHTML = "";
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
controls.rotateSpeed = 1;
controls.panSpeed = 1.35;
controls.zoomSpeed = getDefaultZoomSpeed();
if ("zoomToCursor" in controls) {
controls.zoomToCursor = true;
}
const hemisphereLight = new THREE.HemisphereLight(
getViewerThemeValue(viewerTheme, "hemisphereSky", DEFAULT_LIGHTING.hemisphereSky),
getViewerThemeValue(viewerTheme, "hemisphereGround", DEFAULT_LIGHTING.hemisphereGround),
getViewerThemeValue(viewerTheme, "hemisphereIntensity", DEFAULT_LIGHTING.hemisphereIntensity)
);
const hemispherePosition = viewerTheme?.hemispherePosition;
if (hemispherePosition) {
hemisphereLight.position.set(
Number(hemispherePosition.x) || 0,
Number(hemispherePosition.y) || 0,
Number(hemispherePosition.z) || 0
);
}
scene.add(hemisphereLight);
const ambientLight = new THREE.AmbientLight("#ffffff", 0);
scene.add(ambientLight);
const keyLight = new THREE.DirectionalLight(
getViewerThemeValue(viewerTheme, "keyLightColor", DEFAULT_LIGHTING.keyLightColor),
getViewerThemeValue(viewerTheme, "keyLightIntensity", DEFAULT_LIGHTING.keyLightIntensity)
);
keyLight.position.set(240, -150, 340);
keyLight.castShadow = viewerTheme?.shadowsEnabled !== false;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.00025;
keyLight.shadow.normalBias = 0.024;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(
getViewerThemeValue(viewerTheme, "fillLightColor", DEFAULT_LIGHTING.fillLightColor),
getViewerThemeValue(viewerTheme, "fillLightIntensity", DEFAULT_LIGHTING.fillLightIntensity)
);
fillLight.position.set(120, 80, 210);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(
getViewerThemeValue(viewerTheme, "rimLightColor", DEFAULT_LIGHTING.rimLightColor),
getViewerThemeValue(viewerTheme, "rimLightIntensity", DEFAULT_LIGHTING.rimLightIntensity)
);
const rimLightPosition = viewerTheme?.rimLightPosition || { x: -260, y: 240, z: 180 };
rimLight.position.set(
Number(rimLightPosition.x) || 0,
Number(rimLightPosition.y) || 0,
Number(rimLightPosition.z) || 0
);
scene.add(rimLight);
const spotLight = new THREE.SpotLight("#ffffff", 0, 0, Math.PI / 6);
spotLight.position.set(160, -120, 140);
spotLight.visible = false;
spotLight.castShadow = false;
spotLight.shadow.mapSize.set(1024, 1024);
spotLight.shadow.bias = -0.00025;
spotLight.shadow.normalBias = 0.01;
scene.add(spotLight);
scene.add(spotLight.target);
const pointLight = new THREE.PointLight("#ffffff", 0, 0);
pointLight.position.set(-120, 80, 140);
pointLight.visible = false;
pointLight.castShadow = false;
scene.add(pointLight);
const axesHelper = null;
const stageGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const edgesGroup = new THREE.Group();
const facePickGroup = new THREE.Group();
const edgePickGroup = new THREE.Group();
const vertexPickGroup = new THREE.Group();
scene.add(stageGroup);
scene.add(modelGroup);
scene.add(edgesGroup);
scene.add(facePickGroup);
scene.add(edgePickGroup);
scene.add(vertexPickGroup);
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const interactionState = {
active: false,
pixelRatioCap: IDLE_PIXEL_RATIO_CAP,
pixelRatio: getPixelRatioCap(IDLE_PIXEL_RATIO_CAP),
renderQueued: false,
renderQueuedAt: 0,
renderFallbackTimerId: 0,
restoreTimerId: 0
};
const keyboardOrbitState = {
pressedKeys: new Set(),
directionCounts: {
left: 0,
right: 0,
up: 0,
down: 0
},
lastFrameTime: 0
};
const screenSpaceLineMaterials = new Set();
const getScreenSpaceLineMaterialCount = () => (
screenSpaceLineMaterials.size +
Number(runtimeRef.current?.cadScene?.runtime?.screenSpaceLineMaterials?.size || 0)
);
const syncScreenSpaceLineMaterials = () => {
const nextWidth = container.clientWidth || width || 1;
const nextHeight = container.clientHeight || height || 1;
for (const material of screenSpaceLineMaterials) {
material?.resolution?.set?.(nextWidth, nextHeight);
}
runtimeRef.current?.cadScene?.runtime?.syncScreenSpaceLineMaterials?.(nextWidth, nextHeight);
};
const registerScreenSpaceLineMaterial = (material) => {
if (!material?.resolution?.set) {
return;
}
screenSpaceLineMaterials.add(material);
material.resolution.set(container.clientWidth || width || 1, container.clientHeight || height || 1);
};
const unregisterScreenSpaceLineMaterial = (material) => {
if (!material) {
return;
}
screenSpaceLineMaterials.delete(material);
};
const handleContextLost = (event) => {
event.preventDefault();
clearKeyboardOrbitState(keyboardOrbitState);
setError("WebGL context was lost. Restoring CAD Viewer...");
onContextLost?.();
};
const handleContextRestored = () => {
setError("");
onContextRestored?.();
};
const applyRenderQuality = (pixelRatioCap) => {
const nextPixelRatio = getPixelRatioCap(pixelRatioCap);
if (
Math.abs(interactionState.pixelRatioCap - pixelRatioCap) < 1e-4 &&
Math.abs((interactionState.pixelRatio || 0) - nextPixelRatio) < 1e-4
) {
return;
}
interactionState.pixelRatioCap = pixelRatioCap;
interactionState.pixelRatio = nextPixelRatio;
renderer.setPixelRatio(nextPixelRatio);
renderer.setSize(container.clientWidth || width, container.clientHeight || height, false);
syncScreenSpaceLineMaterials();
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
};
let rafId = 0;
const requestRender = () => {
if (interactionState.renderQueued) {
const now = typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
if (interactionState.renderQueuedAt && now - interactionState.renderQueuedAt < 120) {
return;
}
window.cancelAnimationFrame(rafId);
interactionState.renderQueued = false;
interactionState.renderQueuedAt = 0;
}
interactionState.renderQueued = true;
interactionState.renderQueuedAt = typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
rafId = window.requestAnimationFrame(renderFrame);
if (interactionState.renderFallbackTimerId) {
window.clearTimeout(interactionState.renderFallbackTimerId);
}
interactionState.renderFallbackTimerId = window.setTimeout(() => {
if (!interactionState.renderQueued) {
return;
}
window.cancelAnimationFrame(rafId);
renderFrame(
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now()
);
}, 120);
if (runtimeRef.current) {
runtimeRef.current.rafId = rafId;
}
};
function renderFrame(timestamp) {
interactionState.renderQueued = false;
interactionState.renderQueuedAt = 0;
if (interactionState.renderFallbackTimerId) {
window.clearTimeout(interactionState.renderFallbackTimerId);
interactionState.renderFallbackTimerId = 0;
}
const cameraTransitionActive = stepCameraTransition(runtimeRef.current, timestamp);
const viewPresetAnimationActive = !!runtimeRef.current?.viewPresetAnimation;
const keyboardOrbitMoved = stepKeyboardOrbit(runtimeRef.current, timestamp);
const needsMoreFrames = updateOrbitControls(controls, timestamp, runtimeRef.current);
if (cameraTransitionActive || viewPresetAnimationActive || keyboardOrbitMoved) {
emitPerspectiveChange(runtimeRef.current);
}
renderer.render(scene, runtimeRef.current?.camera || camera);
const previewOrbitActive = !!runtimeRef.current?.previewOrbitEnabled;
if (!previewOrbitActive) {
const nextActiveFace = getActiveViewPlaneFaceId(runtimeRef.current);
if (nextActiveFace !== activeViewPlaneFaceRef.current) {
activeViewPlaneFaceRef.current = nextActiveFace;
setActiveViewPlaneFace(nextActiveFace);
}
syncViewPlaneOrientation(runtimeRef.current);
}
if (
cameraTransitionActive ||
viewPresetAnimationActive ||
keyboardOrbitMoved ||
needsMoreFrames ||
interactionState.active ||
previewOrbitActive
) {
requestRender();
}
}
const beginInteraction = () => {
if (interactionState.restoreTimerId) {
window.clearTimeout(interactionState.restoreTimerId);
interactionState.restoreTimerId = 0;
}
interactionState.active = true;
applyRenderQuality(resolveInteractionPixelRatioCap({
idlePixelRatioCap: IDLE_PIXEL_RATIO_CAP,
interactionPixelRatioCap: INTERACTION_PIXEL_RATIO_CAP,
preservePixelRatio: runtimeRef.current?.preserveInteractionPixelRatio === true,
screenSpaceLineMaterialCount: getScreenSpaceLineMaterialCount()
}));
requestRender();
};
const scheduleIdleQuality = () => {
if (interactionState.restoreTimerId) {
window.clearTimeout(interactionState.restoreTimerId);
}
interactionState.restoreTimerId = window.setTimeout(() => {
interactionState.restoreTimerId = 0;
interactionState.active = false;
controls.enableDamping = true;
controls.dampingFactor = DEFAULT_DAMPING_FACTOR;
controls.zoomSpeed = getDefaultZoomSpeed();
applyRenderQuality(IDLE_PIXEL_RATIO_CAP);
requestRender();
}, INTERACTION_IDLE_DELAY_MS);
};
let resizeRafId = 0;
let lastResizeWidth = width;
let lastResizeHeight = height;
const resizeAndRenderFrame = () => {
resizeRafId = 0;
const w = container.clientWidth || 800;
const h = container.clientHeight || 640;
if (w < 2 || h < 2) {
return;
}
if (Math.abs(w - lastResizeWidth) < 1 && Math.abs(h - lastResizeHeight) < 1) {
requestRender();
return;
}
lastResizeWidth = w;
lastResizeHeight = h;
applyRenderQuality(interactionState.pixelRatioCap);
renderer.setSize(w, h, false);
syncCameraViewport(perspectiveCamera, w, h);
syncCameraViewport(orthographicCamera, w, h);
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current, { updateProjection: false });
syncScreenSpaceLineMaterials();
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
runtimeRef.current?.onViewportResize?.();
renderer.render(scene, runtimeRef.current?.camera || camera);
requestRender();
};
const onResize = () => {
if (resizeRafId) {
return;
}
resizeRafId = window.requestAnimationFrame(resizeAndRenderFrame);
};
window.addEventListener("resize", onResize);
const resizeObserver = typeof ResizeObserver === "function"
? new ResizeObserver(() => {
onResize();
})
: null;
resizeObserver?.observe(container);
let controlsStartDistance = null;
const readControlsDistance = () => {
const activeRuntime = runtimeRef.current;
if (!activeRuntime?.camera || !activeRuntime?.controls?.target) {
return null;
}
return activeRuntime.camera.position.distanceTo(activeRuntime.controls.target);
};
const handleControlsStart = () => {
controlsStartDistance = readControlsDistance();
cancelCameraTransition(runtimeRef.current);
beginInteraction();
};
const handleControlsChange = () => {
emitPerspectiveChange(runtimeRef.current);
requestRender();
};
const handleControlsEnd = () => {
const controlsEndDistance = readControlsDistance();
if (Number.isFinite(controlsStartDistance) && Number.isFinite(controlsEndDistance)) {
const threshold = Math.max(Math.abs(controlsStartDistance) * 0.002, 1e-4);
if (Math.abs(controlsEndDistance - controlsStartDistance) > threshold) {
runtimeRef.current?.onManualCameraInteraction?.("zoom");
}
}
controlsStartDistance = null;
scheduleIdleQuality();
};
const applyWheelZoom = (event) => {
const activeRuntime = runtimeRef.current;
const activeCamera = activeRuntime?.camera;
const activeControls = activeRuntime?.controls;
if (!activeRuntime?.THREE || !activeCamera || !activeControls?.target) {
return false;
}
const distanceMultiplier = wheelZoomDistanceMultiplier(event);
if (!Number.isFinite(distanceMultiplier) || distanceMultiplier <= 0 || distanceMultiplier === 1) {
return false;
}
if (activeCamera.isOrthographicCamera) {
const minZoom = Number.isFinite(Number(activeControls.minZoom)) && Number(activeControls.minZoom) > 0
? Number(activeControls.minZoom)
: 0;
const maxZoom = Number.isFinite(Number(activeControls.maxZoom)) && Number(activeControls.maxZoom) > 0
? Number(activeControls.maxZoom)
: Number.POSITIVE_INFINITY;
activeCamera.zoom = Math.max(minZoom, Math.min(maxZoom, activeCamera.zoom / distanceMultiplier));
activeCamera.updateProjectionMatrix?.();
} else {
const offset = activeCamera.position.clone().sub(activeControls.target);
const currentDistance = offset.length();
if (!Number.isFinite(currentDistance) || currentDistance <= 1e-6) {
return false;
}
const minDistance = Number.isFinite(Number(activeControls.minDistance))
? Number(activeControls.minDistance)
: 0.01;
const maxDistance = Number.isFinite(Number(activeControls.maxDistance)) && Number(activeControls.maxDistance) > 0
? Number(activeControls.maxDistance)
: Number.POSITIVE_INFINITY;
const nextDistance = Math.max(minDistance, Math.min(maxDistance, currentDistance * distanceMultiplier));
activeCamera.position.copy(
activeControls.target.clone().add(offset.normalize().multiplyScalar(nextDistance))
);
activeCamera.zoom = 1;
activeCamera.updateProjectionMatrix?.();
}
activeCamera.lookAt(activeControls.target);
activeControls.update?.();
applyCameraFrameInsets?.(activeRuntime, frameInsetsRef?.current, { updateProjection: false });
emitPerspectiveChange(activeRuntime);
requestRender();
return true;
};
const handleWheel = (event) => {
event.preventDefault();
event.stopImmediatePropagation?.();
runtimeRef.current?.onManualCameraInteraction?.("wheel");
cancelCameraTransition(runtimeRef.current);
controls.enableDamping = false;
beginInteraction();
if (applyWheelZoom(event)) {
scheduleIdleQuality();
}
};
const wheelListenerOptions = { passive: false, capture: true };
controls.addEventListener("start", handleControlsStart);
controls.addEventListener("change", handleControlsChange);
controls.addEventListener("end", handleControlsEnd);
renderer.domElement.addEventListener("wheel", handleWheel, wheelListenerOptions);
renderer.domElement.addEventListener("webglcontextlost", handleContextLost, false);
renderer.domElement.addEventListener("webglcontextrestored", handleContextRestored, false);
const handleKeyDown = (event) => {
if (
previewModeRef.current ||
event.defaultPrevented ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
isEditableTarget(event.target)
) {
return;
}
const command = getKeyboardOrbitCommand(event);
if (!command) {
return;
}
if (keyboardOrbitState.pressedKeys.has(command.keyId)) {
event.preventDefault();
return;
}
keyboardOrbitState.pressedKeys.add(command.keyId);
keyboardOrbitState.directionCounts[command.direction] += 1;
keyboardOrbitState.lastFrameTime = 0;
cancelCameraTransition(runtimeRef.current);
beginInteraction();
applyOrbitDelta(
runtimeRef.current,
(command.direction === "right" ? 1 : command.direction === "left" ? -1 : 0) * KEYBOARD_ORBIT_NUDGE_RAD,
(command.direction === "down" ? 1 : command.direction === "up" ? -1 : 0) * KEYBOARD_ORBIT_NUDGE_RAD
);
emitPerspectiveChange(runtimeRef.current);
requestRender();
event.preventDefault();
};
const handleKeyUp = (event) => {
const command = getKeyboardOrbitCommand(event);
if (!command) {
return;
}
if (!keyboardOrbitState.pressedKeys.delete(command.keyId)) {
return;
}
keyboardOrbitState.directionCounts[command.direction] = Math.max(
0,
keyboardOrbitState.directionCounts[command.direction] - 1
);
const axes = getKeyboardOrbitAxes(keyboardOrbitState);
if (!axes.azimuth && !axes.polar) {
keyboardOrbitState.lastFrameTime = 0;
scheduleIdleQuality();
}
event.preventDefault();
};
const clearKeyboardOrbit = () => {
if (!keyboardOrbitState.pressedKeys.size) {
return;
}
clearKeyboardOrbitState(keyboardOrbitState);
scheduleIdleQuality();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== "visible") {
clearKeyboardOrbit();
}
};
runtimeRef.current = {
THREE,
scene,
camera,
perspectiveCamera,
orthographicCamera,
projection: "perspective",
syncCameraViewport,
renderer,
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
meshBvh,
controls,
stageGroup,
modelGroup,
edgesGroup,
facePickGroup,
edgePickGroup,
vertexPickGroup,
facePickMesh: null,
edgePickLines: null,
vertexPickPoints: null,
edgePickObjects: [],
displayRecords: [],
modelBounds: null,
modelRadius: 1,
activeModelKey: "",
sceneScaleMode,
raycaster,
pointer,
hemisphereLight,
ambientLight,
keyLight,
fillLight,
rimLight,
spotLight,
pointLight,
axesHelper,
sceneBackgroundTexture: null,
environmentTexture: null,
environmentTextureUrl: "",
gridConfig: null,
gridHelper: null,
floorMode,
hasVisibleModel: false,
edgePickThreshold: 1.5,
vertexPickThreshold: 0.9,
cameraTransition: null,
previewOrbitEnabled: false,
orbitControlsLastTimestamp: 0,
preserveInteractionPixelRatio: preserveInteractionPixelRatio === true,
interactionState,
keyboardOrbitState,
onResize,
resizeObserver,
rafId,
requestRender,
beginInteraction,
scheduleIdleQuality,
applyCameraFrameInsets,
frameInsetsRef,
onManualCameraInteraction,
onViewportResize,
registerScreenSpaceLineMaterial,
unregisterScreenSpaceLineMaterial
};
syncDrawingCanvasSize(runtimeRef.current);
renderDrawingOverlay();
applySceneBackground(runtimeRef.current, viewerTheme);
applyCameraFrameInsets?.(runtimeRef.current, frameInsetsRef?.current);
applyInitialPerspective?.(runtimeRef.current);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", clearKeyboardOrbit);
document.addEventListener("visibilitychange", handleVisibilityChange);
requestRender();
updateGridHelper(runtimeRef.current, viewerTheme, defaultGridRadius, 0, sceneScaleMode, floorMode);
setViewerReadyTick((value) => value + 1);
cleanup = () => {
const runtime = runtimeRef.current;
if (!runtime) {
return;
}
if (runtime.interactionState.restoreTimerId) {
window.clearTimeout(runtime.interactionState.restoreTimerId);
}
if (runtime.interactionState.renderFallbackTimerId) {
window.clearTimeout(runtime.interactionState.renderFallbackTimerId);
}
cancelCameraTransition(runtime, { scheduleIdle: false });
if (resizeRafId) {
window.cancelAnimationFrame(resizeRafId);
}
window.cancelAnimationFrame(runtime.rafId);
window.removeEventListener("resize", runtime.onResize);
runtime.resizeObserver?.disconnect();
runtime.controls.removeEventListener("start", handleControlsStart);
runtime.controls.removeEventListener("change", handleControlsChange);
runtime.controls.removeEventListener("end", handleControlsEnd);
runtime.renderer.domElement.removeEventListener("wheel", handleWheel, wheelListenerOptions);
runtime.renderer.domElement.removeEventListener("webglcontextlost", handleContextLost, false);
runtime.renderer.domElement.removeEventListener("webglcontextrestored", handleContextRestored, false);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", clearKeyboardOrbit);
document.removeEventListener("visibilitychange", handleVisibilityChange);
runtime.controls.dispose();
clearSceneGroup(runtime.stageGroup);
clearSceneGroup(runtime.modelGroup);
clearSceneGroup(runtime.edgesGroup);
clearSceneGroup(runtime.facePickGroup);
clearSceneGroup(runtime.edgePickGroup);
clearSceneGroup(runtime.vertexPickGroup);
disposeSceneObject(runtime.gridHelper);
disposeSceneObject(runtime.axesHelper);
disposeTexture(runtime.sceneBackgroundTexture);
disposeTexture(runtime.environmentTexture);
runtime.renderer.dispose();
if (container.contains(runtime.renderer.domElement)) {
container.removeChild(runtime.renderer.domElement);
}
runtimeRef.current = null;
};
}
initializeViewer().catch((err) => {
if (!cancelled) {
setError(runtimeErrorMessage(err));
onInitializationError?.(err);
}
});
return () => {
cancelled = true;
cleanup();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runtimeResetToken]);
}
@@ -0,0 +1,28 @@
export const VIEWER_CONTEXT_MENU_SUPPRESSION_MS = 1200;
export function createViewerContextMenuGestureState({
now = () => Date.now(),
suppressionMs = VIEWER_CONTEXT_MENU_SUPPRESSION_MS
} = {}) {
let suppressUntil = 0;
return {
suppressNextContextMenu() {
suppressUntil = Math.max(suppressUntil, now() + suppressionMs);
},
consumeSuppression() {
if (now() > suppressUntil) {
suppressUntil = 0;
return false;
}
suppressUntil = 0;
return true;
},
clear() {
suppressUntil = 0;
},
isSuppressed() {
return now() <= suppressUntil;
}
};
}
@@ -0,0 +1,64 @@
const MAX_ORBIT_DELTA_SECONDS = 1;
const WHEEL_PIXEL_STEP = 100;
const WHEEL_LINE_STEP = 3;
const WHEEL_PAGE_STEP = 9;
const WHEEL_ZOOM_STEP_SCALE = 0.08;
const MAX_WHEEL_ZOOM_STEPS = 3;
export const PREVIEW_ORBIT_SECONDS_PER_TURN = 60;
export const PREVIEW_AUTO_ROTATE_SPEED = 60 / PREVIEW_ORBIT_SECONDS_PER_TURN;
function finiteTimestamp(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
export function orbitControlsDeltaSeconds(timestamp, previousTimestamp) {
const current = finiteTimestamp(timestamp);
const previous = finiteTimestamp(previousTimestamp);
if (current === null || previous === null || previous <= 0 || current <= previous) {
return null;
}
return Math.min((current - previous) / 1000, MAX_ORBIT_DELTA_SECONDS);
}
export function updateOrbitControls(controls, timestamp, state) {
if (!controls || typeof controls.update !== "function") {
return false;
}
if (!controls.autoRotate) {
if (state) {
state.orbitControlsLastTimestamp = 0;
}
return controls.update();
}
const current = finiteTimestamp(timestamp);
const deltaSeconds = orbitControlsDeltaSeconds(current, state?.orbitControlsLastTimestamp);
if (state) {
state.orbitControlsLastTimestamp = current ?? 0;
}
return deltaSeconds === null ? controls.update() : controls.update(deltaSeconds);
}
export function normalizedWheelZoomSteps(event) {
const deltaY = Number(event?.deltaY);
if (!Number.isFinite(deltaY) || deltaY === 0) {
return 0;
}
let divisor = WHEEL_PIXEL_STEP;
if (event?.deltaMode === 1) {
divisor = WHEEL_LINE_STEP;
} else if (event?.deltaMode === 2) {
divisor = WHEEL_PAGE_STEP;
}
const steps = deltaY / divisor;
return Math.max(-MAX_WHEEL_ZOOM_STEPS, Math.min(MAX_WHEEL_ZOOM_STEPS, steps));
}
export function wheelZoomDistanceMultiplier(event) {
const steps = normalizedWheelZoomSteps(event);
return steps === 0 ? 1 : Math.exp(steps * WHEEL_ZOOM_STEP_SCALE);
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizedWheelZoomSteps,
orbitControlsDeltaSeconds,
PREVIEW_AUTO_ROTATE_SPEED,
PREVIEW_ORBIT_SECONDS_PER_TURN,
updateOrbitControls,
wheelZoomDistanceMultiplier
} from "./orbitControls.js";
test("preview auto-rotate speed uses the configured full-turn duration", () => {
assert.equal(PREVIEW_ORBIT_SECONDS_PER_TURN, 60);
assert.equal(PREVIEW_AUTO_ROTATE_SPEED, 1);
});
test("orbitControlsDeltaSeconds converts animation timestamps from ms to seconds", () => {
assert.equal(orbitControlsDeltaSeconds(1016, 1000), 0.016);
});
test("orbitControlsDeltaSeconds preserves slow render frames", () => {
assert.equal(orbitControlsDeltaSeconds(1400, 1000), 0.4);
});
test("orbitControlsDeltaSeconds clamps stale frame gaps", () => {
assert.equal(orbitControlsDeltaSeconds(3000, 1000), 1);
});
test("updateOrbitControls passes seconds while auto-rotate is active", () => {
const updateArgs = [];
const controls = {
autoRotate: true,
update(...args) {
updateArgs.push(args);
return true;
}
};
const state = { orbitControlsLastTimestamp: 1000 };
assert.equal(updateOrbitControls(controls, 1016, state), true);
assert.deepEqual(updateArgs, [[0.016]]);
assert.equal(state.orbitControlsLastTimestamp, 1016);
});
test("updateOrbitControls resets timing when auto-rotate is inactive", () => {
const updateArgs = [];
const controls = {
autoRotate: false,
update(...args) {
updateArgs.push(args);
return false;
}
};
const state = { orbitControlsLastTimestamp: 1016 };
assert.equal(updateOrbitControls(controls, 1032, state), false);
assert.deepEqual(updateArgs, [[]]);
assert.equal(state.orbitControlsLastTimestamp, 0);
});
test("normalizedWheelZoomSteps normalizes Chrome pixel wheel deltas", () => {
assert.equal(normalizedWheelZoomSteps({ deltaY: 100, deltaMode: 0 }), 1);
assert.equal(normalizedWheelZoomSteps({ deltaY: -100, deltaMode: 0 }), -1);
});
test("normalizedWheelZoomSteps caps very large wheel deltas", () => {
assert.equal(normalizedWheelZoomSteps({ deltaY: 1000, deltaMode: 0 }), 3);
assert.equal(normalizedWheelZoomSteps({ deltaY: -1000, deltaMode: 0 }), -3);
});
test("wheelZoomDistanceMultiplier keeps one wheel event away from zoom limits", () => {
assert.ok(wheelZoomDistanceMultiplier({ deltaY: 100, deltaMode: 0 }) < 1.1);
assert.ok(wheelZoomDistanceMultiplier({ deltaY: -100, deltaMode: 0 }) > 0.9);
assert.ok(wheelZoomDistanceMultiplier({ deltaY: 1000, deltaMode: 0 }) < 1.3);
});
@@ -0,0 +1,20 @@
import {
normalizeThemeSettings
} from "@/viewer-runtime/lib/themeSettings.js";
import {
normalizeDisplaySettings
} from "@/viewer-runtime/lib/displaySettings.js";
export function normalizeViewerRenderState({
themeSettings = {},
displaySettings = null
} = {}) {
const normalizedThemeSettings = normalizeThemeSettings(themeSettings);
const normalizedDisplaySettings = normalizeDisplaySettings(displaySettings);
return {
themeSettings: normalizedThemeSettings,
displaySettings: normalizedDisplaySettings,
displayMode: normalizedDisplaySettings.mode,
clipSettings: normalizedDisplaySettings.clip
};
}
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
normalizeStepClipSettings
} from "@/viewer-runtime/lib/viewer/clipPlane.js";
import {
cloneThemePresetSettings,
normalizeThemeSettings
} from "@/viewer-runtime/lib/themeSettings.js";
import {
CAD_DISPLAY_MODE,
normalizeDisplaySettings
} from "@/viewer-runtime/lib/displaySettings.js";
import {
normalizeViewerRenderState
} from "./renderState.js";
test("viewer render-state normalization preserves current theme and display behavior", () => {
const themeSettings = cloneThemePresetSettings("dark");
const displaySettings = {
mode: CAD_DISPLAY_MODE.WIREFRAME,
clip: {
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}
};
const state = normalizeViewerRenderState({
themeSettings,
displaySettings
});
assert.deepEqual(state.themeSettings, normalizeThemeSettings(themeSettings));
assert.deepEqual(state.displaySettings, normalizeDisplaySettings(displaySettings));
assert.equal(state.displayMode, CAD_DISPLAY_MODE.WIREFRAME);
assert.deepEqual(state.clipSettings, normalizeStepClipSettings({
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}));
});
test("viewer render-state normalization keeps viewer-side defaults local", () => {
const state = normalizeViewerRenderState();
assert.deepEqual(state.themeSettings, normalizeThemeSettings({}));
assert.deepEqual(state.displaySettings, normalizeDisplaySettings(null));
assert.equal(state.displayMode, "solid");
assert.deepEqual(state.clipSettings, normalizeStepClipSettings(null));
});
+198
View File
@@ -0,0 +1,198 @@
function getDocumentSelection(doc) {
if (!doc || typeof doc.getSelection !== "function") {
return null;
}
try {
return doc.getSelection();
} catch {
return null;
}
}
function preserveSelection(selection) {
if (!selection || typeof selection.rangeCount !== "number" || typeof selection.getRangeAt !== "function") {
return [];
}
const ranges = [];
for (let index = 0; index < selection.rangeCount; index += 1) {
try {
ranges.push(selection.getRangeAt(index));
} catch {
return ranges;
}
}
return ranges;
}
function restoreSelection(selection, ranges) {
if (!selection || typeof selection.removeAllRanges !== "function" || typeof selection.addRange !== "function") {
return;
}
try {
selection.removeAllRanges();
for (const range of ranges) {
selection.addRange(range);
}
} catch {
// Selection restoration is a courtesy; copying already happened.
}
}
function focusElement(element) {
if (!element || typeof element.focus !== "function") {
return;
}
try {
element.focus({ preventScroll: true });
} catch {
try {
element.focus();
} catch {
// Ignore focus restoration failures.
}
}
}
function execCommandCopyText(text) {
const doc = globalThis.document;
if (!doc?.body || typeof doc.createElement !== "function" || typeof doc.execCommand !== "function") {
return false;
}
const activeElement = doc.activeElement;
const selection = getDocumentSelection(doc);
const preservedRanges = preserveSelection(selection);
const textarea = doc.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.opacity = "0";
doc.body.appendChild(textarea);
focusElement(textarea);
textarea.select();
let copied = false;
try {
copied = doc.execCommand("copy");
} finally {
doc.body.removeChild(textarea);
restoreSelection(selection, preservedRanges);
if (activeElement && activeElement !== textarea) {
focusElement(activeElement);
}
}
return copied;
}
function normalizeClipboardImageBlob(blob, fallbackType) {
if (!blob || typeof blob !== "object") {
throw new Error("Failed to encode screenshot");
}
const type = String(blob.type || fallbackType || "").trim();
if (!type) {
return blob;
}
if (blob.type === type) {
return blob;
}
return blob.slice(0, blob.size, type);
}
function isClipboardPermissionError(error) {
const name = String(error?.name || "").toLowerCase();
const message = String(error?.message || error || "").toLowerCase();
return (
name.includes("notallowed") ||
name.includes("security") ||
message.includes("permission") ||
message.includes("denied") ||
message.includes("not allowed")
);
}
function blockedClipboardError(error) {
if (isClipboardPermissionError(error)) {
return new Error("Clipboard image copy is blocked in this browser.");
}
return error instanceof Error ? error : new Error("Clipboard image copy failed");
}
function blockedClipboardTextReadError(error) {
if (isClipboardPermissionError(error)) {
return new Error("Clipboard paste is blocked in this browser");
}
return error instanceof Error ? error : new Error("Clipboard paste failed");
}
export async function copyImageBlobToClipboard(blobOrPromise, { type = "image/png" } = {}) {
const clipboard = globalThis.navigator?.clipboard;
const ClipboardItemCtor = globalThis.ClipboardItem;
const normalizedType = String(type || "image/png").trim() || "image/png";
const blobPromise = Promise.resolve(blobOrPromise).then((blob) => normalizeClipboardImageBlob(blob, normalizedType));
if (!clipboard?.write || typeof ClipboardItemCtor !== "function") {
throw new Error("Clipboard image copy is not supported in this browser");
}
let item;
try {
item = new ClipboardItemCtor({ [normalizedType]: blobPromise });
} catch {
const blob = await blobPromise;
const blobType = String(blob.type || normalizedType).trim() || normalizedType;
item = new ClipboardItemCtor({ [blobType]: blob });
}
try {
await clipboard.write([item]);
} catch (error) {
blobPromise.catch(() => {});
throw blockedClipboardError(error);
}
return await blobPromise;
}
export async function copyTextToClipboard(text) {
const clipboardText = String(text ?? "");
const clipboard = globalThis.navigator?.clipboard;
let clipboardError = null;
if (typeof clipboard?.writeText === "function") {
try {
await clipboard.writeText(clipboardText);
return;
} catch (error) {
clipboardError = error;
}
}
if (execCommandCopyText(clipboardText)) {
return;
}
if (clipboardError instanceof Error) {
throw clipboardError;
}
throw new Error("Clipboard is unavailable");
}
export async function readTextFromClipboard() {
const clipboard = globalThis.navigator?.clipboard;
if (typeof clipboard?.readText !== "function") {
throw new Error("Clipboard paste is unavailable");
}
try {
return await clipboard.readText();
} catch (error) {
throw blockedClipboardTextReadError(error);
}
}
+6
View File
@@ -0,0 +1,6 @@
export function isEditableTarget(target) {
if (!(target instanceof Element)) {
return false;
}
return !!target.closest("input, textarea, select, [contenteditable=''], [contenteditable='true'], [role='textbox']");
}
+86
View File
@@ -0,0 +1,86 @@
const DOWNLOAD_BLOCKED_MESSAGE = "Download was blocked by the browser";
function normalizeFilename(value) {
return String(value || "").trim();
}
function downloadRequestedMessage(filename) {
const normalizedFilename = normalizeFilename(filename);
return normalizedFilename ? `Downloading ${normalizedFilename}` : "Downloading file";
}
function getDownloadDocument() {
const doc = globalThis.document;
if (!doc || typeof doc.createElement !== "function") {
throw new Error("Downloads are not available in this browser");
}
return doc;
}
function clickDownloadLink(link) {
if (typeof link.click !== "function") {
throw new Error("Downloads are not available in this browser");
}
try {
link.click();
} catch (error) {
throw error instanceof Error ? error : new Error(DOWNLOAD_BLOCKED_MESSAGE);
}
}
export function triggerUrlDownload(url, { filename = "" } = {}) {
const href = String(url || "").trim();
if (!href) {
throw new Error("No download URL is available");
}
const doc = getDownloadDocument();
const link = doc.createElement("a");
link.href = href;
link.rel = "noopener";
const normalizedFilename = normalizeFilename(filename);
if (normalizedFilename) {
link.download = normalizedFilename;
}
if (link.style) {
link.style.display = "none";
}
doc.body?.appendChild?.(link);
try {
clickDownloadLink(link);
} finally {
doc.body?.removeChild?.(link);
}
return {
filename: normalizedFilename,
message: downloadRequestedMessage(normalizedFilename),
status: "requested"
};
}
export function triggerBlobDownload(blob, { filename = "" } = {}) {
if (!blob || typeof blob !== "object") {
throw new Error("No download data is available");
}
const urlApi = globalThis.URL;
if (typeof urlApi?.createObjectURL !== "function") {
throw new Error("Downloads are not available in this browser");
}
const downloadUrl = urlApi.createObjectURL(blob);
try {
return triggerUrlDownload(downloadUrl, { filename });
} finally {
const revoke = () => {
urlApi.revokeObjectURL?.(downloadUrl);
};
if (typeof globalThis.setTimeout === "function") {
globalThis.setTimeout(revoke, 1000);
} else {
revoke();
}
}
}
@@ -0,0 +1,39 @@
export const ASSET_STATUS = {
PENDING: "pending",
LOADING: "loading",
READY: "ready",
ERROR: "error"
};
export const REFERENCE_STATUS = {
IDLE: "idle",
DISABLED: "disabled",
LOADING: "loading",
READY: "ready",
ERROR: "error"
};
export { RENDER_FORMAT } from "@/viewer-runtime/lib/fileFormats.js";
export const TAB_TOOL_MODE = {
REFERENCES: "references",
DRAW: "draw"
};
export const DRAWING_TOOL = {
FREEHAND: "freehand",
LINE: "line",
SURFACE_LINE: "surface-line",
ARROW: "arrow",
DOUBLE_ARROW: "double-arrow",
RECTANGLE: "rectangle",
CIRCLE: "circle",
FILL: "fill",
ERASE: "erase"
};
export const AI_SELECTION_MODE = {
NONE: "",
POINT: "point",
LASSO: "lasso"
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,785 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import {
applyPartVisualState,
buildModel,
CAD_DISPLAY_MODE,
normalizeDisplayMode
} from "./cadScene.js";
import {
DEFAULT_DISPLAY_EDGE_SETTINGS
} from "./displaySettings.js";
import {
cloneThemeSettings
} from "./themeSettings.js";
const EPSILON = 1e-6;
function assertNear(actual, expected, message = "") {
assert.ok(Math.abs(actual - expected) < EPSILON, `${message} expected ${expected}, received ${actual}`);
}
function sampleMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [3, 1, 0]
},
parts: [
{
id: "left",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "right",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
}
]
};
}
function nestedAssemblyMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0,
10, 0, 0,
11, 0, 0,
10, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7, 8]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [11, 1, 0]
},
parts: [
{
id: "o1.2.1",
occurrenceId: "o1.2.1",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "o1.2.2",
occurrenceId: "o1.2.2",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
},
{
id: "o1.3",
occurrenceId: "o1.3",
vertexOffset: 6,
vertexCount: 3,
triangleOffset: 2,
triangleCount: 1,
bounds: { min: [10, 0, 0], max: [11, 1, 0] }
}
]
};
}
function createDisplayRecord(partId, {
baseOpacity = 1
} = {}) {
const material = new THREE.MeshStandardMaterial({
color: "#aaaaaa",
emissive: "#000000",
transparent: false,
opacity: 1
});
const edgeMaterial = new THREE.LineBasicMaterial({
color: "#222222",
transparent: true,
opacity: 1
});
return {
partId,
mesh: { visible: true, renderOrder: 2 },
edges: { visible: true, renderOrder: 3 },
material,
edgeMaterial,
baseOpacity,
baseColor: new THREE.Color("#aaaaaa"),
baseEmissiveColor: new THREE.Color("#000000"),
baseEmissiveIntensity: 0
};
}
function squareMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
1, 1, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 0, 2, 3]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: []
};
}
function twentyDegreeCreaseMeshData() {
const angle = THREE.MathUtils.degToRad(20);
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
0, -Math.cos(angle), Math.sin(angle)
]),
indices: new Uint32Array([0, 1, 2, 1, 0, 3]),
bounds: {
min: [0, -Math.cos(angle), 0],
max: [1, 1, Math.sin(angle)]
},
parts: []
};
}
function edgeSegmentCount(record) {
return Math.floor((record?.edges?.geometry?.getAttribute("position")?.count || 0) / 2);
}
test("applyPartVisualState keeps dimmed context from depth-occluding highlights", () => {
const dimmed = createDisplayRecord("dimmed");
const selected = createDisplayRecord("selected");
applyPartVisualState(THREE, [dimmed, selected], {
baseTheme: {
edge: "#111111",
edgeOpacity: 0.5
},
edgeSettings: {
opacity: 0.5
},
hiddenPartIds: [],
hoveredPartId: "",
focusedPartId: ["selected"],
selectedPartIds: ["selected"],
showEdges: true
});
assert.equal(dimmed.material.transparent, true);
assert.equal(dimmed.material.depthWrite, false);
assert.equal(dimmed.mesh.renderOrder, 2);
assert.equal(dimmed.edges.renderOrder, 3);
assert.equal(selected.material.transparent, true);
assert.equal(selected.material.depthWrite, true);
assert.equal(selected.mesh.renderOrder, 23);
assert.equal(selected.edges.renderOrder, 26);
applyPartVisualState(THREE, [selected], {
baseTheme: {},
edgeSettings: {},
hiddenPartIds: [],
hoveredPartId: "",
focusedPartId: [],
selectedPartIds: [],
showEdges: true
});
assert.equal(selected.material.transparent, false);
assert.equal(selected.material.depthWrite, true);
assert.equal(selected.mesh.renderOrder, 2);
assert.equal(selected.edges.renderOrder, 3);
});
test("buildModel renders solid part records and updates theme without rebuilding geometry", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme,
renderPartsIndividually: true
});
const firstMesh = scene.displayRecords[0].mesh;
const firstGeometry = firstMesh.geometry;
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].partId, "left");
assert.equal(scene.displayRecords[0].edges.visible, true);
assert.equal(scene.displayRecords[0].mesh.castShadow, true);
assert.equal(scene.displayRecords[0].mesh.receiveShadow, false);
assertNear(scene.displayRecords[0].material.reflectivity, 0.35, "default reflectivity");
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
defaultColor: "#ff0000",
fillColors: ["#ff0000"],
reflectivity: 0.62
}
}
});
assert.equal(scene.displayRecords[0].mesh, firstMesh);
assert.equal(scene.displayRecords[0].mesh.geometry, firstGeometry);
assert.equal(scene.displayRecords[0].material.color.getHexString(), "ff0000");
assertNear(scene.displayRecords[0].material.reflectivity, 0.62, "updated reflectivity");
scene.dispose();
});
test("buildModel supports Deepbuild front-face materials without mesh shadows", () => {
const scene = buildModel(THREE, squareMeshData(), {
theme: cloneThemeSettings("workbench"),
baseTheme: {
surfaceSide: "front",
surfaceCastShadow: false,
surfaceReceiveShadow: false
}
});
const record = scene.displayRecords[0];
assert.equal(record.material.side, THREE.FrontSide);
assert.equal(record.mesh.castShadow, false);
assert.equal(record.mesh.receiveShadow, false);
scene.dispose();
});
test("buildModel keeps source-mesh color buffers immutable across material refreshes", () => {
const sourceColors = new Float32Array([
0.2, 0.4, 0.6,
0.8, 0.45, 0.2,
0.55, 0.3, 0.75
]);
const originalColors = Array.from(sourceColors);
const sourceMesh = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
colors: sourceColors
};
const meshData = {
vertices: new Float32Array(0),
indices: new Uint32Array(0),
normals: new Float32Array(0),
bounds: { min: [0, 0, 0], max: [1, 1, 0] },
parts: [
{
id: "camera-source",
sourceMeshKey: "camera-source",
sourceMesh,
hasSourceColors: true,
vertexCount: 3,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
}
]
};
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, meshData, {
theme,
renderPartsIndividually: true
});
const record = scene.displayRecords[0];
const colorAttribute = record.geometry.getAttribute("color");
assert.notEqual(record.rawColors, sourceColors);
assert.notEqual(colorAttribute.array, sourceColors);
assert.deepEqual(Array.from(sourceColors), originalColors);
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
brightness: 0.72,
saturation: 1.8
}
}
});
assert.deepEqual(Array.from(sourceColors), originalColors);
assert.deepEqual(Array.from(record.rawColors), originalColors);
scene.dispose();
});
test("buildModel selection can focus and hide subassembly occurrence descendants", () => {
const focused = buildModel(THREE, nestedAssemblyMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
focus: ["#o1.2"]
}
});
assert.deepEqual(focused.displayRecords.map((record) => record.partId), ["o1.2.1", "o1.2.2"]);
assert.deepEqual(focused.bounds, {
min: [0, 0, 0],
max: [3, 1, 0]
});
focused.dispose();
const hidden = buildModel(THREE, nestedAssemblyMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
hide: ["o1.2"]
}
});
assert.deepEqual(hidden.displayRecords.map((record) => record.partId), ["o1.3"]);
assert.deepEqual(hidden.bounds, {
min: [10, 0, 0],
max: [11, 1, 0]
});
hidden.dispose();
});
test("buildModel renders STEP surface-owned edges from mesh attributes without line objects", () => {
const meshData = sampleMeshData();
meshData.surfaceEdgeBarycentric = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
meshData.surfaceEdgeClass = new Uint8Array([
1, 1, 0,
1, 1, 0,
1, 1, 0,
2, 3, 0,
2, 3, 0,
2, 3, 0
]);
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
renderPartsIndividually: true
});
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.edgesGroup.children.length, 0);
assert.equal(scene.displayRecords[0].edges, null);
assert.equal(scene.displayRecords[0].material.userData.cadSurfaceEdges, true);
assert.equal(scene.displayRecords[0].material.polygonOffset, false);
assert.equal(scene.displayRecords[0].geometry.getAttribute("_cad_edge_barycentric").count, 3);
assert.equal(scene.displayRecords[0].geometry.getAttribute("_cad_edge_class").count, 3);
scene.dispose();
});
test("buildModel rebuilds surface edge shader materials when edge theme settings change", () => {
const meshData = sampleMeshData();
meshData.surfaceEdgeBarycentric = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1,
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
meshData.surfaceEdgeClass = new Uint8Array([
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0,
1, 2, 0
]);
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, meshData, {
theme,
displayMode: CAD_DISPLAY_MODE.SOLID,
renderPartsIndividually: true
});
const originalMaterial = scene.displayRecords[0].material;
scene.update({
displayMode: CAD_DISPLAY_MODE.SOLID,
theme: {
...theme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
color: "#0055ff",
classes: {
...DEFAULT_DISPLAY_EDGE_SETTINGS.classes,
tangent: {
...DEFAULT_DISPLAY_EDGE_SETTINGS.classes.tangent,
thickness: 2.5
}
}
}
}
});
assert.notEqual(scene.displayRecords[0].material, originalMaterial);
assert.equal(scene.displayRecords[0].material.userData.cadSurfaceEdges, true);
assert.equal(scene.edgesGroup.children.length, 0);
scene.dispose();
});
test("buildModel reuses cached geometry for posed wrappers with the same geometry source", () => {
const geometrySource = sampleMeshData();
const posedMeshData = {
...geometrySource,
geometrySource,
parts: geometrySource.parts.map((part) => ({
...part,
transform: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]
}))
};
const movedMeshData = {
...geometrySource,
geometrySource,
parts: geometrySource.parts.map((part, index) => ({
...part,
bounds: {
min: [part.bounds.min[0] + index, part.bounds.min[1], part.bounds.min[2]],
max: [part.bounds.max[0] + index, part.bounds.max[1], part.bounds.max[2]]
},
transform: [
1, 0, 0, index,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]
}))
};
const scene = buildModel(THREE, posedMeshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
const firstGeometry = scene.displayRecords[0].mesh.geometry;
const movedScene = buildModel(THREE, movedMeshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
assert.equal(movedScene.displayRecords[0].mesh.geometry, firstGeometry);
scene.dispose();
movedScene.dispose();
});
test("buildModel wireframe mode keeps a translucent surface and wire edges", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.WIREFRAME,
renderPartsIndividually: true
});
assert.equal(normalizeDisplayMode("wireframe"), CAD_DISPLAY_MODE.WIREFRAME);
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].material.type, "MeshBasicMaterial");
assert.equal(scene.displayRecords[0].material.opacity, 0.035);
assert.equal(scene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
scene.dispose();
});
test("buildModel display modes control edges, transparency, and flat surfaces", () => {
const theme = cloneThemeSettings("workbench");
const renderedScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.RENDERED,
renderPartsIndividually: true
});
assert.equal(renderedScene.displayRecords[0].edges, null);
assert.equal(renderedScene.displayRecords[0].material.opacity, 1);
const transparentScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.TRANSPARENT,
renderPartsIndividually: true
});
assert.equal(transparentScene.displayRecords[0].material.opacity, 0.22);
assert.equal(transparentScene.displayRecords[0].material.transparent, true);
assert.equal(transparentScene.displayRecords[0].material.depthWrite, false);
assert.equal(transparentScene.displayRecords[0].edgeMaterial.depthTest, false);
const hiddenEdgeScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.HIDDEN_EDGES,
renderPartsIndividually: true
});
assert.equal(hiddenEdgeScene.displayRecords[0].material.opacity, 1);
assert.equal(hiddenEdgeScene.displayRecords[0].edgeMaterial.depthTest, false);
const unshadedScene = buildModel(THREE, sampleMeshData(), {
theme,
displayMode: CAD_DISPLAY_MODE.UNSHADED,
renderPartsIndividually: true
});
assert.equal(unshadedScene.displayRecords[0].material.type, "MeshBasicMaterial");
assert.equal(unshadedScene.displayRecords[0].edges, null);
renderedScene.dispose();
transparentScene.dispose();
hiddenEdgeScene.dispose();
unshadedScene.dispose();
});
test("buildModel applies source part opacity from GLB material metadata", () => {
const meshData = sampleMeshData();
meshData.parts = meshData.parts.map((part, index) => index === 0
? { ...part, color: "#ff0000", opacity: 0.2, hasSourceColors: true }
: part
);
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true
});
const left = scene.displayRecords.find((record) => record.partId === "left");
assert.equal(left.baseOpacity, 0.2);
assert.equal(left.material.opacity, 0.2);
assert.equal(left.material.transparent, true);
assert.equal(left.material.depthWrite, false);
scene.dispose();
});
test("buildModel uses part records when only source opacity differs", () => {
const meshData = sampleMeshData();
meshData.sourceColor = "#ff0000";
meshData.has_source_colors = true;
meshData.parts = meshData.parts.map((part) => ({
...part,
color: "#ff0000",
opacity: 0.2,
hasSourceColors: true
}));
const scene = buildModel(THREE, meshData, {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: false
});
assert.equal(scene.displayRecords.length, 2);
for (const record of scene.displayRecords) {
assert.equal(record.baseOpacity, 0.2);
assert.equal(record.material.opacity, 0.2);
assert.equal(record.material.transparent, true);
assert.equal(record.material.depthWrite, false);
}
scene.dispose();
});
test("buildModel ignores deprecated mesh edge detail and keeps wireframe all-edge mode", () => {
const baseTheme = cloneThemeSettings("workbench");
const deprecatedDetailScene = buildModel(THREE, squareMeshData(), {
theme: {
...baseTheme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
topologyFilter: "all"
}
},
displayMode: CAD_DISPLAY_MODE.SOLID
});
const wireScene = buildModel(THREE, squareMeshData(), {
theme: baseTheme,
displayMode: CAD_DISPLAY_MODE.WIREFRAME
});
assert.equal(edgeSegmentCount(deprecatedDetailScene.displayRecords[0]), 4);
assert.notEqual(deprecatedDetailScene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
assert.equal(edgeSegmentCount(wireScene.displayRecords[0]), 5);
assert.equal(wireScene.displayRecords[0].edges.geometry.type, "WireframeGeometry");
deprecatedDetailScene.dispose();
wireScene.dispose();
});
test("buildModel derived edges use Deepbuild's 28 degree crease threshold", () => {
const scene = buildModel(THREE, twentyDegreeCreaseMeshData(), {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
edgeRendering: { mode: "basic" }
});
assert.equal(edgeSegmentCount(scene.displayRecords[0]), 4);
scene.dispose();
});
test("buildModel creates screen-space edges from declarative edge rendering options", () => {
const scene = buildModel(THREE, squareMeshData(), {
theme: cloneThemeSettings("workbench"),
displayMode: CAD_DISPLAY_MODE.SOLID,
edgeRendering: {
mode: "screen-space",
LineSegments2,
LineSegmentsGeometry,
LineMaterial
}
});
assert.equal(scene.runtime.edgeRendering.mode, "screen-space");
assert.equal(scene.displayRecords[0].edges instanceof LineSegments2, true);
assert.equal(scene.runtime.screenSpaceLineMaterials.size, 1);
scene.dispose();
assert.equal(scene.runtime.screenSpaceLineMaterials.size, 0);
});
test("buildModel can render silhouette contours without derived mesh edges", () => {
const theme = cloneThemeSettings("workbench");
const scene = buildModel(THREE, sampleMeshData(), {
theme: {
...theme,
edges: {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false,
silhouette: true,
silhouetteScale: 0.004
}
},
displayMode: CAD_DISPLAY_MODE.RENDERED,
silhouette: true,
renderPartsIndividually: true
});
assert.equal(scene.displayRecords.length, 2);
assert.equal(scene.displayRecords[0].edges, null);
assert.equal(scene.displayRecords[0].silhouette?.isMesh, true);
scene.dispose();
});
test("buildModel applies selection, clipping, and STEP parameter effects", () => {
const scene = buildModel(THREE, sampleMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
selection: {
selectedPartIds: ["left"],
hiddenPartIds: ["right"]
},
clip: {
enabled: true,
axis: "x",
offset: 0.5
},
stepParameters: {
definition: {
module: {
render(ctx) {
if (ctx.params.hideLeft) {
ctx.effects.visible("left", false);
}
}
},
manifest: {},
cadPath: "part.step"
},
parameterValues: {
hideLeft: true
}
}
});
const left = scene.displayRecords.find((record) => record.partId === "left");
const right = scene.displayRecords.find((record) => record.partId === "right");
assert.equal(left.mesh.visible, false);
assert.equal(right.mesh.visible, true);
assert.equal(right.material.transparent, true);
assert.equal(right.material.depthWrite, false);
assert.equal(right.material.opacity, 0.035);
assert.equal(left.material.clippingPlanes.length, 1);
assert.equal(scene.bounds.min[0], 2);
assert.equal(scene.bounds.max[0], 3);
scene.dispose();
});
test("buildModel can apply STEP parameter effects while deferring setup lifecycle", () => {
let setupCalls = 0;
const scene = buildModel(THREE, sampleMeshData(), {
theme: cloneThemeSettings("workbench"),
renderPartsIndividually: true,
parameterSetup: false,
stepParameters: {
definition: {
module: {
setup() {
setupCalls += 1;
},
render(ctx) {
ctx.effects.transform("left", { translate: [5, 0, 0] });
}
},
manifest: {},
cadPath: "part.step"
}
}
});
const left = scene.displayRecords.find((record) => record.partId === "left");
assert.equal(setupCalls, 0);
assert.equal(left.mesh.matrix.elements[12], 5);
assert.equal(scene.bounds.min[0], 2);
assert.equal(scene.bounds.max[0], 6);
scene.dispose();
});
@@ -0,0 +1,337 @@
import * as THREE from "three";
export const WORLD_UP = Object.freeze([0, 0, 1]);
export const TOP_VIEW_UP = Object.freeze([0, 1, 0]);
export const RENDER_CAMERA_PRESETS = Object.freeze({
front: Object.freeze({ name: "front", direction: Object.freeze([0, -1, 0]), up: WORLD_UP }),
back: Object.freeze({ name: "back", direction: Object.freeze([0, 1, 0]), up: WORLD_UP }),
right: Object.freeze({ name: "right", direction: Object.freeze([1, 0, 0]), up: WORLD_UP }),
left: Object.freeze({ name: "left", direction: Object.freeze([-1, 0, 0]), up: WORLD_UP }),
top: Object.freeze({ name: "top", direction: Object.freeze([0, 0, 1]), up: TOP_VIEW_UP }),
bottom: Object.freeze({ name: "bottom", direction: Object.freeze([0, 0, -1]), up: TOP_VIEW_UP }),
iso: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
isometric: Object.freeze({ name: "iso", direction: Object.freeze([1, -1, 0.8]), up: WORLD_UP }),
side: Object.freeze({ name: "side", direction: Object.freeze([1, 0, 0]), up: WORLD_UP })
});
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
const DEFAULT_CAMERA_PRESET = "iso";
const CAMERA_SPEC_KEYS = Object.freeze(["preset", "position", "target", "up", "zoom", "direction", "name"]);
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function normalizeVector(value, {
fieldName = "camera vector",
required = false,
nonZero = false,
strict = true
} = {}) {
if (value === undefined || value === null) {
if (required && strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
if (!Array.isArray(value) || value.length < 3) {
if (strict) {
throw new Error(`${fieldName} must be a three-number array`);
}
return null;
}
const vector = [
Number(value[0]),
Number(value[1]),
Number(value[2])
];
if (!vector.every(Number.isFinite)) {
if (strict) {
throw new Error(`${fieldName} must contain only finite numbers`);
}
return null;
}
if (nonZero && vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2 <= 1e-12) {
if (strict) {
throw new Error(`${fieldName} must not be the zero vector`);
}
return null;
}
return vector;
}
export function cloneCameraVector(vector) {
return normalizeVector(vector, { strict: false });
}
export function normalizeCameraZoom(value, fallback = 1, {
fieldName = "camera.zoom",
strict = false
} = {}) {
if (value === undefined || value === null || value === "") {
return fallback;
}
const numericValue = Number(value);
if (Number.isFinite(numericValue) && numericValue > 0) {
return numericValue;
}
if (strict) {
throw new Error(`${fieldName} must be a positive finite number`);
}
return fallback;
}
function clonePreset(preset) {
return {
name: preset.name,
direction: [...preset.direction],
up: [...preset.up]
};
}
function cameraPresetByName(name, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const raw = String(name || DEFAULT_CAMERA_PRESET).trim().toLowerCase();
if (presets[raw]) {
return clonePreset(presets[raw]);
}
const parts = raw.split(":").map((entry) => Number(entry));
if (parts.length >= 2 && parts.every(Number.isFinite)) {
const azimuth = (parts[0] * Math.PI) / 180;
const elevation = (parts[1] * Math.PI) / 180;
const cosElevation = Math.cos(elevation);
return {
name: `${parts[0]}:${parts[1]}`,
direction: [
Math.sin(azimuth) * cosElevation,
-Math.cos(azimuth) * cosElevation,
Math.sin(elevation)
],
up: [...WORLD_UP]
};
}
if (strict) {
throw new Error(`Unknown camera preset: ${name}`);
}
return clonePreset(presets[DEFAULT_CAMERA_PRESET]);
}
function parseCameraJsonString(raw, { strict = true } = {}) {
const text = String(raw || "").trim();
if (!text.startsWith("{")) {
return null;
}
try {
const parsed = JSON.parse(text);
if (!isPlainObject(parsed)) {
throw new Error("camera JSON must be an object");
}
return parsed;
} catch (error) {
if (strict) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid camera JSON: ${message}`);
}
return null;
}
}
function validateCameraSpecKeys(spec) {
const unknownKeys = Object.keys(spec).filter((key) => !CAMERA_SPEC_KEYS.includes(key));
if (unknownKeys.length) {
throw new Error(`Unsupported camera fields: ${unknownKeys.join(", ")}`);
}
}
export function normalizeCameraSpec(rawCamera = DEFAULT_CAMERA_PRESET, {
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const parsedJson = typeof rawCamera === "string"
? parseCameraJsonString(rawCamera, { strict })
: null;
const source = parsedJson || rawCamera;
if (!isPlainObject(source)) {
const preset = cameraPresetByName(source || DEFAULT_CAMERA_PRESET, { presets, strict });
return {
sourceKind: "string",
name: preset.name,
preset: preset.name,
direction: preset.direction,
position: null,
target: null,
up: preset.up,
zoom: 1,
hasExplicitPosition: false,
hasExplicitTarget: false,
hasExplicitUp: false,
hasExplicitZoom: false
};
}
validateCameraSpecKeys(source);
const rawPreset = source.preset ?? DEFAULT_CAMERA_PRESET;
const preset = cameraPresetByName(rawPreset, { presets, strict });
const direction = normalizeVector(source.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || preset.direction;
const position = normalizeVector(source.position, {
fieldName: "camera.position",
strict
});
const target = normalizeVector(source.target, {
fieldName: "camera.target",
strict
});
const up = normalizeVector(source.up, {
fieldName: "camera.up",
nonZero: true,
strict
}) || preset.up || [...WORLD_UP];
const hasExplicitZoom = Object.prototype.hasOwnProperty.call(source, "zoom");
return {
sourceKind: "object",
name: String(source.name || preset.name || "custom"),
preset: preset.name,
direction,
position,
target,
up,
zoom: normalizeCameraZoom(source.zoom, 1, { strict, fieldName: "camera.zoom" }),
hasExplicitPosition: Object.prototype.hasOwnProperty.call(source, "position"),
hasExplicitTarget: Object.prototype.hasOwnProperty.call(source, "target"),
hasExplicitUp: Object.prototype.hasOwnProperty.call(source, "up"),
hasExplicitZoom
};
}
function settingsForCamera(sceneScale, settingsByScale) {
if (settingsByScale?.[sceneScale]) {
return settingsByScale[sceneScale];
}
if (settingsByScale?.cad) {
return settingsByScale.cad;
}
return {
minBoundsSpan: 1,
minModelRadius: 1,
minCameraDistance: 10,
minCameraFar: 1000
};
}
function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = settingsForCamera(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
function vectorFromArray(vector) {
return new THREE.Vector3(vector[0], vector[1], vector[2]);
}
export function resolveCameraView(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
const target = spec.target ? vectorFromArray(spec.target) : null;
const position = spec.position ? vectorFromArray(spec.position) : null;
const direction = position && target
? position.clone().sub(target).normalize().toArray()
: normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict: false
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
return {
name: spec.name,
direction,
up: [...(spec.up || WORLD_UP)],
zoom: spec.zoom
};
}
export function resolveCameraSnapshot(rawCamera = DEFAULT_CAMERA_PRESET, bounds = null, {
sceneScale = "cad",
settingsByScale = null,
presets = RENDER_CAMERA_PRESETS,
strict = true
} = {}) {
const spec = normalizeCameraSpec(rawCamera, { presets, strict });
const settings = settingsForCamera(sceneScale, settingsByScale);
const { center, radius, size } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const target = spec.target ? vectorFromArray(spec.target) : center.clone();
const direction = normalizeVector(spec.direction, {
fieldName: "camera.direction",
nonZero: true,
strict
}) || [...RENDER_CAMERA_PRESETS[DEFAULT_CAMERA_PRESET].direction];
const position = spec.position
? vectorFromArray(spec.position)
: target.clone().add(vectorFromArray(direction).normalize().multiplyScalar(
Math.max(radius * 3.2, settings.minCameraDistance || settings.minModelRadius * 10 || 10)
));
const resolvedDirection = position.clone().sub(target);
if (resolvedDirection.lengthSq() <= 1e-12) {
throw new Error("camera.position must differ from camera.target");
}
const up = vectorFromArray(spec.up || WORLD_UP);
if (up.lengthSq() <= 1e-12) {
throw new Error("camera.up must not be the zero vector");
}
return {
name: spec.name,
preset: spec.preset,
position: position.toArray(),
target: target.toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom,
direction: resolvedDirection.normalize().toArray(),
view: {
name: spec.name,
direction: resolvedDirection.normalize().toArray(),
up: up.normalize().toArray(),
zoom: spec.zoom
},
sourceKind: spec.sourceKind,
hasExplicitPosition: spec.hasExplicitPosition,
hasExplicitTarget: spec.hasExplicitTarget,
hasExplicitUp: spec.hasExplicitUp,
hasExplicitZoom: spec.hasExplicitZoom,
radius,
size: size.toArray()
};
}
export function cameraSpecUsesPerspectiveProjection(rawCamera = DEFAULT_CAMERA_PRESET, options = {}) {
const spec = normalizeCameraSpec(rawCamera, options);
return spec.sourceKind === "object" && (
spec.hasExplicitPosition ||
spec.hasExplicitTarget ||
spec.hasExplicitUp
);
}
@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cameraSpecUsesPerspectiveProjection,
normalizeCameraSpec,
resolveCameraSnapshot
} from "./camera.js";
import {
RENDER_SCENE_SCALE
} from "./renderOptions.js";
const SCALE_SETTINGS = Object.freeze({
[RENDER_SCENE_SCALE.CAD]: Object.freeze({
minBoundsSpan: 1,
minModelRadius: 1,
minFloorSize: 100,
minCameraDistance: 10,
minCameraFar: 1000
}),
[RENDER_SCENE_SCALE.URDF]: Object.freeze({
minBoundsSpan: 0.05,
minModelRadius: 0.05,
minFloorSize: 0.05,
minCameraDistance: 0.5,
minCameraFar: 10
})
});
function assertClose(actual, expected, epsilon = 1e-6) {
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} !== ${expected}`);
}
test("camera preset strings expand like JSON preset specs", () => {
const stringSpec = normalizeCameraSpec("iso");
const jsonSpec = normalizeCameraSpec({ preset: "iso" });
assert.equal(stringSpec.name, jsonSpec.name);
assert.equal(stringSpec.preset, jsonSpec.preset);
assert.deepEqual(stringSpec.direction, jsonSpec.direction);
assert.deepEqual(stringSpec.up, jsonSpec.up);
assert.equal(stringSpec.zoom, jsonSpec.zoom);
});
test("camera JSON preset overrides merge onto the preset defaults", () => {
const spec = normalizeCameraSpec({
preset: "top",
up: [0, 0, 1],
zoom: 1.4
});
assert.equal(spec.name, "top");
assert.deepEqual(spec.direction, [0, 0, 1]);
assert.deepEqual(spec.up, [0, 0, 1]);
assert.equal(spec.zoom, 1.4);
assert.equal(spec.hasExplicitZoom, true);
});
test("explicit camera state passes through resolved snapshots", () => {
const snapshot = resolveCameraSnapshot({
position: [10, 20, 30],
target: [1, 2, 3],
up: [0, 0, 1],
zoom: 1.4
}, { min: [0, 0, 0], max: [2, 4, 6] }, {
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assert.deepEqual(snapshot.position, [10, 20, 30]);
assert.deepEqual(snapshot.target, [1, 2, 3]);
assert.deepEqual(snapshot.up, [0, 0, 1]);
assert.equal(snapshot.zoom, 1.4);
assert.equal(cameraSpecUsesPerspectiveProjection({ position: [10, 20, 30] }), true);
});
test("omitted camera fields derive from model bounds", () => {
const snapshot = resolveCameraSnapshot({ preset: "top" }, { min: [0, 0, 0], max: [2, 4, 6] }, {
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assert.deepEqual(snapshot.target, [1, 2, 3]);
assertClose(snapshot.position[0], 1);
assertClose(snapshot.position[1], 2);
assert.ok(snapshot.position[2] > 3);
assert.deepEqual(snapshot.up, [0, 1, 0]);
assert.equal(snapshot.zoom, 1);
assert.equal(cameraSpecUsesPerspectiveProjection({ preset: "top" }), false);
});
test("invalid camera specs fail clearly", () => {
assert.throws(() => normalizeCameraSpec({ preset: "wat" }), /Unknown camera preset/);
assert.throws(() => normalizeCameraSpec({ position: [1, 2, "x"] }), /camera.position/);
assert.throws(() => normalizeCameraSpec({ up: [0, 0, 0] }), /camera.up/);
assert.throws(() => normalizeCameraSpec({ zoom: 0 }), /camera.zoom/);
assert.throws(() => normalizeCameraSpec({ preset: "iso", extra: true }), /Unsupported camera fields/);
});
@@ -0,0 +1,48 @@
import {
buildPartTransformMatrix
} from "./stepModuleEffects.js";
function applyObjectMatrix(THREE, object3d, matrix) {
if (!object3d || !(matrix instanceof THREE.Matrix4)) {
return;
}
object3d.matrixAutoUpdate = false;
const targetMatrix = object3d.matrix instanceof THREE.Matrix4 ? object3d.matrix : new THREE.Matrix4();
targetMatrix.copy(matrix);
object3d.matrix = targetMatrix;
object3d.matrixWorldNeedsUpdate = true;
}
export function composeDisplayRecordEffectMatrix(THREE, record) {
if (!record || !THREE?.Matrix4) {
return null;
}
const matrices = [
record.effectMatrix,
record.explodedViewMatrix
].filter((matrix) => matrix instanceof THREE.Matrix4);
if (!matrices.length) {
return null;
}
const combined = new THREE.Matrix4();
for (const matrix of matrices) {
combined.premultiply(matrix);
}
return combined;
}
export function composeDisplayRecordObjectMatrix(THREE, record) {
const baseMatrix = buildPartTransformMatrix(THREE, record?.baseTransform);
const effectMatrix = composeDisplayRecordEffectMatrix(THREE, record);
return effectMatrix ? effectMatrix.multiply(baseMatrix) : baseMatrix;
}
export function applyDisplayRecordTransform(THREE, record) {
if (!record) {
return;
}
const combinedMatrix = composeDisplayRecordObjectMatrix(THREE, record);
applyObjectMatrix(THREE, record.mesh, combinedMatrix);
applyObjectMatrix(THREE, record.edges, combinedMatrix);
applyObjectMatrix(THREE, record.silhouette, combinedMatrix);
}
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
composeDisplayRecordEffectMatrix,
composeDisplayRecordObjectMatrix
} from "./displayRecordTransform.js";
test("display record transforms compose module effects and exploded view effects", () => {
const record = {
baseTransform: [
1, 0, 0, 3,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
],
effectMatrix: new THREE.Matrix4().makeTranslation(5, 0, 0),
explodedViewMatrix: new THREE.Matrix4().makeTranslation(0, 7, 0)
};
const effectPoint = new THREE.Vector3(0, 0, 0).applyMatrix4(
composeDisplayRecordEffectMatrix(THREE, record)
);
assert.deepEqual(effectPoint.toArray(), [5, 7, 0]);
const objectPoint = new THREE.Vector3(0, 0, 0).applyMatrix4(
composeDisplayRecordObjectMatrix(THREE, record)
);
assert.deepEqual(objectPoint.toArray(), [8, 7, 0]);
});
@@ -0,0 +1,381 @@
import {
DEFAULT_STEP_CLIP_SETTINGS,
normalizeStepClipSettings,
stepClipSettingsEqual
} from "../lib/viewer/clipPlane.js";
import {
CAMERA_PROJECTION,
normalizeCameraProjection
} from "../lib/perspective.js";
export { CAMERA_PROJECTION, normalizeCameraProjection };
export const CAD_DISPLAY_MODE = Object.freeze({
HIDDEN_EDGES: "hidden_edges",
HIDDEN_LINES_REMOVED: "hidden_lines_removed",
RENDERED: "rendered",
SOLID: "solid",
TRANSPARENT: "transparent",
UNSHADED: "unshaded",
WIREFRAME: "wireframe"
});
export const CAD_DISPLAY_MODE_VALUES = Object.freeze(Object.values(CAD_DISPLAY_MODE));
export const EXPLODED_VIEW_AXES = Object.freeze(["x", "y", "z", "radial"]);
export const EXPLODED_VIEW_DIRECTIONS = Object.freeze(["positive", "negative"]);
export const MAX_EXPLODED_VIEW_DEPTH = 8;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const CAD_EDGE_COLOR = "#132232";
export const CAD_EDGE_HIGHLIGHT_COLOR = "#8dc5ff";
export const CAD_EDGE_CLASS_IDS = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
export const DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 1.15
}),
tangent: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.5,
thickness: 1.15
}),
seam: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 0.85,
thickness: 1.15
}),
degenerate: Object.freeze({
color: CAD_EDGE_COLOR,
opacity: 1,
thickness: 0
})
});
export const DEFAULT_DISPLAY_EDGE_SETTINGS = Object.freeze({
enabled: true,
contrastMode: "manual",
color: CAD_EDGE_COLOR,
thickness: 1,
classes: DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
highlightColor: CAD_EDGE_HIGHLIGHT_COLOR,
highlightOpacity: 1,
highlightThickness: 3,
silhouette: false,
silhouetteScale: 0
});
export const DISABLED_DISPLAY_EDGE_SETTINGS = Object.freeze({
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false
});
export const DEFAULT_EXPLODED_VIEW_SETTINGS = Object.freeze({
enabled: false,
axis: "z",
direction: "positive",
spacing: 1.45,
depth: 1,
keepBaseGrounded: true,
mergeCoplanar: false,
autoFrame: true
});
export const DEFAULT_DISPLAY_SETTINGS = Object.freeze({
projection: CAMERA_PROJECTION.ORTHOGRAPHIC,
mode: CAD_DISPLAY_MODE.SOLID,
clip: DEFAULT_STEP_CLIP_SETTINGS,
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeNumber(value, fallback, min = -Infinity, max = Infinity) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return fallback;
}
return clamp(numericValue, min, max);
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDisplayEdgeContrastMode(value, fallback = "manual") {
const normalized = String(value || "").trim().toLowerCase();
return ["auto", "manual"].includes(normalized)
? normalized
: fallback;
}
export function normalizeDisplayEdgeClassSettings(
value = {},
fallback = DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS,
colorFallback = CAD_EDGE_COLOR
) {
const source = isObject(value) ? value : {};
const fallbackColor = normalizeColor(colorFallback, CAD_EDGE_COLOR);
return Object.fromEntries(CAD_EDGE_CLASS_IDS.map((classId) => {
const classSource = isObject(source[classId]) ? source[classId] : {};
const classFallback = fallback?.[classId] || DEFAULT_DISPLAY_EDGE_CLASS_SETTINGS[classId];
const legacyDisabled = classSource.enabled === false;
return [classId, {
color: normalizeColor(classSource.color, fallbackColor),
opacity: normalizeNumber(classSource.opacity, classFallback.opacity, 0, 1),
thickness: legacyDisabled
? 0
: normalizeNumber(classSource.thickness, classFallback.thickness, 0, 6)
}];
}));
}
export function normalizeDisplayEdgeSettings(value = null, fallback = DEFAULT_DISPLAY_EDGE_SETTINGS) {
const source = isObject(value) ? value : {};
const color = normalizeColor(source.color, fallback.color);
const normalized = {
enabled: normalizeBoolean(source.enabled, fallback.enabled),
contrastMode: normalizeDisplayEdgeContrastMode(source.contrastMode, fallback.contrastMode),
color,
thickness: normalizeNumber(source.thickness, fallback.thickness, 0.5, 6),
classes: normalizeDisplayEdgeClassSettings(source.classes, fallback.classes, color),
highlightColor: normalizeColor(source.highlightColor, fallback.highlightColor || CAD_EDGE_HIGHLIGHT_COLOR),
highlightOpacity: normalizeNumber(source.highlightOpacity, fallback.highlightOpacity || 1, 0, 1),
highlightThickness: normalizeNumber(source.highlightThickness, fallback.highlightThickness || 3, 0.5, 6),
silhouette: normalizeBoolean(source.silhouette, fallback.silhouette || false),
silhouetteScale: normalizeNumber(source.silhouetteScale, fallback.silhouetteScale || 0, 0, 0.04)
};
if (typeof source.depthTest === "boolean") {
normalized.depthTest = source.depthTest;
}
return normalized;
}
function normalizeModeText(value) {
return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").replace(/-/g, "_");
}
export function normalizeDisplayMode(value) {
const normalized = normalizeModeText(value);
if (!normalized) {
return CAD_DISPLAY_MODE.SOLID;
}
if (normalized === "wire" || normalized === "wire_frame") {
return CAD_DISPLAY_MODE.WIREFRAME;
}
if (
normalized === "edges" ||
normalized === "edge" ||
normalized === "shaded_edges" ||
normalized === "shaded_with_edges" ||
normalized === "with_edges"
) {
return CAD_DISPLAY_MODE.SOLID;
}
if (
normalized === "shaded" ||
normalized === "shaded_without_edges" ||
normalized === "without_edges"
) {
return CAD_DISPLAY_MODE.RENDERED;
}
if (
normalized === "translucent" ||
normalized === "xray" ||
normalized === "x_ray" ||
normalized === "see_through"
) {
return CAD_DISPLAY_MODE.TRANSPARENT;
}
if (
normalized === "hidden_edge" ||
normalized === "hidden_edges_visible" ||
normalized === "hidden_edge_display" ||
normalized === "shaded_hidden_edges"
) {
return CAD_DISPLAY_MODE.HIDDEN_EDGES;
}
if (
normalized === "visible_edges" ||
normalized === "visible_edges_only" ||
normalized === "hidden_lines" ||
normalized === "hidden_line_removed" ||
normalized === "hidden_lines_removed" ||
normalized === "hidden_edges_removed"
) {
return CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED;
}
if (normalized === "flat") {
return CAD_DISPLAY_MODE.UNSHADED;
}
if (normalized === "appearance" || normalized === "material" || normalized === "materials") {
return CAD_DISPLAY_MODE.RENDERED;
}
return CAD_DISPLAY_MODE_VALUES.includes(normalized)
? normalized
: CAD_DISPLAY_MODE.SOLID;
}
export function normalizeExplodedViewAxis(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.axis) {
const normalized = String(value || "").trim().toLowerCase();
const axis = normalized.startsWith("-") ? normalized.slice(1) : normalized;
return EXPLODED_VIEW_AXES.includes(axis) ? axis : fallback;
}
export function normalizeExplodedViewDirection(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.direction) {
const normalized = String(value || "").trim().toLowerCase();
if (["negative", "reverse", "down", "backward", "-", "-1"].includes(normalized)) {
return "negative";
}
if (["positive", "forward", "up", "+", "+1", "1"].includes(normalized)) {
return "positive";
}
return fallback;
}
export function normalizeExplodedViewDepth(value, fallback = DEFAULT_EXPLODED_VIEW_SETTINGS.depth) {
const normalized = String(value ?? "").trim().toLowerCase();
if (["all", "full", "parts", "leaves", "leaf"].includes(normalized)) {
return MAX_EXPLODED_VIEW_DEPTH;
}
const numericValue = Math.round(Number(value));
return Number.isFinite(numericValue)
? clamp(numericValue, 1, MAX_EXPLODED_VIEW_DEPTH)
: fallback;
}
export function normalizeExplodedViewSettings(value = null, overrides = {}) {
const source = isObject(value) ? value : {};
const merged = isObject(overrides) ? { ...source, ...overrides } : source;
const axisText = String(merged.axis || "").trim().toLowerCase();
return {
enabled: normalizeBoolean(merged.enabled, DEFAULT_EXPLODED_VIEW_SETTINGS.enabled),
axis: normalizeExplodedViewAxis(merged.axis),
direction: normalizeExplodedViewDirection(merged.direction || (axisText.startsWith("-") ? "negative" : "positive")),
spacing: normalizeNumber(merged.spacing ?? merged.distance ?? merged.distanceScale, DEFAULT_EXPLODED_VIEW_SETTINGS.spacing, 0.25, 4),
depth: normalizeExplodedViewDepth(merged.depth ?? merged.levels ?? merged.scopeDepth),
keepBaseGrounded: normalizeBoolean(merged.keepBaseGrounded ?? merged.groundBase, DEFAULT_EXPLODED_VIEW_SETTINGS.keepBaseGrounded),
mergeCoplanar: normalizeBoolean(merged.mergeCoplanar ?? merged.mergeLayers ?? merged.coalesceLayers, DEFAULT_EXPLODED_VIEW_SETTINGS.mergeCoplanar),
autoFrame: normalizeBoolean(merged.autoFrame, DEFAULT_EXPLODED_VIEW_SETTINGS.autoFrame)
};
}
export function normalizeDisplaySettings(value = null) {
const source = isObject(value) ? value : {};
const explodedOverrides = {};
if (isObject(source.exploded) && source.exploded.enabled !== undefined) {
explodedOverrides.enabled = source.exploded.enabled;
}
return {
projection: normalizeCameraProjection(source.projection, DEFAULT_DISPLAY_SETTINGS.projection),
mode: normalizeDisplayMode(source.mode),
clip: normalizeStepClipSettings(source.clip),
exploded: normalizeExplodedViewSettings(source.exploded, explodedOverrides),
edges: normalizeDisplayEdgeSettings(source.edges)
};
}
export function cloneDisplaySettings(value = DEFAULT_DISPLAY_SETTINGS) {
return normalizeDisplaySettings(value);
}
export function displaySettingsEqual(left, right) {
const a = normalizeDisplaySettings(left);
const b = normalizeDisplaySettings(right);
return a.projection === b.projection &&
a.mode === b.mode &&
stepClipSettingsEqual(a.clip, b.clip) &&
JSON.stringify(a.exploded) === JSON.stringify(b.exploded) &&
JSON.stringify(a.edges) === JSON.stringify(b.edges);
}
export function resolveDisplayMode(displaySettings) {
return normalizeDisplaySettings(displaySettings).mode;
}
export function resolveDisplayEdgeSettings(displaySettings) {
return normalizeDisplaySettings(displaySettings).edges;
}
export function displayModeIsWireframe(value) {
return normalizeDisplayMode(value) === CAD_DISPLAY_MODE.WIREFRAME;
}
export function displayModeForcesEdges(value) {
return [
CAD_DISPLAY_MODE.SOLID,
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED
].includes(normalizeDisplayMode(value));
}
export function displayModeAllowsEdges(value) {
return ![
CAD_DISPLAY_MODE.RENDERED,
CAD_DISPLAY_MODE.UNSHADED
].includes(normalizeDisplayMode(value));
}
export function displayModeShowsEdges(value, edgeSettings = null) {
const mode = normalizeDisplayMode(value);
return mode === CAD_DISPLAY_MODE.WIREFRAME ||
displayModeForcesEdges(mode);
}
export function displayModeShowsThroughEdges(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_EDGES
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesTransparentSurfaces(value) {
return [
CAD_DISPLAY_MODE.TRANSPARENT,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeUsesUnlitSurfaces(value) {
return [
CAD_DISPLAY_MODE.UNSHADED,
CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED,
CAD_DISPLAY_MODE.WIREFRAME
].includes(normalizeDisplayMode(value));
}
export function displayModeSurfaceOpacity(value, fallback = 1) {
const mode = normalizeDisplayMode(value);
if (mode === CAD_DISPLAY_MODE.WIREFRAME) {
return 0.035;
}
if (mode === CAD_DISPLAY_MODE.TRANSPARENT) {
return 0.22;
}
if (mode === CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED) {
return 0.045;
}
const numericFallback = Number(fallback);
return Number.isFinite(numericFallback) ? numericFallback : 1;
}
@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CAD_DISPLAY_MODE,
CAMERA_PROJECTION,
DEFAULT_DISPLAY_EDGE_SETTINGS,
DEFAULT_DISPLAY_SETTINGS,
DEFAULT_EXPLODED_VIEW_SETTINGS,
displayModeForcesEdges,
displayModeShowsEdges,
displayModeShowsThroughEdges,
displayModeSurfaceOpacity,
displayModeUsesUnlitSurfaces,
displaySettingsEqual,
normalizeDisplayEdgeSettings,
normalizeDisplaySettings,
normalizeExplodedViewSettings,
resolveDisplayMode
} from "./displaySettings.js";
test("display settings normalize mode and clip independently from appearance settings", () => {
assert.deepEqual(normalizeDisplaySettings(), DEFAULT_DISPLAY_SETTINGS);
assert.equal(resolveDisplayMode({ mode: "wireframe" }), CAD_DISPLAY_MODE.WIREFRAME);
assert.equal(normalizeDisplaySettings().projection, CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.equal(normalizeDisplaySettings({ projection: "perspective" }).projection, CAMERA_PROJECTION.PERSPECTIVE);
assert.equal(normalizeDisplaySettings({ projection: "fisheye" }).projection, CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.deepEqual(normalizeDisplaySettings({
projection: "perspective",
mode: "wireframe",
clip: {
enabled: true,
axis: "z",
offset: 0.4,
invert: true
}
}), {
projection: CAMERA_PROJECTION.PERSPECTIVE,
mode: CAD_DISPLAY_MODE.WIREFRAME,
clip: {
enabled: true,
axis: "z",
offset: 0.4,
offsets: {
x: 0,
y: 0,
z: 0.4
},
invert: true
},
exploded: DEFAULT_EXPLODED_VIEW_SETTINGS,
edges: DEFAULT_DISPLAY_EDGE_SETTINGS
});
});
test("display settings normalize edge styling independently from appearance settings", () => {
assert.deepEqual(normalizeDisplayEdgeSettings({
enabled: false,
contrastMode: "auto",
color: "#ABC",
thickness: 2,
classes: {
tangent: {
enabled: false,
color: "#456",
opacity: 0.25,
thickness: 4
}
},
highlightColor: "#123456",
highlightOpacity: 0.4,
highlightThickness: 4,
silhouette: true,
silhouetteScale: 0.01
}), {
enabled: false,
contrastMode: "auto",
color: "#aabbcc",
thickness: 2,
classes: {
feature: { color: "#aabbcc", opacity: 1, thickness: 1.15 },
tangent: { color: "#445566", opacity: 0.25, thickness: 0 },
seam: { color: "#aabbcc", opacity: 0.85, thickness: 1.15 },
degenerate: { color: "#aabbcc", opacity: 1, thickness: 0 }
},
highlightColor: "#123456",
highlightOpacity: 0.4,
highlightThickness: 4,
silhouette: true,
silhouetteScale: 0.01
});
assert.deepEqual(normalizeDisplaySettings({
mode: "solid",
edges: {
enabled: false,
color: "#456"
}
}).edges, {
...DEFAULT_DISPLAY_EDGE_SETTINGS,
enabled: false,
color: "#445566",
classes: {
feature: { color: "#445566", opacity: 1, thickness: 1.15 },
tangent: { color: "#445566", opacity: 0.5, thickness: 1.15 },
seam: { color: "#445566", opacity: 0.85, thickness: 1.15 },
degenerate: { color: "#445566", opacity: 1, thickness: 0 }
}
});
});
test("display settings normalize exploded-view controls independently from mode", () => {
assert.deepEqual(normalizeExplodedViewSettings({
enabled: true,
axis: "-x",
spacing: 2,
levels: "all",
groundBase: false,
mergeLayers: true,
autoFrame: false
}), {
enabled: true,
axis: "x",
direction: "negative",
spacing: 2,
depth: 8,
keepBaseGrounded: false,
mergeCoplanar: true,
autoFrame: false
});
assert.equal(normalizeExplodedViewSettings({ axis: "diagonal" }).axis, "z");
assert.equal(normalizeExplodedViewSettings({ axis: "radial" }).axis, "radial");
assert.equal(normalizeDisplaySettings({ exploded: true }).exploded.enabled, false);
assert.equal(normalizeDisplaySettings({ mode: "exploded", exploded: { enabled: true } }).exploded.enabled, true);
assert.deepEqual(normalizeDisplaySettings({ mode: "exploded view" }), DEFAULT_DISPLAY_SETTINGS);
});
test("display modes normalize common CAD aliases", () => {
assert.equal(resolveDisplayMode({ mode: "edges" }), CAD_DISPLAY_MODE.SOLID);
assert.equal(resolveDisplayMode({ mode: "shaded-with-edges" }), CAD_DISPLAY_MODE.SOLID);
assert.equal(resolveDisplayMode({ mode: "shaded without edges" }), CAD_DISPLAY_MODE.RENDERED);
assert.equal(resolveDisplayMode({ mode: "x-ray" }), CAD_DISPLAY_MODE.TRANSPARENT);
assert.equal(resolveDisplayMode({ mode: "hidden edges visible" }), CAD_DISPLAY_MODE.HIDDEN_EDGES);
assert.equal(resolveDisplayMode({ mode: "hidden-lines-removed" }), CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED);
assert.equal(resolveDisplayMode({ mode: "flat" }), CAD_DISPLAY_MODE.UNSHADED);
assert.equal(resolveDisplayMode({ mode: "appearance" }), CAD_DISPLAY_MODE.RENDERED);
assert.equal(resolveDisplayMode({ mode: "wire" }), CAD_DISPLAY_MODE.WIREFRAME);
});
test("display mode policies describe edge and surface behavior", () => {
assert.equal(displayModeShowsEdges(CAD_DISPLAY_MODE.SOLID, { enabled: false }), true);
assert.equal(displayModeForcesEdges(CAD_DISPLAY_MODE.SOLID), true);
assert.equal(displayModeShowsEdges(CAD_DISPLAY_MODE.RENDERED, { enabled: true }), false);
assert.equal(displayModeShowsThroughEdges(CAD_DISPLAY_MODE.HIDDEN_EDGES), true);
assert.equal(displayModeShowsThroughEdges(CAD_DISPLAY_MODE.TRANSPARENT), true);
assert.equal(displayModeUsesUnlitSurfaces(CAD_DISPLAY_MODE.UNSHADED), true);
assert.equal(displayModeSurfaceOpacity(CAD_DISPLAY_MODE.TRANSPARENT, 1), 0.22);
assert.equal(displayModeSurfaceOpacity(CAD_DISPLAY_MODE.HIDDEN_LINES_REMOVED, 1), 0.045);
});
test("display settings compare after normalization", () => {
assert.equal(displaySettingsEqual(
{ mode: "wireframe", clip: { enabled: true, axis: "x", offset: 0.5 } },
{ mode: CAD_DISPLAY_MODE.WIREFRAME, clip: { enabled: true, axis: "x", offsets: { x: 0.5 } } }
), true);
assert.equal(displaySettingsEqual(
{ mode: "solid", clip: { enabled: true } },
{ mode: "wireframe", clip: { enabled: true } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", exploded: { enabled: true } },
{ mode: "solid", exploded: { enabled: false } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", edges: { color: "#111111" } },
{ mode: "solid", edges: { color: "#222222" } }
), false);
assert.equal(displaySettingsEqual(
{ mode: "solid", projection: "orthographic" },
{ mode: "solid", projection: "perspective" }
), false);
});
@@ -0,0 +1,29 @@
export const DEFAULT_ORBIT_FPS = 6;
export const DEFAULT_ORBIT_DURATION_SECONDS = 12;
export function orbitFrameOutputs(job) {
const orbit = job.orbit && typeof job.orbit === "object" ? job.orbit : {};
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || orbit.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || orbit.height || 480)));
const fps = Math.max(1, Math.min(Number(orbit.fps || DEFAULT_ORBIT_FPS), 60));
const durationSeconds = Math.max(0.1, Math.min(Number(orbit.durationSeconds || DEFAULT_ORBIT_DURATION_SECONDS), 60));
const frameCount = Math.max(2, Math.min(Math.round(fps * durationSeconds), 720));
const startAzimuth = Number.isFinite(Number(orbit.startAzimuth)) ? Number(orbit.startAzimuth) : -45;
const elevation = Number.isFinite(Number(orbit.elevation)) ? Number(orbit.elevation) : 30;
const turns = Number.isFinite(Number(orbit.turns)) ? Number(orbit.turns) : 1;
return {
path: String(output.path || job.output || ""),
width,
height,
fps,
durationSeconds,
frameCount,
outputs: Array.from({ length: frameCount }, (_, index) => ({
path: "",
width,
height,
camera: `${startAzimuth + ((360 * turns * index) / frameCount)}:${elevation}`
}))
};
}
@@ -0,0 +1,232 @@
import gifencDefault, {
GIFEncoder as exportedGifEncoder,
applyPalette as exportedApplyPalette,
quantize as exportedQuantize
} from "gifenc";
import * as THREE from "three";
import {
buildModel
} from "./cadScene.js";
import {
captureModel,
modelOptionsForRenderJob,
renderJobContext,
renderModel
} from "./renderMeshScene.js";
import {
hasStepParameterRenderValues
} from "./stepParameters.js";
import {
loadSource,
stepParameterFrameRuntime
} from "./source.js";
import {
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
const GIFEncoder = exportedGifEncoder || gifencDefault?.GIFEncoder || gifencDefault;
const quantize = exportedQuantize || gifencDefault?.quantize;
const applyPalette = exportedApplyPalette || gifencDefault?.applyPalette;
async function dataUrlToImageData(dataUrl, width, height) {
const image = new Image();
image.decoding = "async";
const loaded = new Promise((resolve, reject) => {
image.onload = resolve;
image.onerror = () => reject(new Error("Failed to load rendered orbit frame"));
});
image.src = dataUrl;
await loaded;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return context.getImageData(0, 0, width, height);
}
function shouldEncodeTransparentGif(job = {}) {
const backgroundType = String(
job.appearance?.background?.type || ""
).toLowerCase();
return Boolean(job.render?.transparent) || backgroundType === "transparent";
}
function encodeGifFrameImageData(imageData, { transparent = false } = {}) {
if (!transparent) {
const palette = quantize(imageData.data, 256);
return {
indexed: applyPalette(imageData.data, palette),
palette,
transparent: false,
transparentIndex: 0
};
}
const palette = quantize(imageData.data, 256, {
format: "rgba4444",
oneBitAlpha: true
});
const transparentIndex = palette.findIndex((color) => Number(color?.[3]) <= 127);
return {
indexed: applyPalette(imageData.data, palette, "rgba4444"),
palette,
transparent: transparentIndex >= 0,
transparentIndex: Math.max(transparentIndex, 0)
};
}
async function capturePreparedSource(source, job) {
const context = renderJobContext(source.meshData, job);
const model = buildModel(THREE, source, modelOptionsForRenderJob(context, job));
if (context.mode === "list" || context.mode === "section") {
try {
return await captureModel({ model, context }, { job });
} finally {
model.dispose();
}
}
const viewport = renderModel(THREE, model, { job, context });
try {
return await captureModel(viewport, { job });
} finally {
viewport.dispose();
}
}
async function renderOrbit(source, job) {
const orbit = orbitFrameOutputs(job);
const frameResult = await capturePreparedSource(source, {
...job,
mode: "view",
outputs: orbit.outputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, orbit.width, orbit.height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, orbit.width, orbit.height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / orbit.fps,
repeat: 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: "orbit",
outputs: [{
path: orbit.path,
width: orbit.width,
height: orbit.height,
frameCount: orbit.frameCount,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
async function renderParamAnimation(source, job, stepParameterSource) {
const params = stepParameterSource.renderParameters;
const output = Array.isArray(job.outputs) && job.outputs.length ? job.outputs[0] : {};
const width = Math.max(1, Math.floor(Number(output.width || job.width || 720)));
const height = Math.max(1, Math.floor(Number(output.height || job.height || 480)));
const frameOutputs = Array.from({ length: params.frameCount }, (_, index) => ({
...output,
path: "",
width,
height,
stepParameters: stepParameterFrameRuntime(stepParameterSource, index)
}));
const frameResult = await capturePreparedSource(source, {
...job,
outputs: frameOutputs,
render: {
...(job.render || {}),
lockFraming: true
}
});
const encoder = GIFEncoder();
const transparent = shouldEncodeTransparentGif(job);
for (let index = 0; index < frameResult.outputs.length; index += 1) {
const imageData = await dataUrlToImageData(frameResult.outputs[index].dataUrl, width, height);
const frame = encodeGifFrameImageData(imageData, { transparent });
encoder.writeFrame(frame.indexed, width, height, {
palette: frame.palette,
transparent: frame.transparent,
transparentIndex: frame.transparentIndex,
delay: 1000 / params.fps,
repeat: params.loop === false ? -1 : 0,
dispose: frame.transparent ? 2 : -1
});
}
encoder.finish();
const bytes = encoder.bytesView();
let binary = "";
for (let index = 0; index < bytes.length; index += 1) {
binary += String.fromCharCode(bytes[index]);
}
return {
ok: true,
mode: String(job.mode || "view").toLowerCase(),
outputs: [{
path: String(output.path || job.output || ""),
width,
height,
frameCount: params.frameCount,
fps: params.fps,
durationSeconds: params.durationSeconds,
loop: params.loop !== false,
mimeType: "image/gif",
dataUrl: `data:image/gif;base64,${btoa(binary)}`
}],
timings: frameResult.timings,
warnings: frameResult.warnings || []
};
}
export async function runHeadlessRenderJob(job) {
const source = await loadSource(job);
const stepParameterSource = source.stepParameterSource;
const explicitParams = hasStepParameterRenderValues(job.stepParameters);
const renderJob = {
...job,
selectorRuntime: source.selectorRuntime,
displayEdgeRuntime: source.displayEdgeRuntime
};
if (stepParameterSource && explicitParams && String(job.mode || "view").toLowerCase() !== "view") {
throw new Error("stepParameters support only view mode; set display.mode for display-style changes");
}
const renderJobWithStepParameters = stepParameterSource
? {
...renderJob,
stepParameters: stepParameterFrameRuntime(stepParameterSource, 0)
}
: renderJob;
if (stepParameterSource?.renderParameters?.animated) {
return renderParamAnimation(source, renderJob, stepParameterSource);
}
if (String(job.mode || "view").toLowerCase() === "orbit") {
return renderOrbit(source, renderJobWithStepParameters);
}
return capturePreparedSource(source, renderJobWithStepParameters);
}
if (typeof window !== "undefined") {
window.__snapshotRender = runHeadlessRenderJob;
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_ORBIT_DURATION_SECONDS,
DEFAULT_ORBIT_FPS,
orbitFrameOutputs
} from "./headlessOrbitFrames.js";
test("orbitFrameOutputs uses calm default orbit timing", () => {
const orbit = orbitFrameOutputs({
mode: "orbit",
outputs: [{ path: "orbit.gif" }]
});
assert.equal(orbit.fps, DEFAULT_ORBIT_FPS);
assert.equal(orbit.durationSeconds, DEFAULT_ORBIT_DURATION_SECONDS);
assert.equal(orbit.frameCount, 72);
assert.equal(orbit.outputs.length, 72);
assert.equal(orbit.outputs[0].camera, "-45:30");
assert.equal(orbit.outputs.at(-1).camera, "310:30");
});
test("orbitFrameOutputs honors explicit orbit timing", () => {
const orbit = orbitFrameOutputs({
mode: "orbit",
orbit: {
fps: 18,
durationSeconds: 4,
turns: 0.5,
startAzimuth: 10,
elevation: 20
},
outputs: [{ path: "orbit.gif" }]
});
assert.equal(orbit.fps, 18);
assert.equal(orbit.durationSeconds, 4);
assert.equal(orbit.frameCount, 72);
assert.equal(orbit.outputs[0].camera, "10:20");
assert.equal(orbit.outputs.at(-1).camera, "187.5:20");
});
@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>CAD Viewer headless render</title>
<style>
html,
body {
margin: 0;
min-width: 100%;
min-height: 100%;
overflow: hidden;
background: transparent;
}
</style>
<script type="importmap">
{
"imports": {
"three": "/node_modules/three/build/three.module.js",
"three/examples/": "/node_modules/three/examples/",
"gifenc": "/node_modules/gifenc/dist/gifenc.esm.js"
}
}
</script>
</head>
<body>
<script type="module" src="/common/headlessRenderEntry.js"></script>
</body>
</html>
@@ -0,0 +1,661 @@
import {
buildTopologyDisplayEdgePolylines,
buildTopologyDisplayEdgePositions
} from "./topologyDisplayEdges.js";
import {
displayModeIsWireframe
} from "./displaySettings.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
const DEFAULT_LINE_DEPTH_BIAS = 0;
// Keep topology edges slightly above coplanar faces without letting nearby solids lose the depth test.
export const TOPOLOGY_LINE_DEPTH_BIAS = 0.0045;
const TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025;
const MAX_TOPOLOGY_LINE_DEPTH_BIAS = 0.006;
const COPLANAR_TOPOLOGY_EDGE_CLASSES = new Set(["tangent", "seam"]);
const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.006;
const COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL = 0.00025;
const MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS = 0.008;
const DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES = Object.freeze(["feature"]);
const TOPOLOGY_EDGE_CLASS_ORDER = Object.freeze(["feature", "tangent", "seam", "degenerate"]);
const MAX_TOPOLOGY_LINE_STRIP_POLYLINES = 1200;
const MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES = 180000;
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS = Object.freeze({
feature: Object.freeze({ color: "#132232", opacity: 1, thickness: 1.15 }),
tangent: Object.freeze({ color: "#132232", opacity: 0.5, thickness: 1.15 }),
seam: Object.freeze({ color: "#132232", opacity: 0.85, thickness: 1.15 }),
degenerate: Object.freeze({ color: "#132232", opacity: 1, thickness: 0 })
});
export function topologyLineDepthBiasForWidth(lineWidth = 1, { visibilityClass = "" } = {}) {
const width = Number.isFinite(Number(lineWidth))
? Math.max(1, Number(lineWidth))
: 1;
const normalizedClass = String(visibilityClass || "").trim().toLowerCase();
const baseBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS
: TOPOLOGY_LINE_DEPTH_BIAS;
const biasPerExtraPixel = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL
: TOPOLOGY_LINE_DEPTH_BIAS_PER_EXTRA_PIXEL;
const maxBias = COPLANAR_TOPOLOGY_EDGE_CLASSES.has(normalizedClass)
? MAX_COPLANAR_TOPOLOGY_LINE_DEPTH_BIAS
: MAX_TOPOLOGY_LINE_DEPTH_BIAS;
return clamp(
baseBias + Math.max(0, width - 1) * biasPerExtraPixel,
baseBias,
maxBias
);
}
export function lineSegmentPositionsFromGeometry(geometry) {
const positionAttribute = geometry?.getAttribute?.("position");
const rawPositions = positionAttribute?.array;
if (!positionAttribute?.count || !rawPositions?.length) {
return null;
}
return rawPositions;
}
export function syncLineMaterialOpacity(material, opacity) {
if (!material) {
return;
}
const nextOpacity = clamp(Number(opacity) || 0, 0, 1);
const nextTransparent = nextOpacity < 0.999;
material.opacity = nextOpacity;
material.depthWrite = false;
if (material.transparent !== nextTransparent) {
material.transparent = nextTransparent;
material.needsUpdate = true;
}
}
export function syncScreenSpaceLineMaterialResolution(materials, width, height) {
const nextWidth = Math.max(1, Math.floor(Number(width) || 1));
const nextHeight = Math.max(1, Math.floor(Number(height) || 1));
for (const material of materials || []) {
material?.resolution?.set?.(nextWidth, nextHeight);
}
}
function registerLineMaterial(context = {}, material, materials = null) {
materials?.add?.(material);
context.registerScreenSpaceLineMaterial?.(material);
}
function unregisterLineMaterial(context = {}, material, materials = null) {
materials?.delete?.(material);
context.unregisterScreenSpaceLineMaterial?.(material);
}
function normalizePartIds(value) {
return (Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean);
}
function occurrenceMatchesPartIds(occurrenceId, partIds = []) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
if (!normalizedOccurrenceId || !partIds.length) {
return false;
}
return partIds.some((partId) => (
normalizedOccurrenceId === partId ||
normalizedOccurrenceId.startsWith(`${partId}.`)
));
}
function topologyEdgeRowMatchesPartIds(row, partIds = []) {
return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds);
}
function topologyEdgeRowMatchesLineStripFilters(row, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = []
} = {}) {
if (includePartIds.length && !topologyEdgeRowMatchesPartIds(row, includePartIds)) {
return false;
}
if (excludePartIds.length && topologyEdgeRowMatchesPartIds(row, excludePartIds)) {
return false;
}
if (visibilityClasses.length) {
const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature";
if (!visibilityClasses.includes(visibilityClass)) {
return false;
}
}
return true;
}
function shouldBuildTopologyLineStrips(selectorRuntime, filters = {}) {
const edgeRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : [];
if (!edgeRows.length) {
return false;
}
let polylineCount = 0;
let positionValueCount = 0;
for (const row of edgeRows) {
if (!topologyEdgeRowMatchesLineStripFilters(row, filters)) {
continue;
}
const segmentCount = Number.isFinite(Number(row?.segmentCount))
? Math.max(1, Number(row.segmentCount))
: 1;
polylineCount += 1;
positionValueCount += (segmentCount + 1) * 3;
if (
polylineCount > MAX_TOPOLOGY_LINE_STRIP_POLYLINES ||
positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES
) {
return false;
}
}
return polylineCount > 0;
}
function normalizeVisibilityClasses(value, fallback = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) {
const values = (Array.isArray(value) ? value : [value])
.map((item) => String(item || "").trim().toLowerCase())
.filter(Boolean);
return values.length ? values : fallback;
}
function normalizeColor(value, fallback) {
const normalized = String(value || "").trim();
if (!HEX_COLOR_PATTERN.test(normalized)) {
return fallback;
}
return normalized.length === 4
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`.toLowerCase()
: normalized.toLowerCase();
}
function normalizeTopologyEdgeClassSettings(edgeSettings = {}, baseTheme = {}, fallbackClasses = DEFAULT_VISIBLE_TOPOLOGY_EDGE_CLASSES) {
const classSettings = edgeSettings?.classes && typeof edgeSettings.classes === "object"
? edgeSettings.classes
: null;
const explicitVisibilityClasses = classSettings
? []
: normalizeVisibilityClasses(edgeSettings?.visibilityClasses, fallbackClasses);
const baseOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const baseThickness = Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: (baseTheme?.edgeThickness ?? 1);
const baseColor = normalizeColor(edgeSettings?.color, baseTheme?.edge || "#18181b");
if (!classSettings) {
return explicitVisibilityClasses.map((classId) => ({
classId,
color: baseColor,
opacity: baseOpacity,
thickness: baseThickness
}));
}
return TOPOLOGY_EDGE_CLASS_ORDER
.map((classId) => {
const classValue = classSettings[classId] || DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId];
const classOpacity = Number.isFinite(Number(classValue.opacity))
? clamp(Number(classValue.opacity), 0, 1)
: DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].opacity;
const classThickness = Number.isFinite(Number(classValue.thickness))
? clamp(Number(classValue.thickness), 0, 6)
: DEFAULT_TOPOLOGY_EDGE_CLASS_SETTINGS[classId].thickness;
const classColor = normalizeColor(classValue.color, baseColor);
if (classThickness <= 0 || classOpacity <= 0) {
return null;
}
return {
classId,
color: classColor,
opacity: classOpacity,
thickness: classThickness
};
})
.filter(Boolean);
}
function runtimeHasVisibilityClassRows(selectorRuntime) {
return Array.isArray(selectorRuntime?.edges) &&
selectorRuntime.edges.some((row) => String(row?.visibilityClass || "").trim());
}
export function createScreenSpaceLineSegments(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}, materials = null) {
const LineSegments2 = context.LineSegments2;
const LineSegmentsGeometry = context.LineSegmentsGeometry;
const LineMaterial = context.LineMaterial;
if (
!LineSegments2 ||
!LineSegmentsGeometry ||
!LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
!positions.length
) {
return null;
}
const lineGeometry = new LineSegmentsGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity,
lineWidth,
depthTest,
depthWrite,
depthBias
});
registerLineMaterial(context, lineMaterial, materials);
const line = new LineSegments2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
unregisterLineMaterial(context, lineMaterial, materials);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
function createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity = 1,
lineWidth = 1,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const lineMaterial = new LineMaterial({
color,
linewidth: lineWidth,
opacity,
depthTest,
depthWrite,
toneMapped: false,
worldUnits: false
});
syncLineMaterialOpacity(lineMaterial, opacity);
applyLineDepthBias(lineMaterial, depthBias);
return lineMaterial;
}
export function applyLineDepthBias(material, depthBias = DEFAULT_LINE_DEPTH_BIAS) {
const bias = clamp(Number(depthBias) || 0, 0, 0.01);
if (!material || bias <= 0) {
return material;
}
material.polygonOffset = true;
material.polygonOffsetFactor = 0;
material.polygonOffsetUnits = -clamp(Math.round(bias * 1000), 1, 10);
material.needsUpdate = true;
return material;
}
export function createScreenSpaceLineStrip(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}, materials = null) {
const Line2 = context.Line2;
const LineGeometry = context.LineGeometry;
const LineMaterial = context.LineMaterial;
if (
!Line2 ||
!LineGeometry ||
!LineMaterial ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const lineGeometry = new LineGeometry();
lineGeometry.setPositions(positions);
const lineMaterial = createScreenSpaceLineMaterial(LineMaterial, {
color,
opacity,
lineWidth,
depthTest,
depthWrite,
depthBias
});
registerLineMaterial(context, lineMaterial, materials);
const line = new Line2(lineGeometry, lineMaterial);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.beforeDispose = () => {
unregisterLineMaterial(context, lineMaterial, materials);
};
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createBasicLineStrip(context = {}, positions, {
color,
opacity = 1,
lineWidth = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const THREE = context.THREE;
if (
!THREE ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const geometry = new THREE.BufferGeometry();
const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions);
geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3));
const material = new THREE.LineBasicMaterial({
color,
linewidth: Number.isFinite(Number(lineWidth)) ? Number(lineWidth) : 1,
transparent: Number(opacity) < 0.999,
opacity: clamp(Number(opacity) || 0, 0, 1),
depthTest,
depthWrite,
toneMapped: false
});
applyLineDepthBias(material, depthBias);
const line = new THREE.Line(geometry, material);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createBasicLineSegments(context = {}, positions, {
color,
opacity = 1,
renderOrder = 3,
depthTest = true,
depthWrite = false,
depthBias = DEFAULT_LINE_DEPTH_BIAS
} = {}) {
const THREE = context.THREE;
if (
!THREE ||
!(Array.isArray(positions) || ArrayBuffer.isView(positions)) ||
positions.length < 6
) {
return null;
}
const geometry = new THREE.BufferGeometry();
const positionArray = positions instanceof Float32Array ? positions : new Float32Array(positions);
geometry.setAttribute("position", new THREE.BufferAttribute(positionArray, 3));
const material = new THREE.LineBasicMaterial({
color,
transparent: Number(opacity) < 0.999,
opacity: clamp(Number(opacity) || 0, 0, 1),
depthTest,
depthWrite,
toneMapped: false
});
applyLineDepthBias(material, depthBias);
const line = new THREE.LineSegments(geometry, material);
line.renderOrder = renderOrder;
line.frustumCulled = false;
line.userData.disposeGeometry = true;
line.userData.disposeMaterial = true;
return line;
}
export function createScreenSpaceLineSegmentsFromGeometry(context, geometry, options, materials = null) {
const positions = lineSegmentPositionsFromGeometry(geometry);
return positions ? createScreenSpaceLineSegments(context, positions, options, materials) : null;
}
export function createDisplayEdgeObject(context = {}, {
geometry,
edgeSettings,
baseTheme,
partId,
displayMode,
thickness,
wireframeEdgeColor = ""
}, materials = null) {
const THREE = context.THREE;
const wireframeMode = displayModeIsWireframe(displayMode);
const depthTest = edgeSettings?.depthTest === false ? false : true;
const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const color = wireframeMode
? (wireframeEdgeColor || edgeSettings?.color || baseTheme?.edge || "#18181b")
: (edgeSettings?.color || baseTheme?.edge || "#18181b");
if (wireframeMode && THREE) {
const opacity = Math.max(edgeOpacity, 0.9);
const material = new THREE.LineBasicMaterial({
color,
transparent: opacity < 0.999,
opacity,
depthTest: false,
depthWrite: false,
toneMapped: false
});
const line = new THREE.LineSegments(geometry, material);
line.renderOrder = 4;
line.frustumCulled = false;
line.userData.partId = partId;
return { edgeMesh: line, edgeMaterial: material };
}
const line = createScreenSpaceLineSegmentsFromGeometry(context, geometry, {
color,
opacity: edgeOpacity,
lineWidth: Number.isFinite(Number(thickness)) ? Number(thickness) : 1,
renderOrder: 3,
depthTest,
depthWrite: false,
depthBias: topologyLineDepthBiasForWidth(thickness)
}, materials);
if (!line) {
return { edgeMesh: null, edgeMaterial: null };
}
line.userData.partId = partId;
return { edgeMesh: line, edgeMaterial: line.material };
}
export function createTopologyDisplayEdgeObject(context = {}, selectorRuntime, edgeSettings, baseTheme, materials = null) {
const includePartIds = normalizePartIds(edgeSettings?.includePartIds);
const excludePartIds = normalizePartIds(edgeSettings?.excludePartIds);
const focusedPartIds = normalizePartIds(edgeSettings?.focusedPartIds);
const highlightPartIds = normalizePartIds(edgeSettings?.highlightPartIds);
const edgeClassSettings = normalizeTopologyEdgeClassSettings(edgeSettings, baseTheme)
.filter((classSetting) => (
classSetting.classId === "feature" ||
runtimeHasVisibilityClassRows(selectorRuntime)
));
const visibilityClasses = edgeClassSettings.map((setting) => setting.classId);
const edgeOpacity = Number.isFinite(Number(edgeSettings?.opacity))
? clamp(Number(edgeSettings.opacity), 0, 1)
: (baseTheme?.edgeOpacity ?? 0.84);
const highlightOpacity = Number.isFinite(Number(edgeSettings?.highlightOpacity))
? clamp(Number(edgeSettings.highlightOpacity), 0, 1)
: edgeOpacity;
const dimmedOpacity = Number.isFinite(Number(edgeSettings?.dimmedOpacity))
? clamp(Number(edgeSettings.dimmedOpacity), 0, 1)
: edgeOpacity;
const thickness = Number.isFinite(Number(edgeSettings?.thickness))
? clamp(Number(edgeSettings.thickness), 0.5, 6)
: (baseTheme?.edgeThickness ?? 1);
const baseOptions = {
color: edgeSettings?.color || baseTheme?.edge || "#18181b",
lineWidth: thickness,
renderOrder: 3,
depthTest: edgeSettings?.depthTest === false ? false : true,
depthWrite: false,
depthBias: topologyLineDepthBiasForWidth(thickness)
};
const createLine = (positions, options) => (
createScreenSpaceLineSegments(context, positions, options, materials) ||
createBasicLineSegments(context, positions, options)
);
const createLineStrip = (positions, options = {}) => {
const lineWidth = Number.isFinite(Number(options.lineWidth)) ? Number(options.lineWidth) : 1;
if (lineWidth <= 1.05) {
return (
createBasicLineStrip(context, positions, options) ||
createScreenSpaceLineStrip(context, positions, options, materials)
);
}
return (
createScreenSpaceLineStrip(context, positions, options, materials) ||
createBasicLineStrip(context, positions, options)
);
};
const createClassGroup = (children) => {
const lines = children.filter(Boolean);
if (!lines.length) {
return null;
}
if (lines.length === 1) {
return lines[0];
}
const group = context.THREE ? new context.THREE.Group() : null;
if (!group) {
return lines[0];
}
for (const line of lines) {
group.add(line);
}
return group;
};
const createLineStripGroup = (polylines, options) => {
if (!context.THREE || !Array.isArray(polylines) || !polylines.length) {
return null;
}
const positionValueCount = polylines.reduce((sum, positions) => sum + (positions?.length || 0), 0);
if (
polylines.length > MAX_TOPOLOGY_LINE_STRIP_POLYLINES ||
positionValueCount > MAX_TOPOLOGY_LINE_STRIP_POSITION_VALUES
) {
return null;
}
return createClassGroup(polylines.map((positions) => createLineStrip(positions, options)));
};
const createClassLine = (classSetting, options = {}) => {
const lineWidth = Number.isFinite(Number(options.lineWidth))
? Number(options.lineWidth)
: classSetting.thickness;
const filters = {
includePartIds: options.includePartIds || includePartIds,
excludePartIds: options.excludePartIds || excludePartIds,
visibilityClasses: [classSetting.classId]
};
const lineOptions = {
...baseOptions,
...options,
color: options.color || classSetting.color || baseOptions.color,
lineWidth,
opacity: Number.isFinite(Number(options.opacity)) ? options.opacity : classSetting.opacity,
depthBias: Number.isFinite(Number(options.depthBias))
? options.depthBias
: topologyLineDepthBiasForWidth(lineWidth, { visibilityClass: classSetting.classId })
};
const polylines = shouldBuildTopologyLineStrips(selectorRuntime, filters)
? buildTopologyDisplayEdgePolylines(selectorRuntime, filters)
: [];
const stripGroup = createLineStripGroup(polylines, lineOptions);
if (stripGroup) {
return stripGroup;
}
const positions = buildTopologyDisplayEdgePositions(selectorRuntime, {
...filters
});
if (!positions?.length) {
return null;
}
return createLine(positions, lineOptions);
};
if (highlightPartIds.length) {
const positions = buildTopologyDisplayEdgePositions(selectorRuntime, {
includePartIds: highlightPartIds,
visibilityClasses
});
if (!positions?.length) {
return null;
}
const line = createLine(positions, {
...baseOptions,
color: edgeSettings?.highlightColor || baseOptions.color,
opacity: highlightOpacity,
depthBias: Number.isFinite(Number(edgeSettings?.highlightDepthBias))
? clamp(Number(edgeSettings.highlightDepthBias), 0, 0.01)
: 0,
renderOrder: Number.isFinite(Number(edgeSettings?.highlightRenderOrder))
? Number(edgeSettings.highlightRenderOrder)
: 26
});
if (line) {
line.name = "TopologyDisplayEdgeHighlights";
line.userData.partId = "__topology_highlight__";
}
return line;
}
if (focusedPartIds.length && context.THREE) {
const group = new context.THREE.Group();
for (const classSetting of edgeClassSettings) {
const dimmedLine = createClassLine(classSetting, {
excludePartIds: focusedPartIds,
opacity: Math.min(dimmedOpacity, classSetting.opacity),
renderOrder: 3
});
const focusedLine = createClassLine(classSetting, {
includePartIds: focusedPartIds,
opacity: classSetting.opacity,
renderOrder: 4
});
if (dimmedLine) {
group.add(dimmedLine);
}
if (focusedLine) {
group.add(focusedLine);
}
}
if (!group.children.length) {
return null;
}
group.name = "TopologyDisplayEdges";
group.userData.partId = "__topology__";
return group;
}
const classLines = edgeClassSettings.map((classSetting) => createClassLine(classSetting));
const line = createClassGroup(classLines);
if (!line) {
return null;
}
line.name = "TopologyDisplayEdges";
line.userData.partId = "__topology__";
return line;
}
@@ -0,0 +1,522 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { Line2 } from "three/examples/jsm/lines/Line2.js";
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
import {
TOPOLOGY_LINE_DEPTH_BIAS,
applyLineDepthBias,
createDisplayEdgeObject,
createTopologyDisplayEdgeObject,
lineSegmentPositionsFromGeometry,
syncLineMaterialOpacity,
syncScreenSpaceLineMaterialResolution,
topologyLineDepthBiasForWidth
} from "./renderEdges.js";
function edgeContext(materials = new Set()) {
return {
THREE,
Line2,
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
LineMaterial,
registerScreenSpaceLineMaterial: (material) => materials.add(material),
unregisterScreenSpaceLineMaterial: (material) => materials.delete(material)
};
}
function twoPointGeometry() {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
0, 0, 0,
1, 0, 0
]), 3));
return geometry;
}
test("line segment extraction uses the geometry position buffer unchanged", () => {
const geometry = twoPointGeometry();
const positions = geometry.getAttribute("position").array;
assert.equal(lineSegmentPositionsFromGeometry(geometry), positions);
});
test("screen-space display edge creation registers material settings", () => {
const materials = new Set();
const { edgeMesh, edgeMaterial } = createDisplayEdgeObject(edgeContext(materials), {
geometry: twoPointGeometry(),
edgeSettings: {
color: "#123456",
opacity: 0.42,
thickness: 2
},
baseTheme: {
edge: "#000000",
edgeOpacity: 0.84
},
partId: "part-a",
displayMode: "solid",
thickness: 2
}, materials);
assert.equal(edgeMesh.userData.partId, "part-a");
assert.equal(edgeMaterial.opacity, 0.42);
assert.equal(edgeMaterial.linewidth, 2);
assert.equal(edgeMaterial.polygonOffset, true);
assert.equal(edgeMaterial.polygonOffsetFactor, 0);
assert.equal(edgeMaterial.polygonOffsetUnits, -5);
assert.equal(materials.has(edgeMaterial), true);
syncScreenSpaceLineMaterialResolution(materials, 640, 480);
assert.equal(edgeMaterial.resolution.x, 640);
assert.equal(edgeMaterial.resolution.y, 480);
});
test("screen-space display edges can render through surfaces", () => {
const { edgeMaterial } = createDisplayEdgeObject(edgeContext(), {
geometry: twoPointGeometry(),
edgeSettings: {
color: "#123456",
opacity: 0.42,
thickness: 2,
depthTest: false
},
baseTheme: {
edge: "#000000",
edgeOpacity: 0.84
},
partId: "part-a",
displayMode: "hidden_edges",
thickness: 2
});
assert.equal(edgeMaterial.depthTest, false);
});
test("wireframe display edges preserve high opacity and basic line material", () => {
const { edgeMesh, edgeMaterial } = createDisplayEdgeObject(edgeContext(), {
geometry: twoPointGeometry(),
edgeSettings: {
color: "#123456",
opacity: 0.2
},
baseTheme: {
edge: "#000000",
edgeOpacity: 0.84
},
partId: "wire-a",
displayMode: "wireframe",
wireframeEdgeColor: "#abcdef"
});
assert.equal(edgeMesh.userData.partId, "wire-a");
assert.equal(edgeMaterial.isLineBasicMaterial, true);
assert.equal(edgeMaterial.opacity, 0.9);
assert.equal(edgeMaterial.depthTest, false);
});
test("topology display edge helper builds filtered screen-space edges", () => {
const materials = new Set();
const line = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
proxy: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1])
}
},
{
color: "#654321",
opacity: 0.66,
thickness: 1.5
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(line.name, "TopologyDisplayEdges");
assert.equal(line.userData.partId, "__topology__");
assert.equal(line.material.opacity, 0.66);
assert.equal(line.material.linewidth, 1.5);
assert.equal(line.material.polygonOffset, true);
assert.equal(line.material.polygonOffsetUnits, -5);
});
test("topology display edge helper renders feature-class edges by default", () => {
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges: [
{ visibilityClass: "feature", segmentStart: 0, segmentCount: 1 },
{ visibilityClass: "tangent", segmentStart: 1, segmentCount: 1 },
{ visibilityClass: "seam", segmentStart: 2, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5]),
edgeIds: new Uint32Array([0, 1, 2])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.isLine, true);
assert.equal(line.geometry.getAttribute("position").count, 2);
});
test("topology display edge helper renders row-backed CAD edges as continuous strips", () => {
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges: [
{ visibilityClass: "feature", segmentStart: 0, segmentCount: 2 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2]),
edgeIds: new Uint32Array([0, 0])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.isLine, true);
assert.equal(line.geometry.getAttribute("position").count, 3);
});
test("topology display edge helper keeps large edge sets batched", () => {
const edgeCount = 1201;
const points = new Float32Array((edgeCount + 1) * 3);
const edgeIndices = new Uint32Array(edgeCount * 2);
const edgeIds = new Uint32Array(edgeCount);
const edges = [];
for (let index = 0; index < edgeCount; index += 1) {
points[index * 3] = index;
points[(index + 1) * 3] = index + 1;
edgeIndices[index * 2] = index;
edgeIndices[(index * 2) + 1] = index + 1;
edgeIds[index] = index;
edges.push({ visibilityClass: "feature", segmentStart: index, segmentCount: 1 });
}
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges,
proxy: {
edgePositions: points,
edgeIndices,
edgeIds
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.type, "LineSegments2");
assert.notEqual(line.isLine2, true);
assert.equal(line.geometry.attributes.instanceStart.count, edgeCount);
});
test("topology display edge helper renders enabled classified edge styles", () => {
const group = createTopologyDisplayEdgeObject(
edgeContext(),
{
edges: [
{ visibilityClass: "feature", segmentStart: 0, segmentCount: 1 },
{ visibilityClass: "tangent", segmentStart: 1, segmentCount: 1 },
{ visibilityClass: "seam", segmentStart: 2, segmentCount: 1 },
{ visibilityClass: "degenerate", segmentStart: 3, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0,
6, 0, 0,
7, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5, 6, 7]),
edgeIds: new Uint32Array([0, 1, 2, 3])
}
},
{
color: "#132232",
opacity: 0.5,
classes: {
feature: { color: "#101010", opacity: 1, thickness: 1 },
tangent: { color: "#224466", opacity: 0.32, thickness: 0.75 },
seam: { color: "#6688aa", opacity: 0.7, thickness: 1 },
degenerate: { color: "#aa8844", opacity: 1, thickness: 0 }
}
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(group.children.length, 3);
assert.equal(group.children[0].material.linewidth, 1);
assert.equal(group.children[0].material.color.getHexString(), "101010");
assert.equal(group.children[0].material.opacity, 1);
assert.equal(group.children[1].material.linewidth, 0.75);
assert.equal(group.children[1].material.color.getHexString(), "224466");
assert.equal(group.children[1].material.opacity, 0.32);
assert.equal(group.children[1].material.polygonOffsetUnits, -6);
assert.equal(group.children[2].material.linewidth, 1);
assert.equal(group.children[2].material.color.getHexString(), "6688aa");
assert.equal(group.children[2].material.opacity, 0.7);
assert.equal(group.children[2].material.polygonOffsetUnits, -6);
});
test("thick topology display edges get stronger depth separation", () => {
const line = createTopologyDisplayEdgeObject(
edgeContext(),
{
proxy: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1])
}
},
{
thickness: 6
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(topologyLineDepthBiasForWidth(1), TOPOLOGY_LINE_DEPTH_BIAS);
assert.equal(topologyLineDepthBiasForWidth(6), 0.00575);
assert.equal(topologyLineDepthBiasForWidth(1, { visibilityClass: "tangent" }), 0.006);
assert.equal(topologyLineDepthBiasForWidth(6, { visibilityClass: "seam" }), 0.00725);
assert.equal(line.material.polygonOffsetUnits, -6);
});
test("topology display edge helper splits dimmed and focused inspection edges", () => {
const materials = new Set();
const group = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
edges: [
{ occurrenceId: "part-a", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", segmentStart: 1, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3]),
edgeIds: new Uint32Array([0, 1])
}
},
{
color: "#654321",
opacity: 0.66,
dimmedOpacity: 0.035,
focusedPartIds: ["part-a"]
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(group.name, "TopologyDisplayEdges");
assert.equal(group.children.length, 2);
assert.equal(group.children[0].material.opacity, 0.035);
assert.equal(group.children[1].material.opacity, 0.66);
});
test("topology display edge helper renders highlighted node edges with theme settings", () => {
const materials = new Set();
const line = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
edges: [
{ occurrenceId: "part-a", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", segmentStart: 1, segmentCount: 1 },
{ occurrenceId: "part-a.child", segmentStart: 2, segmentCount: 1 }
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5]),
edgeIds: new Uint32Array([0, 1, 2])
}
},
{
color: "#222222",
opacity: 0.48,
thickness: 2.25,
highlightPartIds: ["part-a"],
highlightColor: "#4f9dff"
},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(line.name, "TopologyDisplayEdgeHighlights");
assert.equal(line.userData.partId, "__topology_highlight__");
assert.equal(line.material.color.getHexString(), "4f9dff");
assert.equal(line.material.opacity, 0.48);
assert.equal(line.material.linewidth, 2.25);
assert.equal(line.renderOrder, 26);
assert.doesNotMatch(line.material.customProgramCacheKey(), /lineDepthBias/);
assert.equal(line.geometry.attributes.instanceStart.count, 2);
assert.equal(line.geometry.attributes.instanceEnd.count, 2);
});
test("line depth bias uses fixed-function polygon offset", () => {
const material = new THREE.LineBasicMaterial();
applyLineDepthBias(material, 0.0003);
const shader = {
uniforms: {},
vertexShader: "void main() {\n#include <logdepthbuf_vertex>\n}"
};
material.onBeforeCompile(shader, {});
assert.equal(material.polygonOffset, true);
assert.equal(material.polygonOffsetFactor, 0);
assert.equal(material.polygonOffsetUnits, -1);
assert.deepEqual(shader.uniforms, {});
assert.doesNotMatch(shader.vertexShader, /lineDepthBias/);
});
test("topology display edge helper renders raw CAD edge segments as one object", () => {
const materials = new Set();
const line = createTopologyDisplayEdgeObject(
edgeContext(materials),
{
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
},
materials
);
assert.equal(line.name, "TopologyDisplayEdges");
assert.equal(line.userData.partId, "__topology__");
assert.equal(materials.size, 1);
});
test("topology display edge helper falls back to basic line strips", () => {
const context = edgeContext();
delete context.Line2;
delete context.LineGeometry;
delete context.LineSegments2;
delete context.LineSegmentsGeometry;
const line = createTopologyDisplayEdgeObject(
context,
{
edges: [
{
flags: 0,
segmentStart: 0,
segmentCount: 2
}
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2]),
edgeIds: new Uint32Array([0, 0])
}
},
{},
{
edge: "#000000",
edgeOpacity: 0.84,
edgeThickness: 1
}
);
assert.equal(line.isLine, true);
assert.equal(line.geometry.getAttribute("position").count, 3);
});
test("line material opacity helper clamps transparency consistently", () => {
const material = new THREE.LineBasicMaterial({ opacity: 1, transparent: false });
syncLineMaterialOpacity(material, 0.25);
assert.equal(material.opacity, 0.25);
assert.equal(material.transparent, true);
syncLineMaterialOpacity(material, 5);
assert.equal(material.opacity, 1);
assert.equal(material.transparent, false);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
buildModel
} from "./cadScene.js";
import {
modelOptionsForRenderJob,
projectedVisibleGeometryFrame,
renderJobContext,
renderMeshJob
} from "./renderMeshScene.js";
function twoPartMeshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
2, 0, 0,
3, 0, 0,
2, 1, 0
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
bounds: {
min: [0, 0, 0],
max: [3, 1, 0]
},
parts: [
{
id: "left",
name: "Left",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: { min: [0, 0, 0], max: [1, 1, 0] }
},
{
id: "right",
name: "Right",
vertexOffset: 3,
vertexCount: 3,
triangleOffset: 1,
triangleCount: 1,
bounds: { min: [2, 0, 0], max: [3, 1, 0] }
}
]
};
}
test("renderMeshJob list capture uses buildModel selection", async () => {
const result = await renderMeshJob(twoPartMeshData(), {
mode: "list",
selection: {
focus: ["right"]
}
});
assert.equal(result.ok, true);
assert.equal(result.mode, "list");
assert.deepEqual(result.parts.map((part) => part.id), ["right"]);
assert.deepEqual(result.bounds, {
min: [2, 0, 0],
max: [3, 1, 0]
});
});
test("render view focus preserves full assembly while hide still filters", () => {
const focusedContext = renderJobContext(twoPartMeshData(), {
mode: "view",
selection: {
focus: ["right"]
}
});
const focused = buildModel(
THREE,
twoPartMeshData(),
modelOptionsForRenderJob(focusedContext, {
mode: "view",
selection: {
focus: ["right"]
}
})
);
assert.deepEqual(focused.displayRecords.map((record) => record.partId), ["left", "right"]);
assert.deepEqual(focused.bounds, {
min: [0, 0, 0],
max: [3, 1, 0]
});
focused.dispose();
const hiddenContext = renderJobContext(twoPartMeshData(), {
mode: "view",
selection: {
hide: ["left"]
}
});
const hidden = buildModel(
THREE,
twoPartMeshData(),
modelOptionsForRenderJob(hiddenContext, {
mode: "view",
selection: {
hide: ["left"]
}
})
);
assert.deepEqual(hidden.displayRecords.map((record) => record.partId), ["right"]);
assert.deepEqual(hidden.bounds, {
min: [2, 0, 0],
max: [3, 1, 0]
});
hidden.dispose();
});
test("projectedVisibleGeometryFrame fits actual vertices instead of sparse bounds", () => {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
-1, -1, 0,
1, -1, 0,
-1, 1, 0,
1, 1, 0
]), 3));
const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial());
mesh.updateWorldMatrix(true, false);
const camera = new THREE.OrthographicCamera(-10, 10, 10, -10, 0.01, 100);
camera.position.set(0, 0, 10);
camera.up.set(0, 1, 0);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld(true);
const frame = projectedVisibleGeometryFrame([{ mesh }], camera);
assert.equal(frame.count, 4);
assert.equal(frame.centerX, 0);
assert.equal(frame.centerY, 0);
assert.equal(frame.spanX, 2);
assert.equal(frame.spanY, 2);
});
@@ -0,0 +1,223 @@
import {
fitCameraToModel
} from "./cadScene.js";
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function addLight(scene, light, enabled = true) {
light.visible = enabled;
scene.add(light);
return light;
}
function applyViewportLighting(THREE, scene, theme = {}) {
const lighting = theme.lighting || {};
addLight(
scene,
new THREE.HemisphereLight(
lighting.hemisphere?.skyColor || "#ffffff",
lighting.hemisphere?.groundColor || "#101014",
toFiniteNumber(lighting.hemisphere?.intensity, 0)
),
lighting.hemisphere?.enabled === true
);
addLight(
scene,
new THREE.AmbientLight(
lighting.ambient?.color || "#ffffff",
toFiniteNumber(lighting.ambient?.intensity, 0.2)
),
lighting.ambient?.enabled !== false
);
const directionalLights = Array.isArray(lighting.directionalLights)
? lighting.directionalLights
: [lighting.directional || {}];
for (const config of directionalLights) {
const light = new THREE.DirectionalLight(
config.color || "#ffffff",
toFiniteNumber(config.intensity, 1)
);
const position = config.position || {};
light.position.set(
toFiniteNumber(position.x, 160),
toFiniteNumber(position.y, -140),
toFiniteNumber(position.z, 240)
);
light.castShadow = config.castShadow === true;
addLight(scene, light, config.enabled !== false);
}
}
function resolveViewportSize(hostElement, canvas, fallbackWidth = 1400, fallbackHeight = 900) {
const rect = hostElement?.getBoundingClientRect?.();
const width = Math.max(1, Math.floor(rect?.width || hostElement?.clientWidth || canvas?.clientWidth || fallbackWidth));
const height = Math.max(1, Math.floor(rect?.height || hostElement?.clientHeight || canvas?.clientHeight || fallbackHeight));
return { width, height };
}
function applyBackground(THREE, renderer, scene, theme = {}, { alpha = false } = {}) {
const background = theme.background || {};
if (alpha || background.type === "transparent") {
scene.background = null;
renderer.setClearColor(new THREE.Color("#000000"), 0);
return;
}
const color = background.solidColor || background.color || "#ffffff";
scene.background = new THREE.Color(color);
renderer.setClearColor(new THREE.Color(color), 1);
}
export function renderModel(THREE, model, options = {}) {
if (!THREE) {
throw new Error("renderModel requires THREE");
}
if (!model?.root) {
throw new Error("renderModel requires a model returned by buildModel");
}
const canvas = options.canvas || undefined;
const hostElement = options.hostElement || options.container || canvas?.parentElement || null;
const theme = options.theme || options.themeSettings || {};
const alpha = options.alpha === true;
const renderer = options.renderer || new THREE.WebGLRenderer({
canvas,
alpha,
antialias: options.antialias !== false,
powerPreference: options.powerPreference || "high-performance",
preserveDrawingBuffer: options.preserveDrawingBuffer === true,
logarithmicDepthBuffer: options.logarithmicDepthBuffer !== false
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = options.shadows !== false;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = options.scene || new THREE.Scene();
applyBackground(THREE, renderer, scene, theme, { alpha });
applyViewportLighting(THREE, scene, theme);
scene.add(model.root);
const camera = options.camera || new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 10000);
const frame = {
direction: options.direction || options.view?.direction || [1, -1, 0.8],
up: options.up || options.view?.up || [0, 0, 1],
padding: toFiniteNumber(options.padding, 0.12),
scale: options.scale || options.sceneScale || "cad",
lockedHalfHeightScale: options.lockedHalfHeightScale || null
};
let disposed = false;
let rafId = 0;
let lastRenderTime = typeof performance !== "undefined" ? performance.now() : 0;
const resize = () => {
const { width, height } = resolveViewportSize(hostElement, renderer.domElement, options.width, options.height);
renderer.setPixelRatio(clamp(toFiniteNumber(options.pixelRatio, globalThis.devicePixelRatio || 1), 1, options.maxPixelRatio || 2));
renderer.setSize(width, height, false);
model.runtime?.syncScreenSpaceLineMaterials?.(width, height);
const fit = fitCameraToModel(THREE, camera, model.bounds, {
direction: frame.direction,
up: frame.up,
width,
height,
padding: frame.padding,
scale: frame.scale
});
if (frame.lockedHalfHeightScale) {
fitCameraToModel(THREE, camera, model.bounds, {
direction: frame.direction,
lockedHalfHeight: fit.halfHeight * toFiniteNumber(frame.lockedHalfHeightScale, 1),
up: frame.up,
width,
height,
padding: frame.padding,
scale: frame.scale
});
}
return { width, height, fit };
};
const render = () => {
renderer.render(scene, camera);
};
const step = () => {
if (disposed) {
return;
}
const now = typeof performance !== "undefined" ? performance.now() : lastRenderTime;
const deltaSeconds = Math.min(Math.max((now - lastRenderTime) / 1000, 0), 0.1);
lastRenderTime = now;
options.beforeRender?.({ deltaSeconds, viewport: api });
render();
rafId = globalThis.requestAnimationFrame?.(step) || 0;
};
const start = () => {
if (!rafId) {
lastRenderTime = typeof performance !== "undefined" ? performance.now() : 0;
rafId = globalThis.requestAnimationFrame?.(step) || 0;
}
};
const stop = () => {
if (rafId) {
globalThis.cancelAnimationFrame?.(rafId);
rafId = 0;
}
};
const resizeObserver = options.autoResize === false || typeof ResizeObserver !== "function" || !hostElement
? null
: new ResizeObserver(() => {
resize();
if (options.autoRender === false) {
render();
}
});
resizeObserver?.observe(hostElement);
const api = {
THREE,
model,
renderer,
scene,
camera,
ready: Promise.resolve(),
resize,
render,
start,
stop,
capturePng() {
render();
return renderer.domElement.toDataURL("image/png");
},
dispose() {
if (disposed) {
return;
}
disposed = true;
stop();
resizeObserver?.disconnect();
scene.remove(model.root);
if (options.disposeModel !== false) {
model.dispose?.();
}
if (!options.renderer) {
renderer.dispose();
}
}
};
resize();
if (options.autoStart === true) {
start();
} else if (options.autoRender !== false) {
render();
}
return api;
}
@@ -0,0 +1,573 @@
import * as THREE from "three";
import {
RENDER_CAMERA_PRESETS,
TOP_VIEW_UP,
WORLD_UP,
normalizeCameraZoom,
resolveCameraSnapshot,
resolveCameraView
} from "./camera.js";
import {
cloneThemeSettings,
DEFAULT_FLOOR_GRID_SETTINGS,
getEnvironmentPresetById,
MAX_FLOOR_GRID_DENSITY,
MIN_FLOOR_GRID_DENSITY,
THEME_FLOOR_MODES,
normalizeThemeSettings,
resolveThemeSettingsForColorMode
} from "./themeSettings.js";
import {
createCadWebGlRenderer
} from "./webglRenderer.js";
export const RENDER_SCENE_SCALE = Object.freeze({
CAD: "cad",
URDF: "urdf"
});
export { TOP_VIEW_UP, WORLD_UP };
export const RENDER_VIEW_PRESETS = RENDER_CAMERA_PRESETS;
export const SHARED_RENDER_OPTION_KEYS = Object.freeze([
"themeSettings",
"display",
"camera",
"framing",
"sceneScale",
"clip",
"selection",
"floor",
"background",
"lighting",
"outputSize",
"renderScale"
]);
/**
* SharedRenderOptions is the policy-free shape consumed by lower-level render
* helpers after snapshot or viewer code has already chosen defaults.
*
* @typedef {Object} SharedRenderOptions
* @property {Object|null} themeSettings Explicit, normalized appearance settings.
* @property {Object|null} display Explicit display settings.
* @property {Object|null} camera Explicit camera/view state.
* @property {Object|null} framing Explicit framing settings.
* @property {string|null} sceneScale Explicit CAD/URDF scene scale.
* @property {Object|null} clip Explicit clip settings.
* @property {Object|null} selection Explicit part/selection settings.
* @property {Object|null} floor Explicit floor settings.
* @property {Object|null} background Explicit background settings.
* @property {Object|null} lighting Explicit lighting settings.
* @property {Object|null} outputSize Explicit output width/height.
* @property {number|null} renderScale Explicit render scale.
*/
export function createSharedRenderOptions(explicitOptions = {}) {
const result = {};
for (const key of SHARED_RENDER_OPTION_KEYS) {
result[key] = Object.prototype.hasOwnProperty.call(explicitOptions, key)
? explicitOptions[key]
: null;
}
return result;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
export function normalizeRenderSceneScale(value) {
return value === RENDER_SCENE_SCALE.URDF ? RENDER_SCENE_SCALE.URDF : RENDER_SCENE_SCALE.CAD;
}
export function renderSceneScaleSettings(value, settingsByScale) {
return settingsByScale[normalizeRenderSceneScale(value)];
}
export function inferRenderSceneScale({
explicit = "",
kind = "",
parts = []
} = {}) {
const normalizedExplicit = String(explicit || "").trim().toLowerCase();
if (normalizedExplicit) {
return normalizeRenderSceneScale(normalizedExplicit);
}
const normalizedKind = String(kind || "").trim().toLowerCase();
if (normalizedKind === "urdf" || normalizedKind === "srdf" || normalizedKind === "sdf") {
return RENDER_SCENE_SCALE.URDF;
}
return (Array.isArray(parts) ? parts : []).some((part) => String(part?.linkName || "").trim())
? RENDER_SCENE_SCALE.URDF
: RENDER_SCENE_SCALE.CAD;
}
export function resolveAppearanceJobConfig(job = {}, { defaultThemeId = "workbench" } = {}) {
if (typeof job.appearance === "string") {
return {
themeId: job.appearance,
settings: null
};
}
if (job.appearance && typeof job.appearance === "object" && !Array.isArray(job.appearance)) {
return {
themeId: defaultThemeId,
settings: job.appearance
};
}
return {
themeId: defaultThemeId,
settings: null
};
}
export function resolveAppearanceSettings(job = {}, { defaultThemeId = "workbench" } = {}) {
const appearance = resolveAppearanceJobConfig(job, { defaultThemeId });
const themeSettings = cloneThemeSettings(appearance.themeId || defaultThemeId);
const normalized = normalizeThemeSettings(appearance.settings || themeSettings);
return typeof job.appearance === "string"
? resolveThemeSettingsForColorMode(normalized, { prefersDark: false })
: normalized;
}
export function resolveRenderView(camera = "iso", viewPresets = RENDER_VIEW_PRESETS, {
strict = false
} = {}) {
return resolveCameraView(camera, {
presets: viewPresets,
strict
});
}
export function boundsFromVertices(vertices) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let index = 0; index + 2 < (vertices?.length || 0); index += 3) {
const x = Number(vertices[index]);
const y = Number(vertices[index + 1]);
const z = Number(vertices[index + 2]);
if (![x, y, z].every(Number.isFinite)) {
continue;
}
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
if (!min.every(Number.isFinite) || !max.every(Number.isFinite)) {
return { min: [0, 0, 0], max: [1, 1, 1] };
}
return { min, max };
}
export function centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
const center = new THREE.Vector3(
(toFiniteNumber(min[0]) + toFiniteNumber(max[0], 1)) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1], 1)) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2], 1)) / 2
);
const size = new THREE.Vector3(
Math.max(toFiniteNumber(max[0], 1) - toFiniteNumber(min[0]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[1], 1) - toFiniteNumber(min[1]), settings.minBoundsSpan),
Math.max(toFiniteNumber(max[2], 1) - toFiniteNumber(min[2]), settings.minBoundsSpan)
);
return {
center,
radius: Math.max(size.length() / 2, settings.minModelRadius),
size
};
}
export function colorTextureFromBackground(background, width, height) {
if (background.type === "solid") {
return new THREE.Color(background.solidColor);
}
const canvas = document.createElement("canvas");
canvas.width = Math.max(2, width);
canvas.height = Math.max(2, height);
const context = canvas.getContext("2d");
if (background.type === "radial") {
const gradient = context.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.max(canvas.width, canvas.height) / 1.35
);
gradient.addColorStop(0, background.radialInner);
gradient.addColorStop(1, background.radialOuter);
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
} else {
const angle = ((toFiniteNumber(background.linearAngle, 135) - 90) * Math.PI) / 180;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const length = Math.hypot(canvas.width, canvas.height);
const dx = Math.cos(angle) * length / 2;
const dy = Math.sin(angle) * length / 2;
const gradient = context.createLinearGradient(cx - dx, cy - dy, cx + dx, cy + dy);
gradient.addColorStop(0, background.linearStart);
gradient.addColorStop(1, background.linearEnd);
context.fillStyle = gradient;
context.fillRect(0, 0, canvas.width, canvas.height);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
export async function applyEnvironment(scene, themeSettings, warnings = []) {
const environment = themeSettings.environment || {};
if (!environment.enabled) {
scene.environment = null;
return;
}
const preset = getEnvironmentPresetById(environment.presetId);
const textureUrl = String(preset?.url || "").trim();
if (!textureUrl) {
return;
}
try {
const loader = new THREE.TextureLoader();
if (typeof loader.setCrossOrigin === "function") {
loader.setCrossOrigin("anonymous");
}
const texture = await loader.loadAsync(textureUrl);
texture.mapping = THREE.EquirectangularReflectionMapping;
texture.colorSpace = THREE.SRGBColorSpace;
scene.environment = texture;
if (scene.environmentRotation?.set) {
scene.environmentRotation.set(0, toFiniteNumber(environment.rotationY), 0);
}
if (environment.useAsBackground) {
scene.background = texture;
}
} catch {
warnings.push(`Environment preset unavailable: ${preset?.label || environment.presetId || "default"}`);
}
}
export function applyLighting(scene, themeSettings) {
const lighting = themeSettings.lighting || {};
const addIfEnabled = (light, enabled) => {
light.visible = enabled;
scene.add(light);
};
addIfEnabled(
new THREE.HemisphereLight(
lighting.hemisphere?.skyColor || "#ffffff",
lighting.hemisphere?.groundColor || "#101014",
toFiniteNumber(lighting.hemisphere?.intensity, 1)
),
lighting.hemisphere?.enabled !== false
);
addIfEnabled(
new THREE.AmbientLight(lighting.ambient?.color || "#ffffff", toFiniteNumber(lighting.ambient?.intensity, 0.2)),
lighting.ambient?.enabled !== false
);
const directional = new THREE.DirectionalLight(
lighting.directional?.color || "#ffffff",
toFiniteNumber(lighting.directional?.intensity, 1)
);
directional.position.set(
toFiniteNumber(lighting.directional?.position?.x, 160),
toFiniteNumber(lighting.directional?.position?.y, -140),
toFiniteNumber(lighting.directional?.position?.z, 240)
);
directional.castShadow = true;
addIfEnabled(directional, lighting.directional?.enabled !== false);
const spot = new THREE.SpotLight(
lighting.spot?.color || "#ffffff",
toFiniteNumber(lighting.spot?.intensity, 0),
toFiniteNumber(lighting.spot?.distance, 0),
toFiniteNumber(lighting.spot?.angle, Math.PI / 6)
);
spot.position.set(
toFiniteNumber(lighting.spot?.position?.x, 160),
toFiniteNumber(lighting.spot?.position?.y, -120),
toFiniteNumber(lighting.spot?.position?.z, 140)
);
addIfEnabled(spot, lighting.spot?.enabled === true);
scene.add(spot.target);
const point = new THREE.PointLight(
lighting.point?.color || "#ffffff",
toFiniteNumber(lighting.point?.intensity, 0),
toFiniteNumber(lighting.point?.distance, 0)
);
point.position.set(
toFiniteNumber(lighting.point?.position?.x, -120),
toFiniteNumber(lighting.point?.position?.y, 80),
toFiniteNumber(lighting.point?.position?.z, 140)
);
addIfEnabled(point, lighting.point?.enabled === true);
}
export function addFloor(scene, bounds, themeSettings, sceneScale, settingsByScale) {
const floor = themeSettings.floor || {};
const mode = floor.mode || THEME_FLOOR_MODES.STAGE;
const floorEnabled = floor.enabled === true || (
!Object.hasOwn(floor, "enabled")
&& mode !== THEME_FLOOR_MODES.NONE
&& mode !== THEME_FLOOR_MODES.GRID
);
const gridSettings = floor.grid && typeof floor.grid === "object" && !Array.isArray(floor.grid)
? floor.grid
: {};
const gridEnabled = gridSettings.enabled === true || mode === THEME_FLOOR_MODES.GRID;
if (!floorEnabled && !gridEnabled) {
return;
}
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const { center, radius } = centerAndRadiusFromBounds(bounds, sceneScale, settingsByScale);
const minZ = Array.isArray(bounds?.min) ? toFiniteNumber(bounds.min[2]) : 0;
const gridSize = Math.max(radius * 3, settings.minFloorSize);
if (gridEnabled) {
const gridDensity = clamp(
toFiniteNumber(gridSettings.density ?? floor.gridDensity, DEFAULT_FLOOR_GRID_SETTINGS.gridDensity),
MIN_FLOOR_GRID_DENSITY,
MAX_FLOOR_GRID_DENSITY
);
const grid = new THREE.GridHelper(
gridSize,
Math.max(8, Math.round(28 * gridDensity)),
gridSettings.centerColor || floor.gridCenterColor || floor.color || DEFAULT_FLOOR_GRID_SETTINGS.gridCenterColor,
gridSettings.cellColor || floor.gridCellColor || floor.color || DEFAULT_FLOOR_GRID_SETTINGS.gridCellColor
);
const materials = Array.isArray(grid.material) ? grid.material : [grid.material];
for (const material of materials) {
material.transparent = true;
material.opacity = clamp(
toFiniteNumber(gridSettings.opacity ?? floor.gridOpacity, DEFAULT_FLOOR_GRID_SETTINGS.gridOpacity),
0,
1
);
material.depthWrite = false;
material.toneMapped = false;
}
grid.rotation.x = Math.PI / 2;
grid.position.set(center.x, center.y, minZ - 0.02);
scene.add(grid);
}
if (!floorEnabled) {
return;
}
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(gridSize, gridSize),
new THREE.ShadowMaterial({
color: floor.color || "#f1f5f9",
opacity: clamp(toFiniteNumber(floor.shadowOpacity, 0.24), 0, 1)
})
);
plane.rotation.x = 0;
plane.position.set(center.x, center.y, minZ - 0.03);
plane.receiveShadow = true;
scene.add(plane);
}
export function boundsCorners(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : [1, 1, 1];
return [
[min[0], min[1], min[2]],
[min[0], min[1], max[2]],
[min[0], max[1], min[2]],
[min[0], max[1], max[2]],
[max[0], min[1], min[2]],
[max[0], min[1], max[2]],
[max[0], max[1], min[2]],
[max[0], max[1], max[2]]
].map((corner) => new THREE.Vector3(
toFiniteNumber(corner[0]),
toFiniteNumber(corner[1]),
toFiniteNumber(corner[2])
));
}
export function framePadding(job = {}) {
const rawPadding = job.render?.padding ?? job.render?.paddingPercent ?? job.padding ?? job.paddingPercent;
return clamp(toFiniteNumber(rawPadding, 0.04), 0, 0.15);
}
export function frameHalfHeightForView(view, bounds, width, height, padding, sceneScale, settingsByScale) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedView = resolveRenderView(view, RENDER_VIEW_PRESETS, { strict: false });
const direction = new THREE.Vector3(...resolvedView.direction).normalize();
const up = new THREE.Vector3(...resolvedView.up).normalize();
const right = new THREE.Vector3().crossVectors(direction, up).normalize();
const screenUp = new THREE.Vector3().crossVectors(right, direction).normalize();
const corners = boundsCorners(bounds);
const xs = corners.map((corner) => corner.dot(right));
const ys = corners.map((corner) => corner.dot(screenUp));
const spanX = Math.max(Math.max(...xs) - Math.min(...xs), settings.minBoundsSpan);
const spanY = Math.max(Math.max(...ys) - Math.min(...ys), settings.minBoundsSpan);
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const safeContentScale = Math.max(1 - (padding * 2), 0.1);
return Math.max(
spanY / (2 * safeContentScale),
spanX / (2 * aspect * safeContentScale),
settings.minBoundsSpan / 2
) / normalizeCameraZoom(resolvedView.zoom, 1);
}
export function fitOrthographicCamera(camera, view, bounds, width, height, {
lockedHalfHeight = null,
padding = 0.12,
sceneScale = RENDER_SCENE_SCALE.CAD,
settingsByScale
} = {}) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedCamera = resolveCameraSnapshot(view, bounds, {
sceneScale,
settingsByScale,
strict: false
});
const target = new THREE.Vector3(...resolvedCamera.target);
camera.position.set(...resolvedCamera.position);
camera.up.set(...resolvedCamera.up);
camera.lookAt(target);
const aspect = Math.max(width / Math.max(height, 1), 0.01);
const halfHeight = lockedHalfHeight || frameHalfHeightForView(resolvedCamera.view, bounds, width, height, padding, sceneScale, settingsByScale);
camera.top = halfHeight;
camera.bottom = -halfHeight;
camera.left = -halfHeight * aspect;
camera.right = halfHeight * aspect;
camera.near = 0.01;
camera.far = Math.max(resolvedCamera.radius * 12, settings.minCameraFar);
camera.updateProjectionMatrix();
return resolvedCamera;
}
export function fitPerspectiveCamera(camera, cameraSpec, bounds, width, height, {
sceneScale = RENDER_SCENE_SCALE.CAD,
settingsByScale,
strict = true
} = {}) {
const settings = renderSceneScaleSettings(sceneScale, settingsByScale);
const resolvedCamera = resolveCameraSnapshot(cameraSpec, bounds, {
sceneScale,
settingsByScale,
strict
});
camera.aspect = Math.max(width / Math.max(height, 1), 0.01);
camera.position.set(...resolvedCamera.position);
camera.up.set(...resolvedCamera.up);
camera.zoom = normalizeCameraZoom(resolvedCamera.zoom, 1);
camera.near = Math.max(resolvedCamera.radius / 1200, 0.01);
camera.far = Math.max(resolvedCamera.radius * 600, settings.minCameraFar, 2000);
camera.lookAt(new THREE.Vector3(...resolvedCamera.target));
camera.updateProjectionMatrix();
return resolvedCamera;
}
export function lockedFrameHalfHeight(outputs, bounds, width, height, job, sceneScale, settingsByScale) {
const padding = framePadding(job);
return Math.max(
...outputs.map((output) => {
const outputWidth = toFiniteNumber(output.width, width);
const outputHeight = toFiniteNumber(output.height, height);
return frameHalfHeightForView(resolveRenderView(output.camera || job.camera || "iso"), bounds, outputWidth, outputHeight, padding, sceneScale, settingsByScale);
}),
renderSceneScaleSettings(sceneScale, settingsByScale).minBoundsSpan / 2
);
}
export function outputSize(output, job) {
return {
width: Math.max(1, Math.floor(toFiniteNumber(output.width, job.width || 1400))),
height: Math.max(1, Math.floor(toFiniteNumber(output.height, job.height || 900)))
};
}
export function configurePngRenderer(width, height, job, themeSettings, {
defaultRenderScale = 1
} = {}) {
const renderer = createCadWebGlRenderer(THREE, {
preserveDrawingBuffer: true
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = Math.max(toFiniteNumber(themeSettings.lighting?.toneMappingExposure, 1), 0.05);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(clamp(toFiniteNumber(job.render?.renderScale, job.renderScale || defaultRenderScale), 1, 3));
renderer.setSize(width, height, false);
document.body.innerHTML = "";
document.body.appendChild(renderer.domElement);
return renderer;
}
export function dataUrlFromRenderer(renderer, mime = "image/png") {
return renderer.domElement.toDataURL(mime);
}
export function shouldBurnInViewLabels(job = {}) {
return typeof job.render?.viewLabels === "boolean"
? job.render.viewLabels
: typeof job.viewLabels === "boolean"
? job.viewLabels
: false;
}
export function drawBurnedInLabel(context, label, width, height, {
corner = "top-left",
fill = "#111827",
background = "rgba(255, 255, 255, 0.9)",
border = "rgba(17, 24, 39, 0.42)"
} = {}) {
const text = String(label || "").trim();
if (!text) {
return;
}
const safeWidth = Math.max(width, 1);
const safeHeight = Math.max(height, 1);
const fontSize = Math.max(18, Math.min(Math.round(safeWidth * 0.018), 32));
const padX = Math.round(fontSize * 0.72);
const padY = Math.round(fontSize * 0.42);
context.save();
context.font = `700 ${fontSize}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`;
context.textBaseline = "top";
const metrics = context.measureText(text);
const boxWidth = Math.ceil(metrics.width + padX * 2);
const boxHeight = Math.ceil(fontSize + padY * 2);
const margin = Math.max(18, Math.round(Math.min(safeWidth, safeHeight) * 0.024));
const x = corner.includes("right") ? safeWidth - margin - boxWidth : margin;
const y = corner.includes("bottom") ? safeHeight - margin - boxHeight : margin;
context.fillStyle = background;
context.strokeStyle = border;
context.lineWidth = Math.max(1, Math.round(fontSize * 0.06));
context.beginPath();
context.roundRect(x, y, boxWidth, boxHeight, Math.round(fontSize * 0.28));
context.fill();
context.stroke();
context.fillStyle = fill;
context.fillText(text, x + padX, y + padY);
context.restore();
}
export function rendererDataUrlWithOptionalLabel(renderer, label, job) {
if (!shouldBurnInViewLabels(job)) {
return dataUrlFromRenderer(renderer);
}
const source = renderer.domElement;
const canvas = document.createElement("canvas");
canvas.width = source.width;
canvas.height = source.height;
const context = canvas.getContext("2d");
context.drawImage(source, 0, 0);
drawBurnedInLabel(context, label, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
}
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import {
cloneThemeSettings,
normalizeThemeSettings,
resolveThemeSettingsForColorMode
} from "./themeSettings.js";
import {
addFloor,
createSharedRenderOptions,
RENDER_SCENE_SCALE,
boundsFromVertices,
centerAndRadiusFromBounds,
fitOrthographicCamera,
frameHalfHeightForView,
framePadding,
inferRenderSceneScale,
outputSize,
resolveRenderView,
resolveAppearanceJobConfig,
resolveAppearanceSettings
} from "./renderOptions.js";
const SCALE_SETTINGS = Object.freeze({
[RENDER_SCENE_SCALE.CAD]: Object.freeze({
minBoundsSpan: 1,
minModelRadius: 1,
minFloorSize: 100,
minCameraDistance: 10,
minCameraFar: 1000
}),
[RENDER_SCENE_SCALE.URDF]: Object.freeze({
minBoundsSpan: 0.05,
minModelRadius: 0.05,
minFloorSize: 0.05,
minCameraDistance: 0.5,
minCameraFar: 10
})
});
function assertClose(actual, expected, epsilon = 1e-6) {
assert.ok(Math.abs(actual - expected) <= epsilon, `${actual} !== ${expected}`);
}
test("shared render options preserve explicit caller-owned values without defaults", () => {
const options = createSharedRenderOptions({
displayMode: "wireframe",
background: false,
renderScale: 0
});
assert.equal(options.themeSettings, null);
assert.equal(options.display, null);
assert.equal(Object.hasOwn(options, "displayMode"), false);
assert.equal(options.background, false);
assert.equal(options.renderScale, 0);
});
test("appearance resolution uses saved theme ids or direct appearance settings", () => {
assert.deepEqual(
resolveAppearanceSettings({}, { defaultThemeId: "workbench" }),
normalizeThemeSettings(cloneThemeSettings("workbench"))
);
assert.deepEqual(
resolveAppearanceSettings({ appearance: "dark" }, { defaultThemeId: "workbench" }),
resolveThemeSettingsForColorMode(cloneThemeSettings("dark"), { prefersDark: false })
);
assert.deepEqual(
resolveAppearanceJobConfig({
appearance: {
materials: { defaultColor: "#123456" }
}
}, { defaultThemeId: "workbench" }),
{
themeId: "workbench",
settings: { materials: { defaultColor: "#123456" } }
}
);
});
test("view presets and azimuth/elevation camera parsing remain stable", () => {
const top = resolveRenderView("top");
assert.equal(top.name, "top");
assert.deepEqual(top.direction, [0, 0, 1]);
assert.deepEqual(top.up, [0, 1, 0]);
const custom = resolveRenderView("45:30");
assert.equal(custom.name, "45:30");
assertClose(custom.direction[0], Math.SQRT1_2 * Math.cos(Math.PI / 6));
assertClose(custom.direction[1], -Math.SQRT1_2 * Math.cos(Math.PI / 6));
assertClose(custom.direction[2], 0.5);
});
test("scene scale inference, bounds, and camera framing are policy-free helpers", () => {
assert.equal(inferRenderSceneScale({ explicit: "urdf" }), RENDER_SCENE_SCALE.URDF);
assert.equal(inferRenderSceneScale({ kind: "sdf" }), RENDER_SCENE_SCALE.URDF);
assert.equal(inferRenderSceneScale({ parts: [{ linkName: "base_link" }] }), RENDER_SCENE_SCALE.URDF);
assert.equal(inferRenderSceneScale({ kind: "glb", parts: [] }), RENDER_SCENE_SCALE.CAD);
const bounds = boundsFromVertices(new Float32Array([0, 0, 0, 2, 4, 6]));
assert.deepEqual(bounds, { min: [0, 0, 0], max: [2, 4, 6] });
const { center, radius } = centerAndRadiusFromBounds(bounds, RENDER_SCENE_SCALE.CAD, SCALE_SETTINGS);
assert.deepEqual(center.toArray(), [1, 2, 3]);
assertClose(radius, Math.sqrt(56) / 2);
const view = resolveRenderView("iso");
const halfHeight = frameHalfHeightForView(view, bounds, 800, 600, 0.12, RENDER_SCENE_SCALE.CAD, SCALE_SETTINGS);
assert.ok(halfHeight > 0);
const camera = new THREE.OrthographicCamera();
fitOrthographicCamera(camera, view, bounds, 800, 600, {
padding: 0.12,
sceneScale: RENDER_SCENE_SCALE.CAD,
settingsByScale: SCALE_SETTINGS
});
assertClose(camera.top, halfHeight);
assertClose(camera.bottom, -halfHeight);
assertClose(camera.left, -halfHeight * (800 / 600));
assertClose(camera.right, halfHeight * (800 / 600));
assert.equal(camera.near, 0.01);
assert.equal(camera.far >= 1000, true);
});
test("output sizing and padding helpers preserve snapshot fallback semantics", () => {
assert.deepEqual(outputSize({}, {}), { width: 1400, height: 900 });
assert.deepEqual(outputSize({ width: 320, height: 240 }, { width: 1400, height: 900 }), { width: 320, height: 240 });
assert.deepEqual(outputSize({}, { width: 1600, height: 1200 }), { width: 1600, height: 1200 });
assert.equal(framePadding({}), 0.04);
assert.equal(framePadding({ render: { padding: 0 } }), 0);
assert.equal(framePadding({ render: { padding: 0.02 } }), 0.02);
assert.equal(framePadding({ render: { padding: -0.02 } }), 0);
assert.equal(framePadding({ paddingPercent: 0.25 }), 0.15);
assert.equal(framePadding({ render: { paddingPercent: 0.13 } }), 0.13);
});
test("grid floors use theme line opacity and density for snapshots", () => {
const scene = new THREE.Scene();
addFloor(
scene,
{ min: [0, 0, 0], max: [10, 10, 10] },
normalizeThemeSettings({
floor: {
mode: "grid",
gridCenterColor: "#123456",
gridCellColor: "#abcdef",
gridOpacity: 0.37,
gridDensity: 2
}
}),
RENDER_SCENE_SCALE.CAD,
SCALE_SETTINGS
);
const grid = scene.children[0];
const materials = Array.isArray(grid.material) ? grid.material : [grid.material];
assert.equal(grid.type, "GridHelper");
assert.equal(grid.geometry.getAttribute("position").count, 4 * (56 + 1));
assert.equal(materials[0].opacity, 0.37);
assert.equal(materials[0].transparent, true);
assert.equal(materials[0].depthWrite, false);
});
test("snapshot floors render grid independently from the stage floor", () => {
const scene = new THREE.Scene();
addFloor(
scene,
{ min: [0, 0, 0], max: [10, 10, 10] },
normalizeThemeSettings({
floor: {
mode: "stage",
enabled: true,
color: "#ddeeff",
shadowOpacity: 0.25,
grid: {
enabled: true,
centerColor: "#123456",
cellColor: "#abcdef",
opacity: 0.37,
density: 2
}
}
}),
RENDER_SCENE_SCALE.CAD,
SCALE_SETTINGS
);
const grid = scene.children.find((child) => child.type === "GridHelper");
const plane = scene.children.find((child) => child.type === "Mesh");
const gridMaterials = Array.isArray(grid.material) ? grid.material : [grid.material];
assert.ok(grid);
assert.ok(plane);
assert.equal(gridMaterials[0].opacity, 0.37);
assert.equal(plane.receiveShadow, true);
assert.equal(plane.material.opacity, 0.25);
});
@@ -0,0 +1,224 @@
import {
assemblyUsesSelfContainedMesh,
buildSelfContainedAssemblyMeshData
} from "../lib/assembly/meshData.js";
import { buildMeshDataFromGlbBuffer } from "../lib/render/glbMeshData.js";
import {
loadRenderDisplayEdgeBundle,
loadRenderGlb,
loadRenderSelectorBundle,
loadRenderTopologyIndex
} from "../lib/stepRenderAssetClient.js";
import {
buildDisplayEdgeRuntime,
buildSelectorRuntime
} from "../lib/selectors/runtime.js";
import {
loadStepModuleDefinition
} from "./stepModule.js";
import {
hasStepParameterRenderValues,
normalizeStepParameterRenderValues,
stepParameterRenderFrameState,
stepParameterRenderFrameValues
} from "./stepParameters.js";
export const SOURCE_KIND = Object.freeze({
STEP: "step",
STP: "stp",
GLB: "glb",
STL: "stl",
THREE_MF: "3mf",
UNKNOWN: "unknown"
});
function isObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function normalizeKind(value = "") {
const kind = String(value || "").trim().toLowerCase();
if (kind === "step" || kind === "stp" || kind === "glb" || kind === "stl" || kind === "3mf") {
return kind;
}
return SOURCE_KIND.UNKNOWN;
}
function sourceKindFromUrl(url = "") {
const pathname = String(url || "").split(/[?#]/, 1)[0].toLowerCase();
if (pathname.endsWith(".step")) {
return SOURCE_KIND.STEP;
}
if (pathname.endsWith(".stp")) {
return SOURCE_KIND.STP;
}
if (pathname.endsWith(".glb") || pathname.endsWith(".gltf")) {
return SOURCE_KIND.GLB;
}
if (pathname.endsWith(".stl")) {
return SOURCE_KIND.STL;
}
if (pathname.endsWith(".3mf")) {
return SOURCE_KIND.THREE_MF;
}
return SOURCE_KIND.UNKNOWN;
}
export function sourceIsStep(sourceOrKind) {
const kind = typeof sourceOrKind === "string" ? sourceOrKind : sourceOrKind?.kind;
const normalized = normalizeKind(kind);
return normalized === SOURCE_KIND.STEP || normalized === SOURCE_KIND.STP;
}
function assertStepOnlyOption(kind, value, label) {
if (value === undefined || value === null) {
return;
}
if (!sourceIsStep(kind)) {
throw new Error(`${label} is supported only for STEP/STP sources`);
}
}
async function loadStepMeshFromGlb(glbUrl) {
const meshData = await loadRenderGlb(glbUrl);
const topology = await loadRenderTopologyIndex(glbUrl);
if (assemblyUsesSelfContainedMesh(topology)) {
return buildSelfContainedAssemblyMeshData(topology, meshData);
}
return meshData;
}
async function loadSelectorRuntime(glbUrl, { cadPath = "" } = {}) {
if (!glbUrl) {
return null;
}
try {
const selectorBundle = await loadRenderSelectorBundle(glbUrl);
return buildSelectorRuntime(selectorBundle, {
copyCadPath: cadPath
});
} catch {
return null;
}
}
async function loadDisplayEdgeRuntime(glbUrl) {
if (!glbUrl) {
return null;
}
try {
return buildDisplayEdgeRuntime(await loadRenderDisplayEdgeBundle(glbUrl));
} catch {
return null;
}
}
async function loadMeshDataFromUrl(url, kind) {
if (sourceIsStep(kind)) {
return loadStepMeshFromGlb(url);
}
if (kind === SOURCE_KIND.GLB) {
const response = await fetch(url, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load GLB source: HTTP ${response.status}`);
}
return buildMeshDataFromGlbBuffer(await response.arrayBuffer());
}
throw new Error(`Unsupported render source kind: ${kind || SOURCE_KIND.UNKNOWN}`);
}
async function loadStepParameters({
kind,
stepParameters,
stepParameterUrl,
cadPath,
selectorRuntime
}) {
assertStepOnlyOption(kind, stepParameters, "stepParameters");
assertStepOnlyOption(kind, stepParameterUrl, "stepParameterUrl");
const explicit = hasStepParameterRenderValues(stepParameters);
if (!stepParameterUrl) {
if (!explicit) {
return null;
}
throw new Error("STEP render parameters require resolved.stepParameterUrl");
}
const definition = await loadStepModuleDefinition(stepParameterUrl, { cadPath });
const renderParameters = normalizeStepParameterRenderValues(definition, explicit ? stepParameters : {});
return {
definition,
renderParameters,
selectorRuntime,
cadPath: cadPath || definition.cadPath || "",
sourceUrl: stepParameterUrl
};
}
export function stepParameterFrameRuntime(stepParameterSource, frameIndex = 0) {
if (!stepParameterSource) {
return null;
}
const { definition, renderParameters } = stepParameterSource;
return {
definition,
selectorRuntime: stepParameterSource.selectorRuntime || null,
parameterValues: stepParameterRenderFrameValues(definition, renderParameters, frameIndex),
animationState: stepParameterRenderFrameState(renderParameters, frameIndex),
cadPath: stepParameterSource.cadPath || definition.cadPath || "",
sourceUrl: stepParameterSource.sourceUrl || definition.url || ""
};
}
export async function loadSource(input, options = {}) {
const inputObject = isObject(input) ? input : {};
if (Object.prototype.hasOwnProperty.call(inputObject, "params")) {
throw new Error("Render source jobs use stepParameters; params is no longer a shared render API field");
}
const resolved = isObject(inputObject.resolved) ? inputObject.resolved : {};
const explicitMeshData = inputObject.meshData || options.meshData || (
inputObject.vertices && inputObject.indices ? inputObject : null
);
const rawKind = inputObject.kind || resolved.kind || options.kind || (
typeof input === "string" ? sourceKindFromUrl(input) : ""
);
const kind = normalizeKind(rawKind);
const stepParameters = inputObject.stepParameters ?? options.stepParameters;
const stepParameterUrl = String(
inputObject.stepParameterUrl || resolved.stepParameterUrl || options.stepParameterUrl || ""
).trim();
const cadPath = String(inputObject.cadPath || resolved.inputPath || options.cadPath || "").trim();
assertStepOnlyOption(kind, stepParameters, "stepParameters");
assertStepOnlyOption(kind, stepParameterUrl, "stepParameterUrl");
let meshData = explicitMeshData;
const glbUrl = String(inputObject.glbUrl || resolved.glbUrl || options.glbUrl || "").trim();
const url = String(typeof input === "string" ? input : inputObject.url || resolved.url || glbUrl || "").trim();
if (!meshData) {
if (!url) {
throw new Error("loadSource requires meshData, a source URL, or resolved.glbUrl");
}
meshData = await loadMeshDataFromUrl(sourceIsStep(kind) ? glbUrl || url : url, kind);
}
const selectorRuntime = inputObject.selectorRuntime || options.selectorRuntime || await loadSelectorRuntime(glbUrl || url, { cadPath });
const displayEdgeRuntime = inputObject.displayEdgeRuntime || options.displayEdgeRuntime || await loadDisplayEdgeRuntime(glbUrl || url);
const stepParameterSource = await loadStepParameters({
kind,
stepParameters,
stepParameterUrl,
cadPath,
selectorRuntime
});
return {
kind,
meshData,
selectorRuntime,
displayEdgeRuntime,
stepParameterSource,
resolved,
url,
glbUrl,
cadPath
};
}
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import test from "node:test";
import { loadSource } from "./source.js";
function meshData() {
return {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
indices: new Uint32Array([0, 1, 2]),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: []
};
}
async function withTempModule(callback) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "render-source-test-"));
try {
const modulePath = path.join(root, "part.step.mjs");
fs.writeFileSync(modulePath, `
export default {
manifest: {
schemaVersion: 1,
parameters: {
drive: { type: "number", min: 0, max: 360, default: 0 }
}
}
};
`);
return await callback(pathToFileURL(modulePath).href);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
test("loadSource rejects the removed shared params field", async () => {
await assert.rejects(
() => loadSource({
kind: "step",
meshData: meshData(),
params: { drive: 90 }
}),
/use stepParameters/
);
});
test("loadSource rejects STEP parameter options for non-STEP sources", async () => {
await assert.rejects(
() => loadSource({
kind: "glb",
meshData: meshData(),
stepParameters: { drive: 90 }
}),
/stepParameters is supported only for STEP\/STP sources/
);
await assert.rejects(
() => loadSource({
kind: "glb",
meshData: meshData(),
stepParameterUrl: "file:///tmp/part.step.mjs"
}),
/stepParameterUrl is supported only for STEP\/STP sources/
);
});
test("loadSource accepts STEP parameter sidecars for STEP sources", async () => {
await withTempModule(async (stepParameterUrl) => {
const source = await loadSource({
kind: "step",
meshData: meshData(),
cadPath: "part.step",
stepParameterUrl,
stepParameters: { drive: 90 }
});
assert.equal(source.kind, "step");
assert.equal(source.stepParameterSource.renderParameters.values.drive, 90);
assert.equal(source.stepParameterSource.cadPath, "part.step");
});
});
@@ -0,0 +1,512 @@
import {
buildCadRefToken,
normalizeCadPath,
normalizeCadRefSelectors,
parseCadRefSelector,
parseCadRefToken
} from "../lib/cadRefs.js";
export const STEP_MODULE_SCHEMA_VERSION = 1;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function normalizeString(value, fallback = "") {
const text = String(value ?? fallback).trim();
return text || fallback;
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function normalizeParameterType(value) {
const type = normalizeString(value, "number").toLowerCase();
if (["number", "boolean", "enum", "select", "color", "string", "button"].includes(type)) {
return type === "select" ? "enum" : type;
}
return "number";
}
function normalizeParameterOptions(value) {
return (Array.isArray(value) ? value : [])
.map((option) => {
if (isObject(option)) {
const valueText = normalizeString(option.value);
return valueText ? {
value: valueText,
label: normalizeString(option.label, valueText)
} : null;
}
const valueText = normalizeString(option);
return valueText ? { value: valueText, label: valueText } : null;
})
.filter(Boolean);
}
function normalizeParameterDefinition(id, rawDefinition) {
const raw = isObject(rawDefinition) ? rawDefinition : {};
const type = normalizeParameterType(raw.type);
const min = toFiniteNumber(raw.min, 0);
const max = Math.max(toFiniteNumber(raw.max, type === "number" ? 1 : min), min);
const options = normalizeParameterOptions(raw.options || raw.values);
const fallbackDefault = type === "boolean"
? false
: type === "color"
? "#ffffff"
: type === "enum"
? options[0]?.value || ""
: type === "string"
? ""
: 0;
return {
id,
type,
label: normalizeString(raw.label, id),
description: normalizeString(raw.description),
unit: normalizeString(raw.unit),
min,
max,
step: Math.max(toFiniteNumber(raw.step, type === "number" ? 0.01 : 1), 0),
defaultValue: normalizeParameterValue({ type, min, max, options }, raw.default ?? raw.defaultValue ?? fallbackDefault),
options
};
}
export function normalizeParameterValue(definition, value) {
const type = normalizeParameterType(definition?.type);
if (type === "boolean") {
return value === true;
}
if (type === "color") {
const color = normalizeString(value, normalizeString(definition?.defaultValue, "#ffffff"));
return HEX_COLOR_RE.test(color) ? color : "#ffffff";
}
if (type === "enum") {
const options = Array.isArray(definition?.options) ? definition.options : [];
const valueText = normalizeString(value, options[0]?.value || "");
return options.some((option) => option.value === valueText) ? valueText : (options[0]?.value || "");
}
if (type === "string") {
return String(value ?? "");
}
if (type === "button") {
return Math.max(0, Math.floor(toFiniteNumber(value, 0)));
}
const min = toFiniteNumber(definition?.min, 0);
const max = Math.max(toFiniteNumber(definition?.max, min), min);
return clamp(toFiniteNumber(value, toFiniteNumber(definition?.defaultValue, min)), min, max);
}
function normalizeParameters(value) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawDefinition]) => normalizeParameterDefinition(normalizeString(id), rawDefinition))
.filter((definition) => definition.id);
}
function inferCadPathFromStepModuleUrl(url) {
let pathname = String(url || "").split("?")[0].split("#")[0].replace(/\\/g, "/").trim();
if (!pathname) {
return "";
}
try {
pathname = new URL(pathname, "http://localhost").pathname;
} catch {
// Keep the raw path for relative module URLs.
}
const normalizedPath = pathname.replace(/^\/+|\/+$/g, "");
const slashIndex = normalizedPath.lastIndexOf("/");
const directory = slashIndex >= 0 ? normalizedPath.slice(0, slashIndex) : "";
const fileName = slashIndex >= 0 ? normalizedPath.slice(slashIndex + 1) : normalizedPath;
const match = fileName.match(/^\.?(.+)\.step\.js$/);
if (!match?.[1]) {
return "";
}
return normalizeCadPath([directory, match[1]].filter(Boolean).join("/"));
}
function normalizeRelativeStepPath(value) {
const rawPath = normalizeString(value).replace(/\\/g, "/");
if (!rawPath || rawPath.startsWith("/") || /^[A-Za-z]:\//.test(rawPath) || /^[a-z][a-z0-9+.-]*:/i.test(rawPath)) {
return "";
}
const parts = rawPath.split("/").filter((part) => part && part !== ".");
if (!parts.length || parts.some((part) => part === "..")) {
return "";
}
const normalizedPath = parts.join("/");
return /\.(step|stp)$/i.test(normalizedPath) ? normalizedPath : "";
}
function cadPathFromStepPath(stepPath) {
return normalizeCadPath(String(stepPath || "").replace(/\.(step|stp)$/i, ""));
}
function normalizeStepLink(value) {
const raw = isObject(value) ? value : {};
return {
...raw,
path: normalizeRelativeStepPath(raw.path)
};
}
function normalizeManifestStep(value, normalizedStep) {
if (!isObject(value) && !normalizedStep.path) {
return null;
}
const step = isObject(value) ? { ...value } : {};
if (normalizedStep.path) {
step.path = normalizedStep.path;
} else {
delete step.path;
}
return Object.keys(step).length ? step : null;
}
function normalizeFeatureRef(rawFeature, cadPath = "") {
const raw = isObject(rawFeature) ? rawFeature : {};
const ref = normalizeString(raw.ref);
void cadPath;
const fullToken = parseCadRefToken(ref);
if (fullToken) {
return buildCadRefToken({ selectors: fullToken.selectors });
}
const rawSelectors = ref.startsWith("#") ? ref.slice(1) : "";
const selectors = normalizeCadRefSelectors(
rawSelectors ||
raw.selector ||
raw.selectors ||
raw.occurrence ||
raw.occurrences ||
[]
);
return selectors.length
? buildCadRefToken({ selectors })
: ref;
}
function normalizeFeatures(value, { cadPath = "" } = {}) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawFeature]) => {
const raw = isObject(rawFeature) ? rawFeature : {};
const featureId = normalizeString(id);
return featureId ? {
id: featureId,
label: normalizeString(raw.label, featureId),
description: normalizeString(raw.description),
ref: normalizeFeatureRef(raw, cadPath),
partIds: Array.isArray(raw.partIds)
? raw.partIds.map((partId) => normalizeString(partId)).filter(Boolean)
: [],
axis: normalizeVector3(raw.axis, [0, 0, 1]),
origin: normalizePoint3(raw.origin, null)
} : null;
})
.filter(Boolean);
}
function normalizeAnimations(value) {
return Object.entries(isObject(value) ? value : {})
.map(([id, rawAnimation]) => {
const raw = isObject(rawAnimation) ? rawAnimation : {};
const animationId = normalizeString(id);
return animationId ? {
id: animationId,
label: normalizeString(raw.label, animationId),
description: normalizeString(raw.description),
duration: Math.max(toFiniteNumber(raw.duration ?? raw.durationSeconds, 1), 0.001),
loop: raw.loop !== false,
update: typeof raw.update === "function" ? raw.update : null,
raw
} : null;
})
.filter(Boolean);
}
function normalizeVector3(value, fallback = [0, 0, 0]) {
const source = Array.isArray(value) ? value : fallback;
return [
toFiniteNumber(source?.[0], fallback[0] || 0),
toFiniteNumber(source?.[1], fallback[1] || 0),
toFiniteNumber(source?.[2], fallback[2] || 0)
];
}
function normalizePoint3(value, fallback = [0, 0, 0]) {
if (!Array.isArray(value) && fallback === null) {
return null;
}
return normalizeVector3(value, fallback || [0, 0, 0]);
}
export function normalizeStepModuleDefinition(rawModule, { url = "", cadPath = "" } = {}) {
if (!isObject(rawModule)) {
throw new Error("STEP runtime module must export an object");
}
const manifest = isObject(rawModule.manifest) ? rawModule.manifest : rawModule;
const schemaVersion = Number(manifest.schemaVersion || rawModule.schemaVersion || 1);
if (schemaVersion !== STEP_MODULE_SCHEMA_VERSION) {
throw new Error(`Unsupported STEP runtime module schemaVersion ${schemaVersion || "unknown"}`);
}
const step = normalizeStepLink(manifest.step);
const stepCadPath = cadPathFromStepPath(step.path);
const normalizedCadPath = stepCadPath || normalizeCadPath(cadPath) || inferCadPathFromStepModuleUrl(url);
const parameters = normalizeParameters(manifest.parameters);
const parameterMap = Object.fromEntries(parameters.map((definition) => [definition.id, definition]));
const defaultParameterValues = Object.fromEntries(
parameters.map((definition) => [definition.id, definition.defaultValue])
);
const normalizedManifestStep = normalizeManifestStep(manifest.step, step);
const normalizedManifest = {
...manifest,
schemaVersion: STEP_MODULE_SCHEMA_VERSION
};
if (normalizedManifestStep) {
normalizedManifest.step = normalizedManifestStep;
} else {
delete normalizedManifest.step;
}
return {
url: normalizeString(url),
cadPath: normalizedCadPath,
step: {
path: step.path,
cadPath: stepCadPath || normalizedCadPath,
explicit: Boolean(step.path),
inferred: !step.path && Boolean(normalizedCadPath)
},
module: rawModule,
manifest: normalizedManifest,
features: normalizeFeatures(manifest.features, { cadPath: normalizedCadPath }),
parameters,
parameterMap,
defaultParameterValues,
animations: normalizeAnimations(manifest.animations)
};
}
export async function loadStepModuleDefinition(url, options = {}) {
const namespace = await import(/* webpackIgnore: true */ /* @vite-ignore */ url);
return normalizeStepModuleDefinition(namespace?.default ?? namespace, { url, ...options });
}
export function normalizeStepModuleParameterValues(definition, values = {}) {
const parameterMap = definition?.parameterMap || {};
return Object.fromEntries(
Object.values(parameterMap).map((parameter) => [
parameter.id,
normalizeParameterValue(parameter, isObject(values) && Object.hasOwn(values, parameter.id)
? values[parameter.id]
: parameter.defaultValue)
])
);
}
const STEP_MODULE_ZERO_STATE_EPSILON = 1e-6;
function stepModuleValueMatchesZeroState(parameter, value, zeroValue) {
const type = normalizeParameterType(parameter?.type);
if (type === "number" || type === "button") {
const numericValue = Number(value);
const numericZeroValue = Number(zeroValue);
return Number.isFinite(numericValue) &&
Number.isFinite(numericZeroValue) &&
Math.abs(numericValue - numericZeroValue) <= STEP_MODULE_ZERO_STATE_EPSILON;
}
if (type === "color") {
return String(value || "").toLowerCase() === String(zeroValue || "").toLowerCase();
}
return value === zeroValue;
}
export function stepModuleParameterValuesAtZeroState(definition, values = {}) {
const parameters = Array.isArray(definition?.parameters) ? definition.parameters : [];
if (!parameters.length) {
return true;
}
const normalizedValues = normalizeStepModuleParameterValues(definition, values);
const zeroValues = normalizeStepModuleParameterValues(definition, definition?.defaultParameterValues || {});
return parameters.every((parameter) => (
stepModuleValueMatchesZeroState(parameter, normalizedValues[parameter.id], zeroValues[parameter.id])
));
}
export function stepModuleAnimationStateAtZeroState(animationState = {}) {
return animationState?.playing !== true &&
Math.abs(toFiniteNumber(animationState?.elapsedSec, 0)) <= STEP_MODULE_ZERO_STATE_EPSILON;
}
export function stepModuleRuntimeAtZeroState(definition, runtimeState = {}) {
return stepModuleParameterValuesAtZeroState(definition, runtimeState?.parameterValues) &&
stepModuleAnimationStateAtZeroState(runtimeState?.animationState);
}
function boundsCenter(bounds) {
const min = Array.isArray(bounds?.min) ? bounds.min : [0, 0, 0];
const max = Array.isArray(bounds?.max) ? bounds.max : min;
return [
(toFiniteNumber(min[0]) + toFiniteNumber(max[0])) / 2,
(toFiniteNumber(min[1]) + toFiniteNumber(max[1])) / 2,
(toFiniteNumber(min[2]) + toFiniteNumber(max[2])) / 2
];
}
function mergeBounds(boundsList) {
const validBounds = (Array.isArray(boundsList) ? boundsList : [])
.filter((bounds) => Array.isArray(bounds?.min) && Array.isArray(bounds?.max));
if (!validBounds.length) {
return null;
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const bounds of validBounds) {
for (let index = 0; index < 3; index += 1) {
min[index] = Math.min(min[index], toFiniteNumber(bounds.min[index]));
max[index] = Math.max(max[index], toFiniteNumber(bounds.max[index]));
}
}
return { min, max };
}
function partIdsForSelector(selector, meshData) {
const normalizedSelector = normalizeString(selector);
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
if (!normalizedSelector || !parts.length) {
return [];
}
return parts
.filter((part) => {
const ids = [part?.id, part?.occurrenceId]
.map((id) => normalizeString(id))
.filter(Boolean);
return ids.some((id) => (
id === normalizedSelector ||
id.startsWith(`${normalizedSelector}.`) ||
normalizedSelector.startsWith(`${id}.`)
));
})
.map((part) => normalizeString(part.id || part.occurrenceId))
.filter(Boolean);
}
function referenceForSelector(selector, selectorRuntime) {
const normalizedSelector = normalizeString(selector);
if (!normalizedSelector || !selectorRuntime) {
return null;
}
return selectorRuntime.referenceByNormalizedSelector?.get(normalizedSelector)
|| selectorRuntime.referenceByDisplaySelector?.get(normalizedSelector)
|| selectorRuntime.referenceMap?.get(normalizedSelector)
|| null;
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => normalizeString(value))
.filter(Boolean))];
}
function featureSelectors(feature) {
const token = parseCadRefToken(feature?.ref);
if (!token) {
return [];
}
return token.selectors.length ? token.selectors : ["__model__"];
}
export function resolveStepModuleFeatures(definition, {
meshData = null,
selectorRuntime = null
} = {}) {
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const partMap = new Map(parts.map((part) => [normalizeString(part.id || part.occurrenceId), part]));
const allPartIds = parts.length
? parts.map((part) => normalizeString(part.id || part.occurrenceId)).filter(Boolean)
: ["__model__"];
return Object.fromEntries(
(Array.isArray(definition?.features) ? definition.features : []).map((feature) => {
const selectors = featureSelectors(feature);
const references = selectors
.map((selector) => selector === "__model__" ? null : referenceForSelector(selector, selectorRuntime))
.filter(Boolean);
const selectorPartIds = selectors.flatMap((selector) => {
if (selector === "__model__") {
return allPartIds;
}
const parsedSelector = parseCadRefSelector(selector);
if (parsedSelector?.occurrenceId) {
return partIdsForSelector(parsedSelector.occurrenceId, meshData);
}
const reference = referenceForSelector(selector, selectorRuntime);
return partIdsForSelector(reference?.partId || reference?.occurrenceId || selector, meshData);
});
const partIds = uniqueStrings([
...feature.partIds,
...selectorPartIds
]);
const partsForFeature = partIds.map((partId) => partMap.get(partId)).filter(Boolean);
const bounds = mergeBounds(partsForFeature.map((part) => part.bounds)) || meshData?.bounds || null;
return [feature.id, {
...feature,
selectors,
references,
partIds,
parts: partsForFeature,
bounds,
center: feature.origin || boundsCenter(bounds),
transform: partsForFeature.length === 1 ? partsForFeature[0]?.transform || null : null,
transforms: partsForFeature.map((part) => part?.transform || null),
missing: selectors.length > 0 && partIds.length === 0 && selectors[0] !== "__model__"
}];
})
);
}
export function stepModuleTargetPartIds(target, features, meshData) {
if (Array.isArray(target)) {
return uniqueStrings(target.flatMap((item) => stepModuleTargetPartIds(item, features, meshData)));
}
if (isObject(target)) {
if (Array.isArray(target.partIds)) {
return uniqueStrings(target.partIds);
}
if (target.partId) {
return uniqueStrings([target.partId]);
}
if (target.feature) {
return stepModuleTargetPartIds(target.feature, features, meshData);
}
if (target.ref) {
return stepModuleTargetPartIds(target.ref, features, meshData);
}
}
const normalizedTarget = normalizeString(target);
if (!normalizedTarget) {
return [];
}
if (normalizedTarget === "*" || normalizedTarget === "all" || normalizedTarget === "__all__") {
const parts = Array.isArray(meshData?.parts) ? meshData.parts : [];
return parts.length
? uniqueStrings(parts.map((part) => part?.id || part?.occurrenceId))
: ["__model__"];
}
const feature = features?.[normalizedTarget];
if (feature) {
return uniqueStrings(feature.partIds);
}
const token = parseCadRefToken(normalizedTarget);
if (token) {
const selectors = token.selectors.length ? token.selectors : ["*"];
return uniqueStrings(selectors.flatMap((selector) => stepModuleTargetPartIds(selector, features, meshData)));
}
return uniqueStrings(partIdsForSelector(normalizedTarget, meshData));
}
@@ -0,0 +1,323 @@
import { stepModuleTargetPartIds } from "./stepModule.js";
function toNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : [])
.map((value) => String(value || "").trim())
.filter(Boolean))];
}
export function buildPartTransformMatrix(THREE, transform) {
const matrix = new THREE.Matrix4();
if (!Array.isArray(transform) || transform.length !== 16) {
matrix.identity();
return matrix;
}
matrix.set(
Number(transform[0]) || 0,
Number(transform[1]) || 0,
Number(transform[2]) || 0,
Number(transform[3]) || 0,
Number(transform[4]) || 0,
Number(transform[5]) || 0,
Number(transform[6]) || 0,
Number(transform[7]) || 0,
Number(transform[8]) || 0,
Number(transform[9]) || 0,
Number(transform[10]) || 0,
Number(transform[11]) || 0,
Number(transform[12]) || 0,
Number(transform[13]) || 0,
Number(transform[14]) || 0,
Number(transform[15]) || 0
);
return matrix;
}
export function displayTransformForPart(meshData, part, renderPartsIndividually = true) {
if (!renderPartsIndividually || meshData?.partTransformsBaked === true) {
return null;
}
return part?.transform || null;
}
function normalizeStepModulePoint(value, fallback = [0, 0, 0]) {
if (Array.isArray(value) || ArrayBuffer.isView(value)) {
return [
toNumber(value[0], fallback[0] || 0),
toNumber(value[1], fallback[1] || 0),
toNumber(value[2], fallback[2] || 0)
];
}
if (value && typeof value === "object") {
return [
toNumber(value.x, fallback[0] || 0),
toNumber(value.y, fallback[1] || 0),
toNumber(value.z, fallback[2] || 0)
];
}
return [...fallback];
}
function stepModuleVector3(THREE, value, fallback = [0, 0, 0]) {
const point = normalizeStepModulePoint(value, fallback);
return new THREE.Vector3(point[0], point[1], point[2]);
}
function buildStepModuleRotateMatrix(THREE, spec = {}) {
const axis = stepModuleVector3(THREE, spec.axis, [0, 0, 1]);
if (axis.lengthSq() <= 1e-12) {
axis.set(0, 0, 1);
}
axis.normalize();
const origin = stepModuleVector3(THREE, spec.origin, [0, 0, 0]);
const angleRad = Number.isFinite(Number(spec.angleRad))
? Number(spec.angleRad)
: (toNumber(spec.angleDeg, 0) * Math.PI) / 180;
return new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeRotationAxis(axis, angleRad))
.multiply(new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z));
}
function buildStepModuleScaleMatrix(THREE, spec = {}) {
const rawScale = spec.scale ?? spec;
const scale = Array.isArray(rawScale) || ArrayBuffer.isView(rawScale)
? normalizeStepModulePoint(rawScale, [1, 1, 1])
: [toNumber(rawScale, 1), toNumber(rawScale, 1), toNumber(rawScale, 1)];
const origin = stepModuleVector3(THREE, spec.origin, [0, 0, 0]);
return new THREE.Matrix4()
.makeTranslation(origin.x, origin.y, origin.z)
.multiply(new THREE.Matrix4().makeScale(scale[0], scale[1], scale[2]))
.multiply(new THREE.Matrix4().makeTranslation(-origin.x, -origin.y, -origin.z));
}
function buildStepModuleTranslateMatrix(THREE, value) {
const translate = normalizeStepModulePoint(value, [0, 0, 0]);
return new THREE.Matrix4().makeTranslation(translate[0], translate[1], translate[2]);
}
function buildStepModuleSingleEffectMatrix(THREE, spec = {}) {
if (!spec || typeof spec !== "object") {
return new THREE.Matrix4();
}
if (Array.isArray(spec.matrix) && spec.matrix.length === 16) {
return buildPartTransformMatrix(THREE, spec.matrix);
}
const matrix = new THREE.Matrix4();
if (spec.scale !== undefined) {
matrix.premultiply(buildStepModuleScaleMatrix(THREE, spec));
}
if (spec.rotate && typeof spec.rotate === "object") {
matrix.premultiply(buildStepModuleRotateMatrix(THREE, spec.rotate));
}
if (spec.translate !== undefined) {
matrix.premultiply(buildStepModuleTranslateMatrix(THREE, spec.translate));
}
return matrix;
}
export function buildStepModuleEffectMatrix(THREE, spec = {}) {
if (!spec || typeof spec !== "object") {
return new THREE.Matrix4();
}
const steps = Array.isArray(spec.transforms) ? spec.transforms : [spec];
const matrix = new THREE.Matrix4();
for (const step of steps) {
matrix.premultiply(buildStepModuleSingleEffectMatrix(THREE, step));
}
return matrix;
}
function matrixHasTransform(matrix, epsilon = 1e-6) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return false;
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return matrix.elements.some((value, index) => Math.abs(Number(value) - identity[index]) > epsilon);
}
export function resetStepModuleRecordEffects(records) {
for (const record of Array.isArray(records) ? records : []) {
record.effectMatrix = null;
record.effectStyle = null;
record.effectVisible = null;
record.effectHighlighted = false;
}
}
function allStepModulePartIds(meshData, displayRecords) {
const partIds = Array.isArray(meshData?.parts)
? meshData.parts.map((part) => String(part?.id || part?.occurrenceId || "").trim()).filter(Boolean)
: [];
if (partIds.length) {
return uniqueStrings(partIds);
}
const displayPartIds = (Array.isArray(displayRecords) ? displayRecords : [])
.map((record) => String(record?.partId || "").trim())
.filter(Boolean);
return uniqueStrings(displayPartIds.length ? displayPartIds : ["__model__"]);
}
function resolveStepModuleEffectTargetPartIds(target, features, meshData, displayRecords) {
if (target && typeof target === "object" && !Array.isArray(target)) {
if (Array.isArray(target.partIds)) {
return uniqueStrings(target.partIds);
}
if (target.partId) {
return uniqueStrings([target.partId]);
}
if (target.feature) {
return resolveStepModuleEffectTargetPartIds(target.feature, features, meshData, displayRecords);
}
if (target.ref) {
return stepModuleTargetPartIds(String(target.ref), features, meshData);
}
}
const normalizedTarget = String(target || "").trim();
if (normalizedTarget === "*" || normalizedTarget === "all" || normalizedTarget === "__all__") {
return allStepModulePartIds(meshData, displayRecords);
}
return stepModuleTargetPartIds(target, features, meshData);
}
export function createStepModuleEffectsApi(THREE, {
meshData,
features,
runtime,
effectsByPartId,
onTransformEffect
}) {
const ensureEffect = (partId) => {
const id = String(partId || "").trim();
if (!id) {
return null;
}
const current = effectsByPartId.get(id) || {
matrix: null,
style: null,
visible: null,
highlighted: false
};
effectsByPartId.set(id, current);
return current;
};
const forEachTarget = (target, callback) => {
const partIds = resolveStepModuleEffectTargetPartIds(target, features, meshData, runtime?.displayRecords);
for (const partId of partIds) {
const effect = ensureEffect(partId);
if (effect) {
callback(effect, partId);
}
}
return partIds;
};
return {
transform(target, spec) {
const matrix = buildStepModuleEffectMatrix(THREE, spec);
const partIds = forEachTarget(target, (effect) => {
effect.matrix = effect.matrix
? effect.matrix.clone().premultiply(matrix)
: matrix.clone();
});
if (partIds.length && matrixHasTransform(matrix)) {
onTransformEffect?.({
target,
spec,
partIds,
matrix: matrix.clone()
});
}
},
style(target, style = {}) {
if (!style || typeof style !== "object") {
return;
}
forEachTarget(target, (effect) => {
effect.style = {
...(effect.style || {}),
...style
};
});
},
visible(target, visible = true) {
forEachTarget(target, (effect) => {
effect.visible = visible !== false;
});
},
highlight(target, highlighted = true) {
forEachTarget(target, (effect) => {
effect.highlighted = highlighted !== false;
});
},
clear(target) {
const partIds = resolveStepModuleEffectTargetPartIds(target, features, meshData, runtime?.displayRecords);
for (const partId of partIds) {
effectsByPartId.delete(partId);
}
}
};
}
export function buildStepModuleTimeState(animationState = {}) {
const duration = Math.max(Number(animationState.duration) || 0, 0);
const elapsedSec = Math.max(Number(animationState.elapsedSec) || 0, 0);
return {
elapsed: elapsedSec,
elapsedSec,
duration,
progress: duration > 0 ? clamp(elapsedSec / duration, 0, 1) : 0,
cycle: duration > 0 ? elapsedSec / duration : 0,
playing: animationState.playing === true,
speed: Math.max(Number(animationState.speed) || 1, 0)
};
}
export function buildStepModuleContext({
runtime,
stepModuleRuntime,
features,
effects,
cleanup
}) {
const definition = stepModuleRuntime?.definition || null;
return {
THREE: runtime?.THREE,
scene: runtime?.scene,
modelGroup: runtime?.modelGroup,
edgesGroup: runtime?.edgesGroup,
runtime,
manifest: definition?.manifest || {},
module: definition?.module || null,
cadPath: stepModuleRuntime?.cadPath || definition?.cadPath || "",
params: stepModuleRuntime?.parameterValues || {},
features,
effects,
time: buildStepModuleTimeState(stepModuleRuntime?.animationState),
cleanup: typeof cleanup === "function" ? cleanup : () => {},
requestRender: () => runtime?.requestRender?.()
};
}
export function applyStepModuleEffectsToRecords(THREE, records, effectsByPartId) {
resetStepModuleRecordEffects(records);
for (const record of Array.isArray(records) ? records : []) {
const effect = effectsByPartId.get(String(record?.partId || "").trim());
if (!effect) {
continue;
}
record.effectMatrix = effect.matrix instanceof THREE.Matrix4 ? effect.matrix.clone() : null;
record.effectStyle = effect.style && typeof effect.style === "object" ? { ...effect.style } : null;
record.effectVisible = effect.visible;
record.effectHighlighted = effect.highlighted === true;
}
}
@@ -0,0 +1,173 @@
import {
normalizeParameterValue,
normalizeStepModuleParameterValues
} from "./stepModule.js";
export const DEFAULT_STEP_PARAMETER_FPS = 18;
export const DEFAULT_STEP_PARAMETER_DURATION_SECONDS = 4;
export const MAX_STEP_PARAMETER_FRAMES = 720;
function isObject(value) {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function positiveInteger(value, fallback) {
const parsed = Math.round(Number(value));
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
function rawParamsUseEnvelope(rawParams) {
return isObject(rawParams) && (
isObject(rawParams.values)
|| isObject(rawParams.animate)
|| Object.hasOwn(rawParams, "durationSeconds")
|| Object.hasOwn(rawParams, "duration")
|| Object.hasOwn(rawParams, "fps")
|| Object.hasOwn(rawParams, "loop")
);
}
export function hasStepParameterRenderValues(value) {
return value !== undefined && value !== null;
}
export function stepParameterRenderValuesAreAnimated(rawParams) {
return isObject(rawParams) && isObject(rawParams.animate) && Object.keys(rawParams.animate).length > 0;
}
function parameterMapForDefinition(definition) {
return definition?.parameterMap && typeof definition.parameterMap === "object"
? definition.parameterMap
: {};
}
function assertKnownParameterIds(definition, values, label) {
const parameterMap = parameterMapForDefinition(definition);
for (const key of Object.keys(isObject(values) ? values : {})) {
if (!parameterMap[key]) {
throw new Error(`Unknown STEP parameter in ${label}: ${key}`);
}
}
}
function normalizeRange(definition, parameterId, rawRange) {
const parameter = parameterMapForDefinition(definition)[parameterId];
if (!parameter) {
throw new Error(`Unknown STEP parameter animation: ${parameterId}`);
}
if (parameter.type !== "number") {
throw new Error(`STEP parameter animation must be numeric: ${parameterId}`);
}
if (!isObject(rawRange)) {
throw new Error(`STEP parameter animation range must be an object: ${parameterId}`);
}
const from = Number(rawRange.from);
const to = Number(rawRange.to);
if (!Number.isFinite(from) || !Number.isFinite(to)) {
throw new Error(`STEP parameter animation range requires finite from/to values: ${parameterId}`);
}
return {
parameterId,
from,
to
};
}
function normalizeAnimationRanges(definition, rawAnimate) {
return Object.fromEntries(
Object.entries(isObject(rawAnimate) ? rawAnimate : {})
.map(([parameterId, rawRange]) => {
const normalizedId = String(parameterId || "").trim();
if (!normalizedId) {
return null;
}
const range = normalizeRange(definition, normalizedId, rawRange);
return [normalizedId, range];
})
.filter(Boolean)
);
}
export function normalizeStepParameterRenderValues(definition, rawParams = {}) {
if (!isObject(rawParams)) {
throw new Error("STEP parameters must be a JSON object");
}
const usesEnvelope = rawParamsUseEnvelope(rawParams);
const rawValues = usesEnvelope ? (rawParams.values || {}) : rawParams;
if (!isObject(rawValues)) {
throw new Error("STEP parameters.values must be a JSON object");
}
assertKnownParameterIds(definition, rawValues, "stepParameters");
const values = normalizeStepModuleParameterValues(definition, rawValues);
const animate = usesEnvelope ? normalizeAnimationRanges(definition, rawParams.animate) : {};
const animated = Object.keys(animate).length > 0;
const fps = clamp(positiveInteger(rawParams.fps, DEFAULT_STEP_PARAMETER_FPS), 1, 60);
const durationSeconds = clamp(
toFiniteNumber(rawParams.durationSeconds ?? rawParams.duration, DEFAULT_STEP_PARAMETER_DURATION_SECONDS),
0.1,
60
);
const frameCount = animated
? Math.max(2, Math.min(Math.round(fps * durationSeconds), MAX_STEP_PARAMETER_FRAMES))
: 1;
return {
values,
animate,
animated,
fps,
durationSeconds,
frameCount,
loop: rawParams.loop !== false
};
}
export function stepParameterRenderFrameProgress(params, frameIndex) {
const frameCount = Math.max(Math.floor(Number(params?.frameCount) || 1), 1);
const index = clamp(Math.floor(Number(frameIndex) || 0), 0, frameCount - 1);
if (frameCount <= 1) {
return 0;
}
return params?.loop === false
? index / (frameCount - 1)
: index / frameCount;
}
export function stepParameterRenderFrameValues(definition, params, frameIndex) {
const progress = stepParameterRenderFrameProgress(params, frameIndex);
const values = { ...(params?.values || {}) };
for (const [parameterId, range] of Object.entries(isObject(params?.animate) ? params.animate : {})) {
const parameter = parameterMapForDefinition(definition)[parameterId];
if (!parameter) {
continue;
}
values[parameterId] = normalizeParameterValue(
parameter,
range.from + ((range.to - range.from) * progress)
);
}
return values;
}
export function stepParameterRenderFrameState(params, frameIndex) {
const progress = stepParameterRenderFrameProgress(params, frameIndex);
const duration = Math.max(Number(params?.durationSeconds) || 0, 0);
const elapsedSec = progress * duration;
return {
activeId: "params",
playing: Boolean(params?.animated),
elapsedSec,
duration,
speed: 1,
loop: params?.loop !== false
};
}
@@ -0,0 +1,43 @@
import path from "node:path";
export function isPerStepViewerDirectoryName(name) {
const normalized = String(name || "").toLowerCase();
return normalized.startsWith(".") && (normalized.endsWith(".step") || normalized.endsWith(".stp"));
}
export function isInlineStepGlbArtifactPath(filePath) {
const name = path.basename(String(filePath || "")).toLowerCase();
return name.startsWith(".") && (name.endsWith(".step.glb") || name.endsWith(".stp.glb"));
}
export function isInlineStepParameterPath(filePath) {
const name = path.basename(String(filePath || "")).toLowerCase();
return name.startsWith(".") && name.endsWith(".step.js");
}
export function isPathInsidePerStepViewerDirectory(filePath) {
return String(filePath || "")
.split(path.sep)
.some((part) => isPerStepViewerDirectoryName(part));
}
export function inlineStepGlbArtifactPathForSource(sourcePath) {
return path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}.glb`);
}
export function legacyStepGlbArtifactPathForSource(sourcePath) {
return path.join(path.dirname(sourcePath), `.${path.basename(sourcePath)}`, "model.glb");
}
export function stepGlbArtifactPathForSource(sourcePath, { existsSync = null } = {}) {
const inlinePath = inlineStepGlbArtifactPathForSource(sourcePath);
if (!existsSync || existsSync(inlinePath)) {
return inlinePath;
}
return legacyStepGlbArtifactPathForSource(sourcePath);
}
export function stepParameterPathForStepSource(sourcePath) {
const stem = path.basename(sourcePath, path.extname(sourcePath));
return path.join(path.dirname(sourcePath), `.${stem}.step.js`);
}
@@ -0,0 +1,103 @@
export const STEP_TOPOLOGY_EXTENSION = "STEP_topology";
export const STEP_TOPOLOGY_SCHEMA_VERSION = 2;
export const STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM = "oc-brep-continuity-v1";
export const STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM = "oc-polygon-on-triangulation-v1";
export const STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG = 2;
export const STEP_TOPOLOGY_EDGE_SAMPLE_COUNT = 3;
export const STEP_EDGE_BARYCENTRIC_ATTRIBUTE = "_CAD_EDGE_BARYCENTRIC";
export const STEP_EDGE_CLASS_ATTRIBUTE = "_CAD_EDGE_CLASS";
export const STEP_EDGE_FLAGS = Object.freeze({
DEGENERATE: 1 << 1,
SEAM: 1 << 2,
NOT_REFERENCEABLE: 1 << 3,
BOUNDARY: 1 << 4,
NON_MANIFOLD: 1 << 5,
HARD: 1 << 6,
TANGENT: 1 << 7,
UNKNOWN_CONTINUITY: 1 << 8
});
export const STEP_EDGE_VISIBILITY_CLASSES = Object.freeze({
FEATURE: "feature",
TANGENT: "tangent",
SEAM: "seam",
DEGENERATE: "degenerate",
BOUNDARY: "boundary",
NON_MANIFOLD: "nonManifold",
UNKNOWN: "unknown"
});
export const STEP_EDGE_SURFACE_CLASS_CODES = Object.freeze({
none: 0,
feature: 1,
tangent: 2,
seam: 3,
degenerate: 4,
boundary: 5,
nonManifold: 6,
unknown: 7
});
export const STEP_EDGE_RENDER_VISIBILITY_CLASSES = Object.freeze([
STEP_EDGE_VISIBILITY_CLASSES.FEATURE,
STEP_EDGE_VISIBILITY_CLASSES.TANGENT,
STEP_EDGE_VISIBILITY_CLASSES.SEAM,
STEP_EDGE_VISIBILITY_CLASSES.DEGENERATE
]);
export function isCurrentStepTopologySchemaVersion(value) {
return Number(value) === STEP_TOPOLOGY_SCHEMA_VERSION;
}
export function stepEdgeSurfaceClassCode(edge = {}) {
const flags = Number(edge?.flags || 0);
const visibilityClass = String(edge?.visibilityClass || "").trim();
if (flags & STEP_EDGE_FLAGS.DEGENERATE || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.DEGENERATE) {
return STEP_EDGE_SURFACE_CLASS_CODES.degenerate;
}
if (flags & STEP_EDGE_FLAGS.SEAM || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.SEAM) {
return STEP_EDGE_SURFACE_CLASS_CODES.seam;
}
if (flags & STEP_EDGE_FLAGS.BOUNDARY || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.BOUNDARY) {
return STEP_EDGE_SURFACE_CLASS_CODES.boundary;
}
if (flags & STEP_EDGE_FLAGS.NON_MANIFOLD || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.NON_MANIFOLD) {
return STEP_EDGE_SURFACE_CLASS_CODES.nonManifold;
}
if (flags & STEP_EDGE_FLAGS.UNKNOWN_CONTINUITY || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.UNKNOWN) {
return STEP_EDGE_SURFACE_CLASS_CODES.unknown;
}
if (flags & STEP_EDGE_FLAGS.TANGENT || visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.TANGENT) {
return STEP_EDGE_SURFACE_CLASS_CODES.tangent;
}
if (visibilityClass === STEP_EDGE_VISIBILITY_CLASSES.FEATURE || flags & STEP_EDGE_FLAGS.HARD) {
return STEP_EDGE_SURFACE_CLASS_CODES.feature;
}
return STEP_EDGE_SURFACE_CLASS_CODES.feature;
}
export function isDisplayableStepEdgeSurfaceClassCode(value) {
const code = Number(value);
return Number.isFinite(code) &&
code !== STEP_EDGE_SURFACE_CLASS_CODES.none &&
code !== STEP_EDGE_SURFACE_CLASS_CODES.degenerate;
}
export function stepTopologyCapabilities() {
return {
edgeClassification: {
algorithm: STEP_TOPOLOGY_EDGE_CLASSIFICATION_ALGORITHM,
angularToleranceDeg: STEP_TOPOLOGY_EDGE_ANGULAR_TOLERANCE_DEG,
samples: STEP_TOPOLOGY_EDGE_SAMPLE_COUNT
},
surfaceEdgeRendering: {
algorithm: STEP_TOPOLOGY_SURFACE_EDGE_ALGORITHM,
primitiveAttributes: {
barycentric: STEP_EDGE_BARYCENTRIC_ATTRIBUTE,
class: STEP_EDGE_CLASS_ATTRIBUTE
},
classCodes: STEP_EDGE_SURFACE_CLASS_CODES,
visibilityClasses: STEP_EDGE_RENDER_VISIBILITY_CLASSES
}
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
import {
buildTransformedDisplayEdgeRuntime,
buildTransformedSelectorRuntime
} from "../lib/selectors/runtime.js";
import {
shouldUseTopologyDisplayEdges
} from "./topologyDisplayEdges.js";
const displayEdgeVisibilityClassRuntimeCache = new WeakMap();
const transformedSelectorRuntimeCache = new WeakMap();
const transformedDisplayEdgeRuntimeCache = new WeakMap();
const MAX_TRANSFORMED_RUNTIME_CACHE_ENTRIES = 32;
export function topologyDisplayEdgeSurfaceOffsetForSettings(edgeSettings = {}) {
return 0;
}
export function applyTopologyDisplayEdgeSurfaceOffset(records, edgeSettings = {}) {
// Topology display edges must depth-test against the true solid surface.
// Pushing the whole mesh away makes close-but-hidden edges bleed through at distance.
for (const record of Array.isArray(records) ? records : []) {
const material = record?.material;
if (!material) {
continue;
}
material.polygonOffset = false;
material.polygonOffsetFactor = 0;
material.polygonOffsetUnits = 0;
material.needsUpdate = true;
}
}
export function disposeTopologyDisplayEdgeObject(object) {
if (!object) {
return;
}
while (object.children?.length) {
disposeTopologyDisplayEdgeObject(object.children[0]);
}
object.parent?.remove?.(object);
if (typeof object.userData?.beforeDispose === "function") {
object.userData.beforeDispose(object);
delete object.userData.beforeDispose;
}
if (object.userData?.disposeGeometry !== false) {
object.geometry?.dispose?.();
}
if (object.userData?.disposeMaterial !== false) {
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) {
material?.dispose?.();
}
}
}
export function matrixHasTransform(matrix, epsilon = 1e-6) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return false;
}
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
return matrix.elements.some((value, index) => Math.abs(Number(value) - identity[index]) > epsilon);
}
export function rowMajorArrayFromMatrix4(matrix) {
if (!matrix?.elements || matrix.elements.length !== 16) {
return null;
}
const elements = matrix.elements;
return [
elements[0], elements[4], elements[8], elements[12],
elements[1], elements[5], elements[9], elements[13],
elements[2], elements[6], elements[10], elements[14],
elements[3], elements[7], elements[11], elements[15],
];
}
function transformSignatureValue(value) {
const number = Number(value);
return Number.isFinite(number) ? String(number) : "0";
}
function transformEntriesSignature(transforms) {
if (!transforms?.size) {
return "";
}
return [...transforms.entries()]
.map(([partId, transform]) => {
const transformValues = Array.isArray(transform) || ArrayBuffer.isView(transform)
? Array.from(transform, transformSignatureValue)
: [];
return `${String(partId || "").trim()}:${transformValues.join(",")}`;
})
.sort()
.join("|");
}
function cachedTransformedRuntime(cache, baseRuntime, transforms, buildRuntime) {
if (!baseRuntime || !transforms?.size) {
return null;
}
const signature = transformEntriesSignature(transforms);
if (!signature) {
return null;
}
let runtimeBySignature = cache.get(baseRuntime);
if (!runtimeBySignature) {
runtimeBySignature = new Map();
cache.set(baseRuntime, runtimeBySignature);
}
if (runtimeBySignature.has(signature)) {
return runtimeBySignature.get(signature);
}
const runtime = buildRuntime(baseRuntime, transforms);
runtimeBySignature.set(signature, runtime);
if (runtimeBySignature.size > MAX_TRANSFORMED_RUNTIME_CACHE_ENTRIES) {
runtimeBySignature.delete(runtimeBySignature.keys().next().value);
}
return runtime;
}
export function selectorTransformsFromDisplayRecords(displayRecords) {
const transforms = new Map();
for (const record of Array.isArray(displayRecords) ? displayRecords : []) {
const partId = String(record?.partId || "").trim();
if (!partId || !record?.effectMatrix || !matrixHasTransform(record.effectMatrix)) {
continue;
}
const transform = rowMajorArrayFromMatrix4(record.effectMatrix);
if (transform) {
transforms.set(partId, transform);
}
}
return transforms;
}
function runtimeHasVisibilityClassRows(runtime) {
return Array.isArray(runtime?.edges) &&
runtime.edges.some((row) => String(row?.visibilityClass || "").trim());
}
function displayEdgeRowsCanUseSelectorClasses(displayEdgeRuntime, selectorRuntime) {
const displayRows = Array.isArray(displayEdgeRuntime?.edges) ? displayEdgeRuntime.edges : [];
const selectorRows = Array.isArray(selectorRuntime?.edges) ? selectorRuntime.edges : [];
const alignedRows = displayRows.every((row, rowIndex) => {
const selectorRow = selectorRows[rowIndex] || {};
const displayStart = Number(row?.segmentStart);
const selectorStart = Number(selectorRow?.segmentStart);
const displayCount = Number(row?.segmentCount);
const selectorCount = Number(selectorRow?.segmentCount);
return (
(!Number.isFinite(displayStart) || !Number.isFinite(selectorStart) || displayStart === selectorStart) &&
(!Number.isFinite(displayCount) || !Number.isFinite(selectorCount) || displayCount === selectorCount)
);
});
return Boolean(
displayRows.length &&
displayRows.length === selectorRows.length &&
alignedRows &&
!runtimeHasVisibilityClassRows(displayEdgeRuntime) &&
runtimeHasVisibilityClassRows(selectorRuntime)
);
}
function runtimeUsesSurfaceOwnedEdges(runtime) {
return Boolean(runtime?.surfaceEdgeRendering || Number(runtime?.schemaVersion || 0) >= 3);
}
export function displayEdgeRuntimeWithSelectorVisibilityClasses(displayEdgeRuntime, selectorRuntime) {
if (!displayEdgeRowsCanUseSelectorClasses(displayEdgeRuntime, selectorRuntime)) {
return displayEdgeRuntime || null;
}
const cached = displayEdgeVisibilityClassRuntimeCache.get(displayEdgeRuntime);
if (cached?.selectorRuntime === selectorRuntime) {
return cached.runtime;
}
const runtime = {
...displayEdgeRuntime,
edges: displayEdgeRuntime.edges.map((row, rowIndex) => ({
...row,
visibilityClass: selectorRuntime.edges[rowIndex]?.visibilityClass || "feature"
}))
};
displayEdgeVisibilityClassRuntimeCache.set(displayEdgeRuntime, { selectorRuntime, runtime });
return runtime;
}
export function resolveTopologyDisplayEdgeRuntimes({
selectorRuntime = null,
displayEdgeRuntime = null,
displayRecords = null,
transformDisplayEdges = true
} = {}) {
const selectorTransforms = selectorTransformsFromDisplayRecords(displayRecords);
const baseDisplayEdgeRuntime = displayEdgeRuntimeWithSelectorVisibilityClasses(displayEdgeRuntime, selectorRuntime);
const transformedSelectorRuntime = selectorTransforms.size && selectorRuntime
? cachedTransformedRuntime(
transformedSelectorRuntimeCache,
selectorRuntime,
selectorTransforms,
buildTransformedSelectorRuntime
)
: null;
const transformedDisplayEdgeRuntime = transformDisplayEdges && selectorTransforms.size && baseDisplayEdgeRuntime
? cachedTransformedRuntime(
transformedDisplayEdgeRuntimeCache,
baseDisplayEdgeRuntime,
selectorTransforms,
buildTransformedDisplayEdgeRuntime
)
: null;
const activeSelectorRuntime = transformedSelectorRuntime || selectorRuntime || null;
const activeDisplayEdgeRuntime = transformedDisplayEdgeRuntime || baseDisplayEdgeRuntime || null;
return {
selectorTransforms,
transformCount: selectorTransforms.size,
transformedSelectorRuntime,
transformedDisplayEdgeRuntime,
selectorRuntime: activeSelectorRuntime,
displayEdgeRuntime: activeDisplayEdgeRuntime,
topologyRuntime: activeDisplayEdgeRuntime || activeSelectorRuntime
};
}
export function shouldRenderTopologyDisplayEdges({
edgesVisible = false,
wireframeMode = false,
cadEdgeSource = false,
displayEdgeRuntime = null,
selectorRuntime = null,
edgeSettings = null
} = {}) {
return Boolean(
edgesVisible &&
!wireframeMode &&
cadEdgeSource &&
!runtimeUsesSurfaceOwnedEdges(displayEdgeRuntime || selectorRuntime) &&
shouldUseTopologyDisplayEdges(displayEdgeRuntime || selectorRuntime, edgeSettings)
);
}
export function shouldUseRecordTopologyEdgeTransforms({
transformDetected = false,
topologyDisplayEdgesVisible = false,
displayEdgeRuntime = null,
displayRecords = []
} = {}) {
return Boolean(
transformDetected &&
topologyDisplayEdgesVisible &&
displayEdgeRuntime &&
Array.isArray(displayRecords) &&
displayRecords.length > 1
);
}
@@ -0,0 +1,185 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyTopologyDisplayEdgeSurfaceOffset,
displayEdgeRuntimeWithSelectorVisibilityClasses,
resolveTopologyDisplayEdgeRuntimes,
rowMajorArrayFromMatrix4,
selectorTransformsFromDisplayRecords,
shouldRenderTopologyDisplayEdges,
topologyDisplayEdgeSurfaceOffsetForSettings
} from "./topologyDisplayEdgeRuntime.js";
const TRANSLATE_2_3_4 = Object.freeze({
elements: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
2, 3, 4, 1
]
});
const TRANSLATE_3_4_5 = Object.freeze({
elements: [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
3, 4, 5, 1
]
});
function displayEdgeRuntime() {
return {
edges: [{ occurrenceId: "part-a" }],
proxy: {
edgePositions: new Float32Array([0, 0, 0, 1, 0, 0]),
edgeIndices: new Uint32Array([0, 1]),
edgeIds: new Uint32Array([0])
}
};
}
test("rowMajorArrayFromMatrix4 converts Three.js matrix storage", () => {
assert.deepEqual(rowMajorArrayFromMatrix4(TRANSLATE_2_3_4), [
1, 0, 0, 2,
0, 1, 0, 3,
0, 0, 1, 4,
0, 0, 0, 1
]);
});
test("selectorTransformsFromDisplayRecords reads part effect transforms", () => {
const transforms = selectorTransformsFromDisplayRecords([
{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 },
{ partId: "identity", effectMatrix: { elements: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1] } }
]);
assert.equal(transforms.size, 1);
assert.deepEqual(transforms.get("part-a"), [
1, 0, 0, 2,
0, 1, 0, 3,
0, 0, 1, 4,
0, 0, 0, 1
]);
});
test("resolveTopologyDisplayEdgeRuntimes prefers transformed display edges", () => {
const baseRuntime = displayEdgeRuntime();
const resolved = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseRuntime,
selectorRuntime: { proxy: {} },
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 }]
});
assert.equal(resolved.transformCount, 1);
assert.equal(resolved.topologyRuntime, resolved.displayEdgeRuntime);
assert.notEqual(resolved.displayEdgeRuntime, baseRuntime);
assert.deepEqual([...resolved.displayEdgeRuntime.proxy.edgePositions], [2, 3, 4, 3, 3, 4]);
});
test("resolveTopologyDisplayEdgeRuntimes reuses stable transformed runtimes", () => {
const baseDisplayRuntime = displayEdgeRuntime();
const baseSelectorRuntime = {
edges: [{ occurrenceId: "part-a" }],
proxy: {}
};
const first = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseDisplayRuntime,
selectorRuntime: baseSelectorRuntime,
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 }]
});
const second = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseDisplayRuntime,
selectorRuntime: baseSelectorRuntime,
displayRecords: [{
partId: "part-a",
effectMatrix: { elements: [...TRANSLATE_2_3_4.elements] }
}]
});
const changed = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseDisplayRuntime,
selectorRuntime: baseSelectorRuntime,
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_3_4_5 }]
});
assert.equal(second.transformedSelectorRuntime, first.transformedSelectorRuntime);
assert.equal(second.transformedDisplayEdgeRuntime, first.transformedDisplayEdgeRuntime);
assert.notEqual(changed.transformedSelectorRuntime, first.transformedSelectorRuntime);
assert.notEqual(changed.transformedDisplayEdgeRuntime, first.transformedDisplayEdgeRuntime);
});
test("resolveTopologyDisplayEdgeRuntimes can skip transformed display edges for record-driven overlays", () => {
const baseRuntime = displayEdgeRuntime();
const resolved = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: baseRuntime,
selectorRuntime: { proxy: {} },
displayRecords: [{ partId: "part-a", effectMatrix: TRANSLATE_2_3_4 }],
transformDisplayEdges: false
});
assert.equal(resolved.transformCount, 1);
assert.equal(resolved.displayEdgeRuntime, baseRuntime);
assert.equal(resolved.transformedDisplayEdgeRuntime, null);
});
test("display edge runtimes inherit selector visibility classes when compact payloads omit them", () => {
const displayRuntime = {
edges: [
{ occurrenceId: "o1", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "o1", segmentStart: 1, segmentCount: 2 }
],
proxy: {}
};
const selectorRuntime = {
edges: [
{ id: "o1.e1", visibilityClass: "feature" },
{ id: "o1.e2", visibilityClass: "tangent" }
],
proxy: {}
};
const enriched = displayEdgeRuntimeWithSelectorVisibilityClasses(displayRuntime, selectorRuntime);
const enrichedAgain = displayEdgeRuntimeWithSelectorVisibilityClasses(displayRuntime, selectorRuntime);
assert.notEqual(enriched, displayRuntime);
assert.equal(enrichedAgain, enriched);
assert.deepEqual(enriched.edges, [
{ occurrenceId: "o1", segmentStart: 0, segmentCount: 1, visibilityClass: "feature" },
{ occurrenceId: "o1", segmentStart: 1, segmentCount: 2, visibilityClass: "tangent" }
]);
const resolved = resolveTopologyDisplayEdgeRuntimes({
displayEdgeRuntime: displayRuntime,
selectorRuntime
});
assert.deepEqual(resolved.displayEdgeRuntime.edges, enriched.edges);
});
test("shouldRenderTopologyDisplayEdges gates CAD topology overlays", () => {
const runtime = displayEdgeRuntime();
assert.equal(shouldRenderTopologyDisplayEdges({
edgesVisible: true,
cadEdgeSource: true,
displayEdgeRuntime: runtime
}), true);
assert.equal(shouldRenderTopologyDisplayEdges({
edgesVisible: true,
wireframeMode: true,
cadEdgeSource: true,
displayEdgeRuntime: runtime
}), false);
assert.equal(shouldRenderTopologyDisplayEdges({
edgesVisible: true,
cadEdgeSource: true,
selectorRuntime: { schemaVersion: 3, surfaceEdgeRendering: true, edges: [] }
}), false);
});
test("surface offset scales for thick topology display edges", () => {
const material = {};
applyTopologyDisplayEdgeSurfaceOffset([{ material }], { thickness: 6 });
assert.equal(topologyDisplayEdgeSurfaceOffsetForSettings({ thickness: 1 }), 0);
assert.equal(topologyDisplayEdgeSurfaceOffsetForSettings({ thickness: 6 }), 0);
assert.equal(material.polygonOffset, false);
assert.equal(material.polygonOffsetFactor, 0);
assert.equal(material.polygonOffsetUnits, 0);
assert.equal(material.needsUpdate, true);
});
@@ -0,0 +1,434 @@
function isFloat32Array(value) {
return value instanceof Float32Array;
}
function isUint32Array(value) {
return value instanceof Uint32Array;
}
function pointDistance(left, right) {
return Math.hypot(
Number(right?.[0] || 0) - Number(left?.[0] || 0),
Number(right?.[1] || 0) - Number(left?.[1] || 0),
Number(right?.[2] || 0) - Number(left?.[2] || 0)
);
}
function boundsForPolylines(polylines) {
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (const positions of polylines) {
for (let offset = 0; offset + 2 < positions.length; offset += 3) {
min[0] = Math.min(min[0], positions[offset]);
min[1] = Math.min(min[1], positions[offset + 1]);
min[2] = Math.min(min[2], positions[offset + 2]);
max[0] = Math.max(max[0], positions[offset]);
max[1] = Math.max(max[1], positions[offset + 1]);
max[2] = Math.max(max[2], positions[offset + 2]);
}
}
if (!Number.isFinite(min[0]) || !Number.isFinite(max[0])) {
return null;
}
return { min, max };
}
function stitchToleranceForPolylines(polylines) {
const bounds = boundsForPolylines(polylines);
if (!bounds) {
return 1e-5;
}
return Math.max(1e-5, pointDistance(bounds.min, bounds.max) * 1e-7);
}
function pointFromProxy(proxy, vertexIndex) {
const offset = Number(vertexIndex) * 3;
if (
!Number.isInteger(vertexIndex) ||
offset < 0 ||
offset + 2 >= proxy.edgePositions.length
) {
return null;
}
return [
proxy.edgePositions[offset],
proxy.edgePositions[offset + 1],
proxy.edgePositions[offset + 2]
];
}
function normalizePartIdSet(value) {
return new Set(
(Array.isArray(value) ? value : [value])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
}
function normalizeVisibilityClassSet(value) {
const values = (Array.isArray(value) ? value : [value])
.map((item) => String(item || "").trim().toLowerCase())
.filter(Boolean);
return values.length ? new Set(values) : null;
}
function occurrenceMatchesPartIds(occurrenceId, partIds) {
const normalizedOccurrenceId = String(occurrenceId || "").trim();
if (!normalizedOccurrenceId || !partIds?.size) {
return false;
}
for (const partId of partIds) {
if (normalizedOccurrenceId === partId || normalizedOccurrenceId.startsWith(`${partId}.`)) {
return true;
}
}
return false;
}
function edgeRowMatchesPartIds(row, partIds) {
return occurrenceMatchesPartIds(row?.occurrenceId || row?.partId || row?.id, partIds);
}
function edgeRowPassesPartFilter(row, includePartIds, excludePartIds) {
if (includePartIds.size && !edgeRowMatchesPartIds(row, includePartIds)) {
return false;
}
if (excludePartIds.size && edgeRowMatchesPartIds(row, excludePartIds)) {
return false;
}
return true;
}
function edgeRowPassesVisibilityClassFilter(row, visibilityClasses) {
if (!visibilityClasses?.size) {
return true;
}
const visibilityClass = String(row?.visibilityClass || "feature").trim().toLowerCase() || "feature";
return visibilityClasses.has(visibilityClass);
}
function edgeRowPassesFilters(row, includePartIds, excludePartIds, visibilityClasses) {
return (
edgeRowPassesPartFilter(row, includePartIds, excludePartIds) &&
edgeRowPassesVisibilityClassFilter(row, visibilityClasses)
);
}
function pushPoint(target, point, tolerance = 1e-7) {
if (!point) {
return;
}
const last = target.length >= 3
? [target[target.length - 3], target[target.length - 2], target[target.length - 1]]
: null;
if (!last || pointDistance(last, point) > tolerance) {
target.push(point[0], point[1], point[2]);
}
}
function buildTopologyPolylinesForRun(proxy, segmentStart, segmentCount) {
const polylines = [];
let current = [];
let lastPoint = null;
for (let segmentIndex = segmentStart; segmentIndex < segmentStart + segmentCount; segmentIndex += 1) {
const startVertexIndex = Number(proxy.edgeIndices[segmentIndex * 2]);
const endVertexIndex = Number(proxy.edgeIndices[(segmentIndex * 2) + 1]);
const startPoint = pointFromProxy(proxy, startVertexIndex);
const endPoint = pointFromProxy(proxy, endVertexIndex);
if (!startPoint || !endPoint || pointDistance(startPoint, endPoint) <= 1e-10) {
continue;
}
if (lastPoint && pointDistance(lastPoint, startPoint) > 1e-5) {
if (current.length >= 6) {
polylines.push(new Float32Array(current));
}
current = [];
lastPoint = null;
}
if (!lastPoint) {
pushPoint(current, startPoint);
}
pushPoint(current, endPoint);
lastPoint = endPoint;
}
if (current.length >= 6) {
polylines.push(new Float32Array(current));
}
return polylines;
}
function buildTopologyPolylinesForEdgeId(proxy, edgeIds, rowIndex, segmentCount) {
const polylines = [];
let runStart = -1;
let runCount = 0;
const flushRun = () => {
if (runStart >= 0 && runCount > 0) {
polylines.push(...buildTopologyPolylinesForRun(proxy, runStart, runCount));
}
runStart = -1;
runCount = 0;
};
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
if (Number(edgeIds[segmentIndex]) === rowIndex) {
if (runStart < 0) {
runStart = segmentIndex;
}
runCount += 1;
} else {
flushRun();
}
}
flushRun();
return polylines;
}
function polylinePoint(positions, vertexIndex) {
const offset = vertexIndex * 3;
return [
positions[offset],
positions[offset + 1],
positions[offset + 2]
];
}
function polylineStart(positions) {
return polylinePoint(positions, 0);
}
function polylineEnd(positions) {
return polylinePoint(positions, Math.floor(positions.length / 3) - 1);
}
function reversePolyline(positions) {
const reversed = [];
for (let offset = positions.length - 3; offset >= 0; offset -= 3) {
reversed.push(positions[offset], positions[offset + 1], positions[offset + 2]);
}
return reversed;
}
function appendPolyline(target, source, { skipFirst = true, tolerance = 1e-7 } = {}) {
const startOffset = skipFirst ? 3 : 0;
for (let offset = startOffset; offset + 2 < source.length; offset += 3) {
pushPoint(target, [source[offset], source[offset + 1], source[offset + 2]], tolerance);
}
}
function closePolylineIfNeeded(positions, tolerance) {
if (positions.length < 12) {
return positions;
}
const start = polylineStart(positions);
const end = polylineEnd(positions);
if (pointDistance(start, end) > tolerance) {
return positions;
}
const output = Array.from(positions);
output[output.length - 3] = output[0];
output[output.length - 2] = output[1];
output[output.length - 1] = output[2];
return new Float32Array(output);
}
export function stitchTopologyDisplayEdgePolylines(polylines) {
if (!Array.isArray(polylines) || polylines.length <= 1) {
return polylines || [];
}
const tolerance = stitchToleranceForPolylines(polylines);
const chains = polylines
.filter((positions) => positions?.length >= 6)
.map((positions) => Array.from(positions));
for (let index = 0; index < chains.length; index += 1) {
let changed = true;
while (changed) {
changed = false;
for (let candidateIndex = index + 1; candidateIndex < chains.length; candidateIndex += 1) {
const current = chains[index];
const candidate = chains[candidateIndex];
const currentStart = polylineStart(current);
const currentEnd = polylineEnd(current);
const candidateStart = polylineStart(candidate);
const candidateEnd = polylineEnd(candidate);
let merged = null;
if (pointDistance(currentEnd, candidateStart) <= tolerance) {
merged = [...current];
appendPolyline(merged, candidate, { tolerance });
} else if (pointDistance(currentEnd, candidateEnd) <= tolerance) {
merged = [...current];
appendPolyline(merged, reversePolyline(candidate), { tolerance });
} else if (pointDistance(currentStart, candidateEnd) <= tolerance) {
merged = [...candidate];
appendPolyline(merged, current, { tolerance });
} else if (pointDistance(currentStart, candidateStart) <= tolerance) {
merged = reversePolyline(candidate);
appendPolyline(merged, current, { tolerance });
}
if (merged) {
chains[index] = merged;
chains.splice(candidateIndex, 1);
changed = true;
break;
}
}
}
}
return chains
.filter((positions) => positions.length >= 6)
.map((positions) => closePolylineIfNeeded(new Float32Array(positions), tolerance));
}
export function hasTopologyDisplayEdgeProxy(selectorRuntime) {
const proxy = selectorRuntime?.proxy || {};
return (
isFloat32Array(proxy.edgePositions) &&
isUint32Array(proxy.edgeIndices) &&
proxy.edgePositions.length >= 6 &&
proxy.edgeIndices.length >= 2
);
}
export function hasTopologyDisplayEdgeClassification(selectorRuntime) {
return hasTopologyDisplayEdgeProxy(selectorRuntime);
}
export function shouldUseTopologyDisplayEdges(selectorRuntime) {
return !selectorRuntime?.surfaceEdgeRendering &&
Number(selectorRuntime?.schemaVersion || 0) < 3 &&
hasTopologyDisplayEdgeProxy(selectorRuntime);
}
export function buildTopologyDisplayEdgePositions(selectorRuntime, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = null
} = {}) {
if (!hasTopologyDisplayEdgeProxy(selectorRuntime)) {
return null;
}
const proxy = selectorRuntime.proxy;
const segmentCount = Math.floor(proxy.edgeIndices.length / 2);
if (segmentCount <= 0) {
return null;
}
const includePartIdSet = normalizePartIdSet(includePartIds);
const excludePartIdSet = normalizePartIdSet(excludePartIds);
const hasPartFilter = includePartIdSet.size > 0 || excludePartIdSet.size > 0;
const visibilityClassSet = normalizeVisibilityClassSet(visibilityClasses);
const edgeRows = Array.isArray(selectorRuntime.edges) ? selectorRuntime.edges : [];
const hasVisibilityClassFilter = Boolean(visibilityClassSet?.size && edgeRows.length);
const hasEdgeRowFilter = hasPartFilter || hasVisibilityClassFilter;
const edgeIds = isUint32Array(proxy.edgeIds) ? proxy.edgeIds : null;
const linePositions = new Float32Array(segmentCount * 6);
let writeOffset = 0;
const writeSegment = (segmentIndex) => {
const startVertexIndex = Number(proxy.edgeIndices[segmentIndex * 2]);
const endVertexIndex = Number(proxy.edgeIndices[(segmentIndex * 2) + 1]);
const startPoint = pointFromProxy(proxy, startVertexIndex);
const endPoint = pointFromProxy(proxy, endVertexIndex);
if (!startPoint || !endPoint || pointDistance(startPoint, endPoint) <= 1e-10) {
return;
}
linePositions[writeOffset] = startPoint[0];
linePositions[writeOffset + 1] = startPoint[1];
linePositions[writeOffset + 2] = startPoint[2];
linePositions[writeOffset + 3] = endPoint[0];
linePositions[writeOffset + 4] = endPoint[1];
linePositions[writeOffset + 5] = endPoint[2];
writeOffset += 6;
};
if (hasEdgeRowFilter) {
if (!edgeRows.length) {
return null;
}
for (let rowIndex = 0; rowIndex < edgeRows.length; rowIndex += 1) {
const row = edgeRows[rowIndex];
if (!edgeRowPassesFilters(row, includePartIdSet, excludePartIdSet, visibilityClassSet)) {
continue;
}
const start = Number(row?.segmentStart || 0);
const count = Number(row?.segmentCount || 0);
if (Number.isInteger(start) && Number.isInteger(count) && start >= 0 && count > 0 && start + count <= segmentCount) {
for (let segmentIndex = start; segmentIndex < start + count; segmentIndex += 1) {
writeSegment(segmentIndex);
}
continue;
}
if (edgeIds) {
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
if (Number(edgeIds[segmentIndex]) === rowIndex) {
writeSegment(segmentIndex);
}
}
}
}
} else {
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
writeSegment(segmentIndex);
}
}
if (writeOffset <= 0) {
return null;
}
return writeOffset === linePositions.length ? linePositions : linePositions.subarray(0, writeOffset);
}
export function buildTopologyDisplayEdgePolylines(selectorRuntime, {
includePartIds = [],
excludePartIds = [],
visibilityClasses = null
} = {}) {
if (!hasTopologyDisplayEdgeProxy(selectorRuntime)) {
return [];
}
const proxy = selectorRuntime.proxy;
const segmentCount = Math.floor(proxy.edgeIndices.length / 2);
if (segmentCount <= 0) {
return [];
}
const edgeRows = Array.isArray(selectorRuntime.edges) ? selectorRuntime.edges : [];
const edgeIds = isUint32Array(proxy.edgeIds) ? proxy.edgeIds : null;
const includePartIdSet = normalizePartIdSet(includePartIds);
const excludePartIdSet = normalizePartIdSet(excludePartIds);
const hasPartFilter = includePartIdSet.size > 0 || excludePartIdSet.size > 0;
const visibilityClassSet = normalizeVisibilityClassSet(visibilityClasses);
const hasVisibilityClassFilter = Boolean(visibilityClassSet?.size && edgeRows.length);
const hasEdgeRowFilter = hasPartFilter || hasVisibilityClassFilter;
const polylines = [];
if (edgeRows.length) {
for (let rowIndex = 0; rowIndex < edgeRows.length; rowIndex += 1) {
const row = edgeRows[rowIndex];
if (!row) {
continue;
}
if (!edgeRowPassesFilters(row, includePartIdSet, excludePartIdSet, visibilityClassSet)) {
continue;
}
const start = Number(row.segmentStart || 0);
const count = Number(row.segmentCount || 0);
if (Number.isInteger(start) && Number.isInteger(count) && start >= 0 && count > 0 && start + count <= segmentCount) {
polylines.push(...buildTopologyPolylinesForRun(proxy, start, count));
} else if (edgeIds) {
polylines.push(...buildTopologyPolylinesForEdgeId(proxy, edgeIds, rowIndex, segmentCount));
}
}
return stitchTopologyDisplayEdgePolylines(polylines);
}
if (hasEdgeRowFilter) {
return [];
}
for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) {
const rowIndex = edgeIds ? Number(edgeIds[segmentIndex]) : -1;
void rowIndex;
polylines.push(...buildTopologyPolylinesForRun(proxy, segmentIndex, 1));
}
return stitchTopologyDisplayEdgePolylines(polylines);
}
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildTopologyDisplayEdgePositions,
buildTopologyDisplayEdgePolylines,
hasTopologyDisplayEdgeClassification,
hasTopologyDisplayEdgeProxy,
shouldUseTopologyDisplayEdges,
stitchTopologyDisplayEdgePolylines
} from "./topologyDisplayEdges.js";
function runtimeWithEdges(edges, options = {}) {
return {
schemaVersion: options.schemaVersion,
edges,
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0,
3, 0, 0,
4, 0, 0,
5, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 2, 3, 4, 5]),
edgeIds: new Uint32Array([0, 1, 2])
}
};
}
test("topology display edges expose proxy availability", () => {
assert.equal(hasTopologyDisplayEdgeProxy(runtimeWithEdges([])), true);
assert.equal(hasTopologyDisplayEdgeProxy({
proxy: {
edgePositions: new Float32Array(0),
edgeIndices: new Uint32Array(0)
}
}), false);
});
test("classified topology display edges expose v2 availability", () => {
assert.equal(hasTopologyDisplayEdgeClassification(runtimeWithEdges([])), true);
assert.equal(hasTopologyDisplayEdgeClassification(runtimeWithEdges([{ relevance: 1 }])), true);
assert.equal(shouldUseTopologyDisplayEdges(runtimeWithEdges([{ relevance: 1 }])), true);
assert.equal(shouldUseTopologyDisplayEdges(runtimeWithEdges([{ relevance: 1 }], { schemaVersion: 3 })), false);
});
test("topology display edges use every valid proxy segment", () => {
const positions = buildTopologyDisplayEdgePositions(runtimeWithEdges([
{ flags: 0, visibilityClass: "feature" },
{ flags: 4, visibilityClass: "seam" },
{ flags: 8, visibilityClass: "tangent" }
]));
assert.deepEqual(Array.from(positions), [
0, 0, 0, 1, 0, 0,
2, 0, 0, 3, 0, 0,
4, 0, 0, 5, 0, 0
]);
});
test("topology display edges can filter by visibility class", () => {
const positions = buildTopologyDisplayEdgePositions(runtimeWithEdges([
{ flags: 0, visibilityClass: "feature" },
{ flags: 4, visibilityClass: "seam" },
{ flags: 8, visibilityClass: "tangent" }
]), { visibilityClasses: ["feature"] });
assert.deepEqual(Array.from(positions), [
0, 0, 0, 1, 0, 0
]);
});
test("topology display edges can filter by focused occurrence ids", () => {
const runtime = runtimeWithEdges([
{ occurrenceId: "part-a", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", segmentStart: 1, segmentCount: 1 },
{ occurrenceId: "part-a.child", segmentStart: 2, segmentCount: 1 }
]);
assert.deepEqual(Array.from(buildTopologyDisplayEdgePositions(runtime, { includePartIds: ["part-a"] })), [
0, 0, 0, 1, 0, 0,
4, 0, 0, 5, 0, 0
]);
assert.deepEqual(Array.from(buildTopologyDisplayEdgePositions(runtime, { excludePartIds: ["part-a"] })), [
2, 0, 0, 3, 0, 0
]);
});
test("topology display edge polylines can filter by visibility class and focused ids", () => {
const runtime = runtimeWithEdges([
{ occurrenceId: "part-a", visibilityClass: "feature", segmentStart: 0, segmentCount: 1 },
{ occurrenceId: "part-b", visibilityClass: "tangent", segmentStart: 1, segmentCount: 1 },
{ occurrenceId: "part-a.child", visibilityClass: "tangent", segmentStart: 2, segmentCount: 1 }
]);
const polylines = buildTopologyDisplayEdgePolylines(runtime, {
includePartIds: ["part-a"],
visibilityClasses: ["tangent"]
});
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
4, 0, 0,
5, 0, 0
]);
});
test("topology display edges preserve continuous CAD edge polylines", () => {
const polylines = buildTopologyDisplayEdgePolylines({
edges: [
{
flags: 0,
visibilityClass: "feature",
segmentStart: 0,
segmentCount: 2
}
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2]),
edgeIds: new Uint32Array([0, 0])
}
});
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
0, 0, 0,
1, 0, 0,
2, 0, 0
]);
});
test("topology display edges stitch adjacent CAD edge fragments", () => {
const polylines = buildTopologyDisplayEdgePolylines({
edges: [
{
flags: 0,
visibilityClass: "tangent",
segmentStart: 0,
segmentCount: 1
},
{
flags: 0,
visibilityClass: "tangent",
segmentStart: 1,
segmentCount: 1
}
],
proxy: {
edgePositions: new Float32Array([
0, 0, 0,
1, 0, 0,
2, 0, 0
]),
edgeIndices: new Uint32Array([0, 1, 1, 2])
}
});
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
0, 0, 0,
1, 0, 0,
2, 0, 0
]);
});
test("topology polyline stitching handles reversed fragments", () => {
const polylines = stitchTopologyDisplayEdgePolylines([
new Float32Array([
0, 0, 0,
1, 0, 0
]),
new Float32Array([
2, 0, 0,
1, 0, 0
])
]);
assert.equal(polylines.length, 1);
assert.deepEqual(Array.from(polylines[0]), [
0, 0, 0,
1, 0, 0,
2, 0, 0
]);
});
test("all topology display edges include every valid proxy segment", () => {
const positions = buildTopologyDisplayEdgePositions(runtimeWithEdges([
{ flags: 0, relevance: 10 },
{ flags: 4, relevance: 0 },
{ flags: 8, relevance: 0 }
]));
assert.deepEqual(Array.from(positions), [
0, 0, 0, 1, 0, 0,
2, 0, 0, 3, 0, 0,
4, 0, 0, 5, 0, 0
]);
});
@@ -0,0 +1,39 @@
export function cadWebGlRendererAttributes({
alpha = true,
antialias = true,
powerPreference = "high-performance",
preserveDrawingBuffer = false,
logarithmicDepthBuffer = true
} = {}) {
return {
alpha,
antialias,
powerPreference,
preserveDrawingBuffer,
logarithmicDepthBuffer
};
}
export function fallbackCadWebGlRendererAttributes(options = {}) {
return {
...cadWebGlRendererAttributes(options),
antialias: false,
powerPreference: "default",
logarithmicDepthBuffer: false
};
}
export function createCadWebGlRenderer(THREE, {
allowFallback = false,
isRecoverableError = () => true,
...options
} = {}) {
try {
return new THREE.WebGLRenderer(cadWebGlRendererAttributes(options));
} catch (error) {
if (!allowFallback || !isRecoverableError(error)) {
throw error;
}
return new THREE.WebGLRenderer(fallbackCadWebGlRendererAttributes(options));
}
}
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cadWebGlRendererAttributes,
fallbackCadWebGlRendererAttributes
} from "./webglRenderer.js";
test("shared CAD WebGL renderer attributes match viewer depth defaults", () => {
assert.deepEqual(cadWebGlRendererAttributes(), {
alpha: true,
antialias: true,
powerPreference: "high-performance",
preserveDrawingBuffer: false,
logarithmicDepthBuffer: true
});
assert.equal(cadWebGlRendererAttributes({ preserveDrawingBuffer: true }).preserveDrawingBuffer, true);
});
test("shared CAD WebGL renderer fallback preserves snapshot/viewer compatibility knobs", () => {
assert.deepEqual(fallbackCadWebGlRendererAttributes(), {
alpha: true,
antialias: false,
powerPreference: "default",
preserveDrawingBuffer: false,
logarithmicDepthBuffer: false
});
});
+4
View File
@@ -0,0 +1,4 @@
export * from "./common/cadScene.js";
export * from "./common/renderModel.js";
export * from "./common/source.js";
export * from "./common/themeSettings.js";
@@ -0,0 +1,531 @@
import { mergeBounds } from "../urdf/kinematics.js";
const IDENTITY_TRANSFORM = Object.freeze([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
function toTransformArray(value) {
if (!Array.isArray(value) || value.length !== 16) {
return [...IDENTITY_TRANSFORM];
}
return value.map((component, index) => Number.isFinite(Number(component)) ? Number(component) : IDENTITY_TRANSFORM[index]);
}
export function assemblyMeshDescriptor(topologyManifest) {
const mesh = topologyManifest?.assembly?.mesh;
return mesh && typeof mesh === "object" ? mesh : null;
}
export function assemblyUsesSelfContainedMesh(topologyManifest) {
return String(assemblyMeshDescriptor(topologyManifest)?.addressing || "").trim() === "gltf-node-extras";
}
export function assemblyRootFromTopology(topologyManifest) {
const root = topologyManifest?.assembly?.root;
return root && typeof root === "object" ? root : null;
}
function toVectorArray(value) {
if (!Array.isArray(value) || value.length < 3) {
return null;
}
const vector = value.slice(0, 3).map((component) => Number(component));
return vector.every((component) => Number.isFinite(component)) ? vector : null;
}
function normalizeMateEndpoint(endpoint) {
if (!endpoint || typeof endpoint !== "object") {
return null;
}
const result = {
part: String(endpoint.part || "").trim(),
frame: String(endpoint.frame || "").trim()
};
const position = toVectorArray(endpoint.position);
const orientation = toVectorArray(endpoint.orientation);
if (position) {
result.position = position;
}
if (orientation) {
result.orientation = orientation;
}
const axes = endpoint.axes && typeof endpoint.axes === "object" ? endpoint.axes : null;
if (axes) {
const normalizedAxes = {};
for (const key of ["x", "y", "z"]) {
const axis = toVectorArray(axes[key]);
if (axis) {
normalizedAxes[key] = axis;
}
}
if (Object.keys(normalizedAxes).length) {
result.axes = normalizedAxes;
}
}
return result.position || result.orientation || result.part || result.frame ? result : null;
}
export function assemblyMatesFromTopology(topologyManifest) {
const mates = topologyManifest?.assemblyMates;
if (!Array.isArray(mates)) {
return [];
}
return mates
.filter((mate) => mate && typeof mate === "object")
.map((mate, index) => {
const id = String(mate.id || `m${index + 1}`).trim() || `m${index + 1}`;
return {
id,
label: String(mate.label || id).trim() || id,
sourceLabel: String(mate.sourceLabel || mate.name || "").trim(),
type: String(mate.type || mate.relation || "mate").trim(),
relation: String(mate.relation || mate.type || "mate").trim(),
fixed: String(mate.fixed || "").trim(),
moving: String(mate.moving || "").trim(),
parameters: mate.parameters && typeof mate.parameters === "object" ? mate.parameters : {},
fixedEndpoint: normalizeMateEndpoint(mate.fixedEndpoint),
movingEndpoint: normalizeMateEndpoint(mate.movingEndpoint)
};
});
}
export function flattenAssemblyLeafParts(root) {
const leafParts = [];
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
const children = Array.isArray(node?.children) ? node.children : [];
if (children.length) {
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
continue;
}
if (String(node?.nodeType || "").trim() === "part") {
leafParts.push(node);
}
}
return leafParts;
}
export function flattenAssemblyNodes(root) {
const nodes = [];
const stack = root ? [root] : [];
while (stack.length) {
const node = stack.pop();
nodes.push(node);
const children = Array.isArray(node?.children) ? node.children : [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return nodes;
}
export function findAssemblyNode(root, nodeId) {
const normalizedNodeId = String(nodeId || "").trim();
if (!root || !normalizedNodeId || normalizedNodeId === "root") {
return root || null;
}
const stack = [root];
while (stack.length) {
const node = stack.pop();
if (String(node?.id || "").trim() === normalizedNodeId) {
return node;
}
const children = Array.isArray(node?.children) ? node.children : [];
for (let index = children.length - 1; index >= 0; index -= 1) {
stack.push(children[index]);
}
}
return null;
}
export function rootAssemblyInspectionNodeId(root) {
return String(root?.id || "").trim() || "root";
}
export function normalizeAssemblyInspectionNodeId(root, nodeId) {
if (!root) {
return "";
}
const rootId = rootAssemblyInspectionNodeId(root);
const normalizedNodeId = String(nodeId || "").trim();
if (!normalizedNodeId || normalizedNodeId === "root" || normalizedNodeId === rootId) {
return rootId;
}
const node = findAssemblyNode(root, normalizedNodeId);
return String(node?.id || "").trim() || rootId;
}
export function assemblyInspectionNode(root, nodeId) {
if (!root) {
return null;
}
return findAssemblyNode(root, normalizeAssemblyInspectionNodeId(root, nodeId)) || root;
}
function directChildAssemblyNodeIds(node) {
return (Array.isArray(node?.children) ? node.children : [])
.map((child) => String(child?.id || "").trim())
.filter(Boolean);
}
export function selectableAssemblyNodeIdsForInspection(root, nodeId) {
const inspectedNode = assemblyInspectionNode(root, nodeId);
return directChildAssemblyNodeIds(inspectedNode);
}
export function treeSelectableAssemblyNodeIdsForInspection(root, nodeId) {
const inspectedNode = assemblyInspectionNode(root, nodeId);
return directChildAssemblyNodeIds(inspectedNode);
}
export function focusedLeafPartIdsForAssemblyInspection(root, nodeId) {
const inspectedNodeId = normalizeAssemblyInspectionNodeId(root, nodeId);
const rootId = rootAssemblyInspectionNodeId(root);
if (!root || !inspectedNodeId || inspectedNodeId === rootId) {
return [];
}
return descendantLeafPartIds(assemblyInspectionNode(root, inspectedNodeId));
}
export function descendantLeafPartIds(node) {
return flattenAssemblyLeafParts(node)
.map((part) => String(part?.id || "").trim())
.filter(Boolean);
}
export function representativeAssemblyLeafPartId(node) {
const nodeId = String(node?.id || "").trim();
if (!node) {
return "";
}
if (String(node?.nodeType || "").trim() === "part") {
return nodeId;
}
const declaredLeafPartIds = Array.isArray(node?.leafPartIds)
? node.leafPartIds.map((id) => String(id || "").trim()).filter(Boolean)
: [];
if (declaredLeafPartIds.length) {
return declaredLeafPartIds[0];
}
return descendantLeafPartIds(node)[0] || nodeId;
}
export function buildAssemblyLeafToNodePickMap(nodes) {
const map = new Map();
for (const node of Array.isArray(nodes) ? nodes : []) {
const nodeId = String(node?.id || "").trim();
if (!nodeId) {
continue;
}
const leafPartIds = Array.isArray(node?.leafPartIds) && node.leafPartIds.length
? node.leafPartIds
: descendantLeafPartIds(node);
for (const leafPartId of leafPartIds) {
const normalizedLeafPartId = String(leafPartId || "").trim();
if (normalizedLeafPartId) {
map.set(normalizedLeafPartId, nodeId);
}
}
}
return map;
}
export function resolveAssemblyPickedPartId(partId, {
pickPartIdMap,
validLeafPartIds = []
} = {}) {
const normalizedPartId = String(partId || "").trim();
if (!normalizedPartId) {
return "";
}
const validLeafPartIdSet = validLeafPartIds instanceof Set
? validLeafPartIds
: new Set(
(Array.isArray(validLeafPartIds) ? validLeafPartIds : [])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const mappedPartId = pickPartIdMap instanceof Map
? String(pickPartIdMap.get(normalizedPartId) || "").trim()
: "";
if (mappedPartId) {
return mappedPartId;
}
if (validLeafPartIdSet.size && validLeafPartIdSet.has(normalizedPartId)) {
return normalizedPartId;
}
return mappedPartId || normalizedPartId;
}
export function leafPartIdsForAssemblySelection(partId, {
assemblyPartMap,
fallbackPartId = "",
validLeafPartIds = []
} = {}) {
const normalizedPartId = String(partId || "").trim();
const validLeafPartIdSet = validLeafPartIds instanceof Set
? validLeafPartIds
: new Set(
(Array.isArray(validLeafPartIds) ? validLeafPartIds : [])
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const leafIdIsValid = (id) => {
return !validLeafPartIdSet.size || validLeafPartIdSet.has(id);
};
const normalizeLeafIds = (leafPartIds) => {
const seen = new Set();
const result = [];
for (const leafPartId of Array.isArray(leafPartIds) ? leafPartIds : []) {
const normalizedLeafPartId = String(leafPartId || "").trim();
if (!normalizedLeafPartId || seen.has(normalizedLeafPartId) || !leafIdIsValid(normalizedLeafPartId)) {
continue;
}
seen.add(normalizedLeafPartId);
result.push(normalizedLeafPartId);
}
return result;
};
if (normalizedPartId) {
const selectedNode = assemblyPartMap instanceof Map
? assemblyPartMap.get(normalizedPartId) || null
: null;
const selectedLeafPartIds = selectedNode
? normalizeLeafIds(descendantLeafPartIds(selectedNode))
: normalizeLeafIds([normalizedPartId]);
if (selectedLeafPartIds.length) {
return selectedLeafPartIds;
}
}
const normalizedFallbackPartId = String(fallbackPartId || "").trim();
return normalizeLeafIds([normalizedFallbackPartId]);
}
export function assemblyBreadcrumb(root, nodeId) {
const normalizedNodeId = String(nodeId || "").trim();
if (!root) {
return [];
}
const path = [];
function visit(node) {
path.push(node);
if (!normalizedNodeId || normalizedNodeId === "root" || String(node?.id || "").trim() === normalizedNodeId) {
return true;
}
for (const child of Array.isArray(node?.children) ? node.children : []) {
if (visit(child)) {
return true;
}
}
path.pop();
return false;
}
return visit(root) ? [...path] : [root];
}
function meshPartId(part) {
return String(part?.occurrenceId || part?.id || "").trim();
}
function meshPartNumericValue(part, key) {
return Math.max(0, Math.floor(Number(part?.[key]) || 0));
}
function meshPartIdMatches(part, ids) {
const partIds = [
String(part?.occurrenceId || "").trim(),
String(part?.id || "").trim()
].filter(Boolean);
return partIds.some((partId) => ids.has(partId));
}
function meshPartIdHasPrefix(part, prefixes) {
const partIds = [
String(part?.occurrenceId || "").trim(),
String(part?.id || "").trim()
].filter(Boolean);
return partIds.some((partId) => prefixes.some((prefix) => partId.startsWith(prefix)));
}
function meshPartsForTopologyLeaf(meshData, manifestPart) {
const allParts = Array.isArray(meshData?.parts) ? meshData.parts : [];
const ids = new Set(
[manifestPart?.occurrenceId, manifestPart?.id]
.map((id) => String(id || "").trim())
.filter(Boolean)
);
const exactParts = allParts.filter((part) => meshPartIdMatches(part, ids));
if (exactParts.length) {
return exactParts;
}
const prefixes = [...ids].map((id) => `${id}.`);
return prefixes.length ? allParts.filter((part) => meshPartIdHasPrefix(part, prefixes)) : [];
}
export function buildSelfContainedAssemblyMeshData(topologyManifest, meshData) {
const assemblyRoot = assemblyRootFromTopology(topologyManifest);
if (!assemblyRoot) {
throw new Error("Assembly topology is missing assembly.root");
}
const manifestParts = flattenAssemblyLeafParts(assemblyRoot);
const partSources = [];
const meshlessLeafPartIds = [];
for (const manifestPart of manifestParts) {
const partId = String(manifestPart?.id || manifestPart?.occurrenceId || "").trim();
const occurrenceId = String(manifestPart?.occurrenceId || manifestPart?.id || "").trim();
const sourceParts = meshPartsForTopologyLeaf(meshData, manifestPart);
if (!sourceParts.length) {
const meshlessLeafPartId = occurrenceId || partId;
if (meshlessLeafPartId) {
meshlessLeafPartIds.push(meshlessLeafPartId);
}
continue;
}
partSources.push({
manifestPart,
sourceParts
});
}
if (!partSources.length) {
throw new Error("Assembly topology did not match any renderable GLB nodes");
}
let totalVertexCount = 0;
let totalIndexCount = 0;
for (const { sourceParts } of partSources) {
for (const sourcePart of sourceParts) {
totalVertexCount += meshPartNumericValue(sourcePart, "vertexCount");
totalIndexCount += meshPartNumericValue(sourcePart, "triangleCount") * 3;
}
}
const sourceVertices = meshData?.vertices || new Float32Array(0);
const sourceNormals = meshData?.normals || new Float32Array(0);
const sourceColors = meshData?.colors || new Float32Array(0);
const sourceSurfaceEdgeBarycentric = meshData?.surfaceEdgeBarycentric || new Float32Array(0);
const sourceSurfaceEdgeClass = meshData?.surfaceEdgeClass || new Uint8Array(0);
const sourceIndices = meshData?.indices || new Uint32Array(0);
const hasSourceColors = sourceColors.length === sourceVertices.length && sourceColors.length > 0;
const hasSurfaceEdgeAttributes =
sourceSurfaceEdgeBarycentric.length === sourceVertices.length &&
sourceSurfaceEdgeClass.length === sourceVertices.length &&
sourceVertices.length > 0;
const vertices = new Float32Array(totalVertexCount * 3);
const normals = new Float32Array(totalVertexCount * 3);
const colors = hasSourceColors ? new Float32Array(totalVertexCount * 3) : new Float32Array(0);
const surfaceEdgeBarycentric = hasSurfaceEdgeAttributes ? new Float32Array(totalVertexCount * 3) : new Float32Array(0);
const surfaceEdgeClass = hasSurfaceEdgeAttributes ? new Uint8Array(totalVertexCount * 3) : new Uint8Array(0);
const indices = new Uint32Array(totalIndexCount);
const parts = [];
let vertexOffset = 0;
let indexOffset = 0;
for (const { manifestPart, sourceParts } of partSources) {
const partId = String(manifestPart?.id || manifestPart?.occurrenceId || "").trim();
const occurrenceId = String(manifestPart?.occurrenceId || manifestPart?.id || "").trim();
const firstMeshPart = sourceParts[0];
const partVertexOffset = vertexOffset;
const partTriangleOffset = Math.floor(indexOffset / 3);
const sourcePartRanges = [];
for (const sourcePart of sourceParts) {
const sourceVertexOffset = meshPartNumericValue(sourcePart, "vertexOffset");
const sourceVertexCount = meshPartNumericValue(sourcePart, "vertexCount");
const sourceTriangleOffset = meshPartNumericValue(sourcePart, "triangleOffset");
const sourceTriangleCount = meshPartNumericValue(sourcePart, "triangleCount");
const rangeTriangleOffset = Math.floor(indexOffset / 3) - partTriangleOffset;
sourcePartRanges.push({
occurrenceId: meshPartId(sourcePart),
primitiveIndex: meshPartNumericValue(sourcePart, "primitiveIndex"),
triangleOffset: rangeTriangleOffset,
triangleCount: sourceTriangleCount
});
const sourcePositionStart = sourceVertexOffset * 3;
const sourcePositionEnd = sourcePositionStart + sourceVertexCount * 3;
vertices.set(sourceVertices.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
if (sourceNormals.length >= sourcePositionEnd) {
normals.set(sourceNormals.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSourceColors && sourceColors.length >= sourcePositionEnd) {
colors.set(sourceColors.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSurfaceEdgeAttributes && sourceSurfaceEdgeBarycentric.length >= sourcePositionEnd) {
surfaceEdgeBarycentric.set(sourceSurfaceEdgeBarycentric.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
if (hasSurfaceEdgeAttributes && sourceSurfaceEdgeClass.length >= sourcePositionEnd) {
surfaceEdgeClass.set(sourceSurfaceEdgeClass.subarray(sourcePositionStart, sourcePositionEnd), vertexOffset * 3);
}
const sourceIndexStart = sourceTriangleOffset * 3;
const sourceIndexEnd = sourceIndexStart + sourceTriangleCount * 3;
for (let index = sourceIndexStart; index < sourceIndexEnd; index += 1) {
indices[indexOffset] = sourceIndices[index] - sourceVertexOffset + vertexOffset;
indexOffset += 1;
}
vertexOffset += sourceVertexCount;
}
const sourcePath = String(manifestPart?.sourcePath || "").trim();
const displayName = String(
manifestPart?.displayName ||
manifestPart?.instancePath ||
manifestPart?.occurrenceId ||
sourcePath ||
firstMeshPart?.label ||
firstMeshPart?.name ||
meshPartId(firstMeshPart)
).trim();
const sourceBounds = mergeBounds(sourceParts.map((part) => part.bounds));
parts.push({
...firstMeshPart,
...manifestPart,
id: partId || occurrenceId || meshPartId(firstMeshPart),
occurrenceId: occurrenceId || partId || meshPartId(firstMeshPart),
name: displayName,
label: displayName,
nodeType: "part",
sourceKind: String(manifestPart?.sourceKind || "").trim(),
sourcePath,
partSourcePath: sourcePath,
sourceBounds,
bounds: manifestPart?.bbox || sourceBounds,
transform: toTransformArray(manifestPart?.worldTransform || manifestPart?.transform),
hasSourceColors: manifestPartUsesSourceColors(manifestPart) && (
hasSourceColors || sourceParts.some((part) => !!part.hasSourceColors || !!part.color)
),
vertexOffset: partVertexOffset,
vertexCount: vertexOffset - partVertexOffset,
triangleOffset: partTriangleOffset,
triangleCount: Math.floor(indexOffset / 3) - partTriangleOffset,
sourcePartRanges,
edgeIndexOffset: 0,
edgeIndexCount: 0
});
}
return {
...meshData,
vertices,
indices,
normals,
colors,
surfaceEdgeBarycentric,
surfaceEdgeClass,
edge_indices: new Uint32Array(0),
parts,
bounds: mergeBounds(parts.map((part) => part.bounds)) || meshData?.bounds,
assemblyRoot,
assemblyMates: assemblyMatesFromTopology(topologyManifest),
meshlessLeafPartIds,
partTransformsBaked: true,
has_source_colors: hasSourceColors
};
}
function manifestPartUsesSourceColors(part) {
return part?.useSourceColors !== false;
}

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