Initial commit
This commit is contained in:
@@ -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`.
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+5056
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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" } });
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AgentStudio } from "@/components/agent-studio";
|
||||
|
||||
export default function Page() {
|
||||
return <AgentStudio />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 与预览资产。",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" } },
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+4
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']");
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user