refactor: add DesignIR distillation pipeline

This commit is contained in:
Jerry
2026-07-27 17:24:04 +08:00
parent 0adaac2b2a
commit 550b339bcc
1128 changed files with 10248 additions and 426666 deletions
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { streamAgentResponse } from "@/lib/chat";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
return streamAgentResponse({
messages: Array.isArray(body.messages) ? body.messages : [],
attachments: Array.isArray(body.attachments) ? body.attachments : [],
viewerContext: Array.isArray(body.viewerContext) ? body.viewerContext : [],
provider: body.provider,
model: body.model,
selectedTaskId: body.selectedTaskId,
});
} catch (error) {
return NextResponse.json({
error: error instanceof Error ? error.message : "Failed to stream chat response.",
}, { status: 500 });
}
}
@@ -0,0 +1,8 @@
import { NextResponse } from "next/server";
import { publicConfig } from "@/lib/config";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json(publicConfig());
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { contentTypeForArtifact, readArtifact } from "@/lib/preview-artifacts";
export const runtime = "nodejs";
export async function GET(
_request: NextRequest,
context: { params: Promise<{ taskId: string; artifactPath: string[] }> },
) {
const { taskId, artifactPath } = await context.params;
const relativePath = artifactPath.join("/");
try {
const artifact = await readArtifact(taskId, relativePath);
return new NextResponse(artifact.data, {
headers: {
"Cache-Control": "no-store",
"Content-Type": contentTypeForArtifact(relativePath),
},
});
} catch (error) {
return NextResponse.json({
error: error instanceof Error ? error.message : "Failed to read artifact.",
}, { status: 404 });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { createModificationRevision } from "@/lib/task-store";
export const runtime = "nodejs";
export async function POST(request: NextRequest, context: { params: Promise<{ taskId: string }> }) {
const { taskId } = await context.params;
const body = await request.json().catch(() => ({}));
try {
const result = await createModificationRevision({
taskId,
instruction: String(body.instruction || ""),
viewerContext: Array.isArray(body.viewerContext) ? body.viewerContext : [],
});
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({
error: error instanceof Error ? error.message : "Failed to create modification revision.",
}, { status: 500 });
}
}
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { readTask } from "@/lib/task-store";
export const runtime = "nodejs";
export async function GET(_request: NextRequest, context: { params: Promise<{ taskId: string }> }) {
const { taskId } = await context.params;
try {
return NextResponse.json(await readTask(taskId));
} catch (error) {
return NextResponse.json({
error: error instanceof Error ? error.message : "Failed to read task.",
}, { status: 404 });
}
}
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { ensureTask, upsertManifest } from "@/lib/task-store";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => ({}));
const task = await ensureTask(body.taskId);
const manifest = await upsertManifest(task.taskId, {
request: String(body.request || "CAD Agent Studio task"),
studio: {
createdAt: new Date().toISOString(),
},
});
return NextResponse.json({ taskId: task.taskId, dir: task.dir, manifest });
}
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { validateUpload } from "@/lib/upload-validation";
import { writeUploadedFile } from "@/lib/task-store";
export const runtime = "nodejs";
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file");
const taskId = String(formData.get("taskId") || "").trim() || undefined;
if (!(file instanceof File)) {
return NextResponse.json({ error: "Missing file upload." }, { status: 400 });
}
const validation = validateUpload(file.name, file.type, file.size);
if (!validation.ok) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const data = Buffer.from(await file.arrayBuffer());
const attachment = await writeUploadedFile({
taskId,
filename: file.name,
mime: file.type || "application/octet-stream",
data,
});
return NextResponse.json(attachment);
}
+89
View File
@@ -0,0 +1,89 @@
@import "tailwindcss";
:root {
color-scheme: dark;
--background: #111315;
--foreground: #e8e8e3;
--panel: #171a1d;
--panel-2: #1e2226;
--muted: #969b9f;
--border: #2d3339;
--accent: #7dc8cf;
--accent-2: #d8b66c;
--danger: #e56f72;
--radius: 0.625rem;
--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-glass-blur: 26px;
--ui-glass-saturation: 1.4;
--ui-glass-surface: rgb(22 22 25 / 82%);
--ui-glass-popover: rgb(24 24 27 / 86%);
--ui-glass-control: rgb(13 13 16 / 64%);
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
background: var(--background);
color: var(--foreground);
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: rgb(125 200 207 / 0.24);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #3a424a 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;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "CAD Agent Studio",
description: "Agent chat with CAD Viewer preview and structured viewer tools.",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { AgentStudio } from "@/components/agent-studio";
export default function Page() {
return <AgentStudio />;
}
@@ -0,0 +1,652 @@
"use client";
import type { DragEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AssistantRuntimeProvider,
ComposerPrimitive,
MessagePartPrimitive,
MessagePrimitive,
ThreadPrimitive,
useMessage,
type DataMessagePartProps,
type ToolCallMessagePartProps,
} from "@assistant-ui/react";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import type { UIMessage } from "ai";
import { CadViewerPreview } from "@/components/cad-viewer-preview";
import { AiSelectionMode } from "@/lib/cad-edit-tools";
import {
Bot,
Box,
Check,
CircleAlert,
FileImage,
FileUp,
Loader2,
MessageSquare,
Paperclip,
Send,
Settings2,
Sparkles,
Square,
Wrench,
} from "lucide-react";
type Attachment = {
id: string;
taskId: string;
name: string;
kind: "image" | "step" | "source" | "manifest" | "other";
path: string;
size: number;
sha256: string;
mime: string;
artifactUrl?: string;
previewPath?: string;
previewUrl?: string;
viewerAssetPath?: string;
viewerAssetUrl?: string;
previewError?: string;
};
type ViewerContext = {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: string;
};
type CadResult = {
taskId: string;
sourcePath?: string;
artifactPath?: string;
artifactUrl?: string;
previewPath?: string;
previewUrl?: string;
viewerAssetPath?: string;
viewerAssetUrl?: string;
summary?: string;
};
type CadProgress = {
step: string;
label: string;
status: "pending" | "running" | "success" | "error";
message?: string;
};
type CadError = {
stage?: string;
message: string;
};
type PublicConfig = {
defaultProvider: string;
defaultModel: string;
providers: Record<string, {
type: string;
baseURL: string;
configured: boolean;
models: Record<string, string>;
}>;
uploads: {
maxImageMB: number;
maxStepMB: number;
};
};
const initialMessages: UIMessage[] = [{
id: "welcome",
role: "assistant",
parts: [{
type: "text",
text: "CAD Agent Studio 已就绪。上传图片、STEP 或其他文件,或直接描述要生成/修改的模型。",
}],
}];
const DEEPSEEK_PROVIDER_ID = "deepseek";
function uniqueId(prefix: string) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function formatBytes(size: number) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function isStepFile(file: File) {
return /\.(step|stp)$/i.test(file.name);
}
function extractCadResult(text: string) {
const match = /::cad-result\{base64="([^"]+)"\}/.exec(text);
if (!match) return null;
try {
return JSON.parse(atob(match[1])) as CadResult;
} catch {
return null;
}
}
function setIfChanged(setter: (value: string | ((current: string) => string)) => void, nextValue = "") {
setter((current) => (current === nextValue ? current : nextValue));
}
function UploadedAttachmentCard({ attachment }: { attachment: Attachment }) {
return (
<div className="flex min-w-0 items-center gap-2 px-1 py-1 text-[11px] text-[#a9b0b6]">
{attachment.kind === "image" ? <FileImage className="size-3.5 text-[#d8b66c]" /> : <FileUp className="size-3.5 text-[#7dc8cf]" />}
<span className="truncate">{attachment.name}</span>
{attachment.kind === "step" ? (
<span className="shrink-0 text-[10px] text-[#9fbfc3]">重建参考</span>
) : null}
<span className="ml-auto shrink-0 text-[#969b9f]">{formatBytes(attachment.size)}</span>
</div>
);
}
function CadProgressCard({ data }: DataMessagePartProps<CadProgress>) {
const icon = data.status === "running"
? <Loader2 className="size-3 animate-spin text-[#7dc8cf]" />
: data.status === "success"
? <Check className="size-3 text-[#9cd67a]" />
: data.status === "error"
? <CircleAlert className="size-3 text-[#f2b0b2]" />
: <Loader2 className="size-3 animate-spin text-[#969b9f]" />;
return (
<div className="my-1.5 pl-1 text-[12px]">
<div className="flex items-center gap-2 text-[#d7dcdd]">
{icon}
<span className="font-medium">{data.label}</span>
<span className="text-[10px] uppercase tracking-wide text-[#777e84]">{data.status}</span>
</div>
{data.message ? <div className="ml-5 mt-0.5 text-[11px] leading-4 text-[#969b9f]">{data.message}</div> : null}
</div>
);
}
function CadArtifactCard({ data }: DataMessagePartProps<CadResult>) {
return (
<div className="my-2 pl-1 text-[12px]">
<div className="flex items-center gap-2 font-medium text-[#dff5f6]">
<Box className="size-3 text-[#7dc8cf]" />
<span>CAD 结果</span>
</div>
<div className="ml-5 mt-1 grid gap-0.5 text-[11px] text-[#a9b0b6]">
<div>{data.taskId}</div>
{data.sourcePath ? <div>{data.sourcePath}</div> : null}
{data.artifactPath ? <div>{data.artifactPath}</div> : null}
</div>
{data.artifactUrl ? (
<a className="ml-5 mt-1 inline-flex text-[11px] text-[#8bd5dc] underline-offset-2 hover:underline" href={data.artifactUrl} download>
下载 STEP
</a>
) : null}
</div>
);
}
function CadErrorCard({ data }: DataMessagePartProps<CadError>) {
return (
<div className="my-1.5 pl-1 text-[12px] text-[#f2b0b2]">
<div className="flex items-center gap-2 font-medium">
<CircleAlert className="size-3" />
<span>{data.stage ? `${data.stage} 失败` : "CAD 操作失败"}</span>
</div>
<div className="ml-5 mt-0.5 whitespace-pre-wrap text-[11px] leading-4">{data.message}</div>
</div>
);
}
function CadToolCallCard(props: ToolCallMessagePartProps) {
const status = String(props.status?.type || props.status || "");
return (
<div className="my-1.5 pl-1 text-[12px]">
<div className="flex items-center gap-2 text-[#d7dcdd]">
{status.includes("running") || status.includes("input-streaming") ? <Loader2 className="size-3 animate-spin text-[#7dc8cf]" /> : <Wrench className="size-3 text-[#7dc8cf]" />}
<span>{props.toolName}</span>
<span className="text-[10px] uppercase tracking-wide text-[#777e84]">{status || "tool call"}</span>
</div>
</div>
);
}
function DataFallback({ name, data }: DataMessagePartProps) {
return (
<div className="my-1.5 pl-1 text-[11px] text-[#969b9f]">
{name}: {JSON.stringify(data).slice(0, 500)}
</div>
);
}
function MessageText() {
return (
<MessagePrimitive.Parts
components={{
Text: () => (
<p className="whitespace-pre-wrap">
<MessagePartPrimitive.Text />
</p>
),
data: {
by_name: {
"cad-progress": CadProgressCard,
"cad-result": CadArtifactCard,
"cad-error": CadErrorCard,
},
Fallback: DataFallback,
},
tools: {
Fallback: CadToolCallCard,
},
}}
/>
);
}
function UserMessage() {
return (
<MessagePrimitive.Root className="py-3 pl-8 pr-1 text-[12px] leading-5">
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold text-[#969b9f]">
<Sparkles className="size-3.5" />
You
</div>
<MessageText />
</MessagePrimitive.Root>
);
}
function AssistantMessage() {
return (
<MessagePrimitive.Root className="py-3 pr-1 text-[12px] leading-5">
<div className="mb-2 flex items-center gap-2 text-[11px] font-semibold text-[#969b9f]">
<Bot className="size-3.5 text-[#7dc8cf]" />
Agent
</div>
<MessageText />
<MessagePrimitive.Error>
<div className="mt-2 text-[11px] text-[#f2b0b2]">
Agent 请求失败,请检查服务端日志和模型配置。
</div>
</MessagePrimitive.Error>
</MessagePrimitive.Root>
);
}
function AnyMessage() {
const message = useMessage();
if (message.role === "user") return <UserMessage />;
if (message.role === "assistant") return <AssistantMessage />;
return null;
}
export function AgentStudio() {
const [config, setConfig] = useState<PublicConfig | null>(null);
const [provider, setProvider] = useState(DEEPSEEK_PROVIDER_ID);
const [model, setModel] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [pendingViewerContexts, setPendingViewerContexts] = useState<ViewerContext[]>([]);
const [currentTaskId, setCurrentTaskId] = useState("");
const [previewUrl, setPreviewUrl] = useState("");
const [artifactUrl, setArtifactUrl] = useState("");
const [artifactPath, setArtifactPath] = useState("");
const [viewerAssetUrl, setViewerAssetUrl] = useState("");
const [activeEditToolId, setActiveEditToolId] = useState("");
const [aiSelectionMode, setAiSelectionMode] = useState<AiSelectionMode>("point");
const [uploading, setUploading] = useState(false);
const [draggingUpload, setDraggingUpload] = useState(false);
const [error, setError] = useState("");
const dragDepthRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const applyCadResult = useCallback((result: CadResult) => {
if (!result.taskId) return;
setIfChanged(setCurrentTaskId, result.taskId);
setIfChanged(setArtifactPath, result.artifactPath || "");
setIfChanged(setArtifactUrl, result.artifactUrl || "");
setIfChanged(setPreviewUrl, result.previewUrl || "");
setIfChanged(setViewerAssetUrl, result.viewerAssetUrl || "");
setActiveEditToolId("");
setAiSelectionMode("point");
}, []);
const handleData = useCallback((dataPart: { type?: string; data?: unknown }) => {
if (dataPart.type === "data-cad-result") {
applyCadResult(dataPart.data as CadResult);
setPendingViewerContexts([]);
return;
}
if (dataPart.type === "data-cad-error") {
const data = dataPart.data as CadError;
setError(data.message || "CAD 生成失败");
}
}, [applyCadResult]);
const transport = useMemo(() => new AssistantChatTransport<UIMessage>({
api: "/api/chat",
body: {
provider: DEEPSEEK_PROVIDER_ID,
model,
attachments,
viewerContext: pendingViewerContexts,
selectedTaskId: currentTaskId,
},
}), [attachments, currentTaskId, model, pendingViewerContexts]);
const runtime = useChatRuntime({
messages: initialMessages,
transport,
onData: handleData,
onError: (nextError) => {
setError(nextError instanceof Error ? nextError.message : "Agent 回复失败");
},
});
useEffect(() => {
void fetch("/api/config")
.then((response) => response.json())
.then((nextConfig: PublicConfig) => {
setConfig(nextConfig);
setProvider(DEEPSEEK_PROVIDER_ID);
const deepseekProvider = nextConfig.providers[DEEPSEEK_PROVIDER_ID];
setModel(deepseekProvider?.models?.default || "");
})
.catch((nextError) => {
setError(nextError instanceof Error ? nextError.message : "配置加载失败");
});
}, []);
const providerModels = useMemo(() => {
const models = config?.providers?.[DEEPSEEK_PROVIDER_ID]?.models || {};
return Object.entries(models);
}, [config]);
const pushViewerContext = useCallback((type: string, payload: Record<string, unknown>) => {
if (!currentTaskId) {
setError("请先生成或上传一个模型,再使用右侧工具栏。");
return;
}
const context: ViewerContext = {
id: uniqueId("viewer"),
type,
createdAt: new Date().toISOString(),
payload,
};
setPendingViewerContexts((current) => {
const nextCurrent = type === "cadViewer.selection"
? current.filter((item) => item.type !== "cadViewer.selection")
: current;
return [context, ...nextCurrent].slice(0, 8);
});
}, [currentTaskId]);
const handleEditIntent = useCallback((intent: Record<string, unknown>) => {
if (!currentTaskId) return;
pushViewerContext("cadViewer.editIntent", {
...intent,
viewerState: {
viewerAssetUrl,
renderer: "cad-viewer.ported",
},
});
}, [currentTaskId, pushViewerContext, viewerAssetUrl]);
const handleGeometrySelection = useCallback((selectionIntent: Record<string, unknown>) => {
if (!currentTaskId) return;
pushViewerContext("cadViewer.selection", {
...selectionIntent,
viewerState: {
viewerAssetUrl,
renderer: "cad-viewer.ported",
},
});
}, [currentTaskId, pushViewerContext, viewerAssetUrl]);
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files?.length) return;
setUploading(true);
setError("");
try {
for (const file of Array.from(files)) {
const form = new FormData();
form.append("file", file);
const stepUpload = isStepFile(file);
if (currentTaskId && !stepUpload) {
form.append("taskId", currentTaskId);
}
const response = await fetch("/api/upload", {
method: "POST",
body: form,
});
const attachment = await response.json();
if (!response.ok) {
throw new Error(attachment.error || `${file.name} 上传失败`);
}
setAttachments((current) => [attachment, ...current]);
if (attachment.kind === "step") {
continue;
}
setCurrentTaskId(attachment.taskId);
if (attachment.artifactUrl) {
setArtifactUrl(attachment.artifactUrl);
setArtifactPath(attachment.path);
}
if (attachment.previewUrl) {
setPreviewUrl(attachment.previewUrl);
}
if (attachment.viewerAssetUrl) {
setViewerAssetUrl(attachment.viewerAssetUrl);
}
if (attachment.previewError) {
setError(`STEP 已上传,但预览网格生成失败:${attachment.previewError}`);
}
}
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : "上传失败");
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}, [currentTaskId]);
const handleDragEnter = useCallback((event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current += 1;
if (event.dataTransfer.types.includes("Files")) {
setDraggingUpload(true);
}
}, []);
const handleDragOver = useCallback((event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
if (event.dataTransfer.types.includes("Files")) {
setDraggingUpload(true);
}
}, []);
const handleDragLeave = useCallback((event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) {
setDraggingUpload(false);
}
}, []);
const handleDrop = useCallback((event: DragEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
dragDepthRef.current = 0;
setDraggingUpload(false);
void handleUpload(event.dataTransfer.files);
}, [handleUpload]);
return (
<AssistantRuntimeProvider runtime={runtime}>
<main className="flex h-screen min-h-0 flex-col bg-[#111315] text-[#e8e8e3]">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-[#2d3339] bg-[#15181b] px-3">
<div className="flex min-w-0 items-center gap-2">
<Box className="size-4 text-[#7dc8cf]" aria-hidden="true" />
<div className="truncate text-sm font-semibold">CAD Agent Studio</div>
{currentTaskId ? (
<div className="hidden truncate rounded border border-[#2d3339] px-2 py-1 text-[11px] text-[#969b9f] sm:block">
{currentTaskId}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<div className="flex h-8 items-center gap-1.5 rounded border border-[#2d3339] bg-[#1e2226] px-2 text-[11px] text-[#c9cccf]">
<Settings2 className="size-3.5" aria-hidden="true" />
<span>DeepSeek</span>
</div>
<label className="hidden h-8 items-center gap-1.5 rounded border border-[#2d3339] bg-[#1e2226] px-2 text-[11px] text-[#c9cccf] md:flex">
<Sparkles className="size-3.5" aria-hidden="true" />
<select
className="max-w-40 bg-transparent outline-none"
value={model}
onChange={(event) => setModel(event.target.value)}
>
{providerModels.map(([label, value]) => (
<option key={`${label}:${value}`} value={value}>{label}: {value}</option>
))}
</select>
</label>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[420px_minmax(0,1fr)]">
<section
className="relative flex min-h-0 flex-col border-r border-[#2d3339] bg-[#171a1d]"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{draggingUpload ? (
<div className="pointer-events-none absolute inset-2 z-40 grid place-items-center rounded-md border border-dashed border-[#7dc8cf] bg-[#101315]/88 text-center shadow-[0_0_0_999px_rgba(17,19,21,0.42)] backdrop-blur-sm">
<div>
<FileUp className="mx-auto mb-2 size-7 text-[#7dc8cf]" />
<div className="text-sm font-semibold text-[#f2f4f4]">拖放文件上传</div>
<div className="mt-1 text-[12px] text-[#a9b0b6]">支持图片、STEP/STP 和其他参考文件</div>
</div>
</div>
) : null}
<div className="flex h-10 shrink-0 items-center justify-between border-b border-[#2d3339] px-3">
<div className="flex items-center gap-2 text-xs font-semibold">
<MessageSquare className="size-4 text-[#7dc8cf]" aria-hidden="true" />
Agent
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => void handleUpload(event.target.files)}
/>
</div>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="scrollbar-thin min-h-0 flex-1 overflow-y-auto px-3 py-3">
{error ? (
<div className="mb-3 flex gap-2 rounded border border-[#7a3438] bg-[#2a171a] p-2 text-[12px] text-[#f2b0b2]">
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
) : null}
{attachments.length ? (
<div className="mb-3 border-b border-[#252b30] pb-2">
{attachments.slice(0, 5).map((attachment) => (
<UploadedAttachmentCard key={attachment.id} attachment={attachment} />
))}
</div>
) : null}
<div className="divide-y divide-[#252b30]">
<ThreadPrimitive.Messages components={{ Message: AnyMessage, UserMessage, AssistantMessage }} />
</div>
</ThreadPrimitive.Viewport>
<div className="shrink-0 border-t border-[#2d3339] p-3">
<ComposerPrimitive.Root className="grid gap-2">
<ComposerPrimitive.Input
placeholder="描述要生成或修改的 CAD 模型..."
submitMode="enter"
className="max-h-44 min-h-24 w-full resize-none rounded border border-[#2d3339] bg-[#111315] p-2 text-[12px] leading-5 text-[#e8e8e3] outline-none focus:border-[#7dc8cf]"
/>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-[#969b9f]">
<Check className="size-3.5" />
<span className="truncate">Enter 发送,Shift + Enter 换行</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex h-8 items-center gap-1 rounded border border-[#2d3339] px-2 text-[11px] text-[#c9cccf] hover:bg-[#22272c] disabled:opacity-60"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
title="上传文件"
aria-label="上传文件"
>
{uploading ? <Loader2 className="size-3.5 animate-spin" /> : <Paperclip className="size-3.5" />}
上传
</button>
<ComposerPrimitive.Send className="inline-flex h-8 items-center gap-1.5 rounded bg-[#7dc8cf] px-3 text-[12px] font-semibold text-[#101315] hover:bg-[#91d4da] disabled:cursor-not-allowed disabled:opacity-50">
<Send className="size-3.5" />
发送
</ComposerPrimitive.Send>
</div>
</div>
</ComposerPrimitive.Root>
</div>
</ThreadPrimitive.Root>
</section>
<section className="relative min-h-[42vh] bg-[#0d0f11] md:min-h-0">
{viewerAssetUrl ? (
<CadViewerPreview
taskId={currentTaskId}
artifactPath={artifactPath}
viewerAssetUrl={viewerAssetUrl}
activeEditToolId={activeEditToolId}
aiSelectionMode={aiSelectionMode}
onSelectTool={setActiveEditToolId}
onSelectionModeChange={setAiSelectionMode}
onEditIntent={handleEditIntent}
onGeometrySelection={handleGeometrySelection}
/>
) : (
<div className="flex h-full min-h-[42vh] items-center justify-center p-6">
<div className="max-w-md rounded border border-[#2d3339] bg-[#15181b] p-5 text-center">
<Square className="mx-auto mb-3 size-7 text-[#7dc8cf]" />
<div className="text-sm font-semibold">3D 预览等待模型</div>
<div className="mt-2 text-[12px] leading-5 text-[#969b9f]">
上传文件会进入 Agent 上下文。生成、重建或修改完成后会切换到真实输出结果。
</div>
</div>
</div>
)}
{artifactUrl ? (
<a
className="absolute right-24 top-4 z-30 inline-flex h-7 items-center gap-1 rounded-md border border-[#2d3339] bg-[#15181b]/95 px-2 text-[11px] text-[#c9cccf] hover:bg-[#20252a]"
href={artifactUrl}
download
>
<FileUp className="size-3.5" />
STEP
</a>
) : null}
</section>
</div>
</main>
</AssistantRuntimeProvider>
);
}
@@ -0,0 +1,984 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
import { EmbeddedCadEditToolbar } from "@/components/embedded-cad-toolbar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { FloatingPanel } from "@/components/ui/panel";
import { Select } from "@/components/ui/select";
import {
AiSelectionMode,
CAD_EDIT_TOOLS,
buildEmbeddedCadEditIntent,
buildEmbeddedGeometrySelectionIntent,
cadEditToolNextPickKind,
cadEditToolPickComplete,
cadEditToolForOperation,
defaultCadEditParameters,
} from "@/lib/cad-edit-tools";
import CadViewer from "@/viewer-port/components/CadViewer";
import { RENDER_FORMAT } from "@/viewer-port/workbench/constants";
import {
loadRenderDisplayEdgeBundle,
loadRenderGlb,
loadRenderSelectorBundle,
} from "cadjs/lib/renderAssetClient";
import {
buildDisplayEdgeRuntime,
buildSelectorRuntime,
} from "cadjs/lib/selectors/runtime";
type LoadState =
| { kind: "idle" | "loading" }
| {
kind: "ready";
viewerAssetUrl: string;
meshData: unknown;
selectorRuntime: Record<string, unknown> | null;
displayEdgeRuntime: Record<string, unknown> | null;
references: Array<Record<string, unknown>>;
isRefreshing?: boolean;
}
| { kind: "error"; message: string };
type EditToolParameters = Record<string, string | number>;
type AiSelectionDraft = {
active?: boolean;
points?: Array<{ x: number; y: number }>;
} | null;
const EMPTY_REFERENCES: Array<Record<string, unknown>> = [];
const EMPTY_REFERENCE_IDS: string[] = [];
const EMPTY_EDIT_PARAMETERS: EditToolParameters = {};
const VIEWPORT_FRAME_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
function referenceId(value: unknown) {
return String((value as { referenceId?: string })?.referenceId || "").trim();
}
function uniqueStringList(values: unknown[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = String(value || "").trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function selectionReferenceIds(selection: Record<string, unknown> | null | undefined, { includePick = false } = {}) {
const entities = Array.isArray(selection?.entities) ? selection.entities : [];
const values = [
...(Array.isArray(selection?.referenceIds) ? selection.referenceIds : []),
...entities.map((entity) => referenceId(entity)),
];
if (includePick) {
values.push(referenceId(selection?.pick));
}
return uniqueStringList(values);
}
function runtimeReferenceId(value: unknown) {
const record = value as { id?: string; referenceId?: string };
return String(record?.id || record?.referenceId || "").trim();
}
function referenceKind(reference: Record<string, unknown>) {
return String(reference.selectorType || reference.entityType || "").toLowerCase();
}
function finiteVector(value: unknown) {
const raw = Array.isArray(value)
? value
: value && typeof value === "object"
? [(value as { x?: unknown }).x, (value as { y?: unknown }).y, (value as { z?: unknown }).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 vectorSignature(value: unknown) {
return (finiteVector(value) || [])
.map((component) => component.toFixed(5))
.join(",");
}
function editPickSignature(value: Record<string, unknown> | null) {
if (!value) return "";
return [
String(value.kind || ""),
referenceId(value),
vectorSignature(value.pointModel),
vectorSignature(value.pointWorld),
Number.isFinite(Number(value.clientX)) ? Number(value.clientX).toFixed(1) : "",
Number.isFinite(Number(value.clientY)) ? Number(value.clientY).toFixed(1) : "",
].join("|");
}
function compactSurface(surface: unknown) {
const record = surface && typeof surface === "object" ? surface as Record<string, unknown> : {};
return {
type: String(record.type || "").trim(),
radius: Number.isFinite(Number(record.radius)) ? Number(record.radius) : null,
origin: finiteVector(record.origin),
axis: finiteVector(record.axis),
normal: finiteVector(record.normal),
xDir: finiteVector(record.xDir),
yDir: finiteVector(record.yDir),
};
}
function compactReference(reference: Record<string, unknown>) {
const pickData = reference.pickData && typeof reference.pickData === "object"
? reference.pickData as Record<string, unknown>
: {};
return {
referenceId: runtimeReferenceId(reference),
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: referenceKind(reference),
rowIndex: Number.isInteger(Number(reference.rowIndex ?? pickData.rowIndex))
? Number(reference.rowIndex ?? pickData.rowIndex)
: null,
center: finiteVector(pickData.center || reference.center),
normal: finiteVector(pickData.normal || reference.normal || (pickData.surface as { normal?: unknown })?.normal),
surfaceType: String(pickData.surfaceType || (pickData.surface as { type?: unknown })?.type || reference.surfaceType || "").trim(),
surface: compactSurface(pickData.surface || reference.surface),
bbox: pickData.bbox || reference.bbox || null,
adjacentSelectors: Array.isArray(pickData.adjacentSelectors) ? pickData.adjacentSelectors : [],
loopCount: Number.isFinite(Number(reference.loopCount ?? pickData.loopCount)) ? Number(reference.loopCount ?? pickData.loopCount) : null,
};
}
function buildSingleReferenceSelection(reference: Record<string, unknown>, referenceId: string) {
const entity = Object.keys(reference).length ? compactReference(reference) : { referenceId };
return {
kind: "single_reference_activation",
selectionSource: "single_click",
scope: "selected_reference_only",
referenceIds: [referenceId],
entities: [entity],
instruction: "Only modify the single selected topology reference unless the user explicitly asks to modify every repeated/symmetric feature.",
};
}
function buildSlotSelection(picks: Array<Record<string, unknown>>) {
const start = picks[0];
const end = picks[1];
return {
kind: "surface_segment",
referenceId: referenceId(start),
selector: String(start?.selector || ""),
partId: String(start?.partId || ""),
entityType: String(start?.entityType || ""),
selectorType: String(start?.selectorType || ""),
start,
end,
normal: start?.normal || end?.normal || null,
surface: start?.surface || end?.surface || null,
};
}
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, pick: Record<string, unknown>, index: number) {
if (activeToolId === "add_slot") {
return index === 0 ? "起点" : "终点";
}
if (pick?.kind === "edge") {
return "边";
}
if (activeToolId === "add_hole_pattern") {
return "中心";
}
return "位置";
}
function numericEditParameter(parameters: EditToolParameters, name: string, fallback: number) {
const value = Number(parameters?.[name]);
return Number.isFinite(value) ? value : fallback;
}
function previewPixelsFromMm(valueMm: number, {
scale = 5,
min = 12,
max = 180,
} = {}) {
const value = Math.abs(Number(valueMm));
if (!Number.isFinite(value) || value <= 0) {
return min;
}
return Math.min(Math.max(value * scale, min), max);
}
function virtualPreviewSpec(activeToolId: string, parameters: EditToolParameters, anchorPoint: { x: number; y: number } | null) {
if (!activeToolId || !anchorPoint) {
return null;
}
if (activeToolId === "add_hole") {
return {
kind: "circle",
point: anchorPoint,
diameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 2)),
};
}
if (activeToolId === "add_counterbore") {
return {
kind: "double_circle",
point: anchorPoint,
innerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 3)),
outerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "counterboreDiameter", 6)),
};
}
if (activeToolId === "add_countersink") {
return {
kind: "countersink",
point: anchorPoint,
innerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 3)),
outerDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "countersinkDiameter", 6)),
};
}
if (activeToolId === "add_circular_pocket") {
return {
kind: "circle",
point: anchorPoint,
diameterPx: previewPixelsFromMm(numericEditParameter(parameters, "diameter", 8), { scale: 4, min: 18, max: 220 }),
};
}
if (activeToolId === "add_pocket") {
return {
kind: "rect",
point: anchorPoint,
widthPx: previewPixelsFromMm(numericEditParameter(parameters, "width", 10), { scale: 4, min: 24, max: 240 }),
heightPx: previewPixelsFromMm(numericEditParameter(parameters, "height", 6), { scale: 4, min: 18, max: 180 }),
};
}
if (activeToolId === "add_hole_pattern") {
return {
kind: "hole_pattern",
point: anchorPoint,
holeDiameterPx: previewPixelsFromMm(numericEditParameter(parameters, "holeDiameter", 2), { scale: 4, min: 8, max: 48 }),
rows: Math.min(Math.max(Math.round(numericEditParameter(parameters, "rows", 2)), 1), 8),
columns: Math.min(Math.max(Math.round(numericEditParameter(parameters, "columns", 2)), 1), 8),
pitchXPx: previewPixelsFromMm(numericEditParameter(parameters, "pitchX", 8), { scale: 3, min: 18, max: 90 }),
pitchYPx: previewPixelsFromMm(numericEditParameter(parameters, "pitchY", 8), { scale: 3, min: 18, max: 90 }),
};
}
return null;
}
function EditToolVirtualPreview({
spec,
}: {
spec: ReturnType<typeof virtualPreviewSpec>;
}) {
if (!spec?.point) {
return null;
}
if (spec.kind === "circle") {
const diameterPx = Number(spec.diameterPx) || 0;
return (
<circle
cx={spec.point.x}
cy={spec.point.y}
r={diameterPx / 2}
className="fill-[#7dc8cf]/10 stroke-[#7dc8cf]/80"
strokeWidth="2"
strokeDasharray="5 4"
/>
);
}
if (spec.kind === "double_circle" || spec.kind === "countersink") {
const outerDiameterPx = Number(spec.outerDiameterPx) || 0;
const innerDiameterPx = Number(spec.innerDiameterPx) || 0;
return (
<g>
<circle
cx={spec.point.x}
cy={spec.point.y}
r={outerDiameterPx / 2}
className="fill-[#7dc8cf]/10 stroke-[#7dc8cf]/85"
strokeWidth="2"
strokeDasharray={spec.kind === "countersink" ? "7 4" : "none"}
/>
<circle
cx={spec.point.x}
cy={spec.point.y}
r={innerDiameterPx / 2}
className="fill-transparent stroke-[#f2f4f4]/80"
strokeWidth="1.5"
strokeDasharray="4 4"
/>
</g>
);
}
if (spec.kind === "rect") {
const widthPx = Number(spec.widthPx) || 0;
const heightPx = Number(spec.heightPx) || 0;
return (
<rect
x={spec.point.x - widthPx / 2}
y={spec.point.y - heightPx / 2}
width={widthPx}
height={heightPx}
rx="6"
className="fill-[#7dc8cf]/10 stroke-[#7dc8cf]/80"
strokeWidth="2"
strokeDasharray="6 4"
/>
);
}
if (spec.kind === "hole_pattern") {
const rows = Number(spec.rows) || 1;
const columns = Number(spec.columns) || 1;
const pitchXPx = Number(spec.pitchXPx) || 0;
const pitchYPx = Number(spec.pitchYPx) || 0;
const holeDiameterPx = Number(spec.holeDiameterPx) || 0;
const startX = spec.point.x - ((columns - 1) * pitchXPx) / 2;
const startY = spec.point.y - ((rows - 1) * pitchYPx) / 2;
const holes = [];
for (let row = 0; row < rows; row += 1) {
for (let column = 0; column < columns; column += 1) {
holes.push({
x: startX + column * pitchXPx,
y: startY + row * pitchYPx,
});
}
}
return (
<g>
{holes.map((hole, index) => (
<circle
key={`${index}:${hole.x}:${hole.y}`}
cx={hole.x}
cy={hole.y}
r={holeDiameterPx / 2}
className="fill-[#7dc8cf]/10 stroke-[#7dc8cf]/80"
strokeWidth="1.5"
strokeDasharray="4 3"
/>
))}
</g>
);
}
return null;
}
function slotPreviewWidthPx(parameters: EditToolParameters) {
return previewPixelsFromMm(numericEditParameter(parameters, "slotWidth", 2), { scale: 5, min: 12, max: 80 });
}
function EditToolPickOverlay({
activeToolId,
picks,
hoverPick,
parameters,
}: {
activeToolId: string;
picks: Array<Record<string, unknown>>;
hoverPick: Record<string, unknown> | null;
parameters: EditToolParameters;
}) {
if (!activeToolId) {
return null;
}
const points = picks
.map((pick, index) => ({
pick,
index,
hover: false,
point: finiteClientPoint(pick),
}))
.filter((entry): entry is {
pick: Record<string, unknown>;
index: number;
hover: false;
point: { x: number; y: number };
} => Boolean(entry.point));
const hoverPoint = finiteClientPoint(hoverPick);
const hoverEntry = hoverPoint && !points.some((entry) => (
Math.hypot(entry.point.x - hoverPoint.x, entry.point.y - hoverPoint.y) < 1
))
? {
pick: hoverPick || {},
index: points.length,
hover: true,
point: hoverPoint,
}
: null;
const allPoints = hoverEntry ? [...points, hoverEntry] : points;
if (!allPoints.length) {
return null;
}
const slotLine = activeToolId === "add_slot" && allPoints.length >= 2
? { start: allPoints[0].point, end: allPoints[1].point, preview: allPoints[1].hover }
: null;
const anchorPoint = activeToolId === "add_slot"
? null
: (points[points.length - 1]?.point || hoverEntry?.point || null);
const previewSpec = virtualPreviewSpec(activeToolId, parameters, anchorPoint);
const slotWidthPx = slotLine ? slotPreviewWidthPx(parameters) : 0;
return (
<div className="pointer-events-none fixed inset-0 z-[25]" aria-hidden="true">
{slotLine || previewSpec ? (
<svg className="fixed inset-0 size-full overflow-visible">
{slotLine ? (
<>
<line
x1={slotLine.start.x}
y1={slotLine.start.y}
x2={slotLine.end.x}
y2={slotLine.end.y}
className="stroke-[#7dc8cf]/15"
strokeWidth={slotWidthPx}
strokeLinecap="round"
/>
<line
x1={slotLine.start.x}
y1={slotLine.start.y}
x2={slotLine.end.x}
y2={slotLine.end.y}
className={slotLine.preview ? "stroke-[#7dc8cf]/65" : "stroke-[#7dc8cf]/90"}
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={slotLine.preview ? "7 5" : "none"}
/>
<line
x1={slotLine.start.x}
y1={slotLine.start.y}
x2={slotLine.end.x}
y2={slotLine.end.y}
className="stroke-[#f2f4f4]/70"
strokeWidth="1"
strokeLinecap="round"
strokeDasharray="5 5"
/>
</>
) : null}
<EditToolVirtualPreview spec={previewSpec} />
</svg>
) : null}
{allPoints.map(({ pick, index, point, hover }) => (
<div
key={`${index}:${referenceId(pick)}:${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", hover ? "bg-[#7dc8cf]/20" : "bg-[#7dc8cf]/35"].join(" ")} />
<span className={["absolute left-1/2 top-1/2 h-px w-12 -translate-x-1/2 -translate-y-1/2", hover ? "bg-[#7dc8cf]/20" : "bg-[#7dc8cf]/35"].join(" ")} />
<span className={[
"absolute left-1/2 top-1/2 size-7 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-[#7dc8cf]/10 shadow-[0_0_22px_rgba(125,200,207,0.32)]",
hover ? "border-[#7dc8cf]/25 border-dashed" : "border-[#7dc8cf]/35",
].join(" ")} />
<span className={[
"relative block rounded-full border border-[#f2f4f4]/80 bg-[#7dc8cf] shadow-md shadow-black/25",
hover ? "size-2 opacity-70" : "size-3",
].join(" ")} />
<span className="absolute left-4 top-3 whitespace-nowrap rounded-full border border-[#7dc8cf]/20 bg-[#15181b]/95 px-2 py-0.5 text-[10px] font-semibold text-[#f2f4f4] shadow-sm">
{hover ? "预选" : editToolPickMarkerLabel(activeToolId, pick, index)}
</span>
</div>
))}
</div>
);
}
function AiSelectionOverlay({
draft,
}: {
draft: AiSelectionDraft;
}) {
const draftPoints = Array.isArray(draft?.points) ? draft.points : [];
const points = draft?.active ? draftPoints : [];
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-[#7dc8cf]/85"
strokeWidth="2.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
);
}
function coerceEditToolParameters(activeToolId: string, parameters: EditToolParameters) {
const tool = cadEditToolForOperation(activeToolId);
return Object.fromEntries(
Object.entries(parameters).map(([name, value]) => {
const field = tool.parameterFields.find((entry) => entry.name === name);
if (field?.type !== "number" || value === "") {
return [name, value];
}
const numericValue = Number(value);
return [name, Number.isFinite(numericValue) ? numericValue : value];
}),
);
}
function EditToolParameterPanel({
activeToolId,
parameters,
selectionReady,
onParameterChange,
onSubmit,
onCancel,
}: {
activeToolId: string;
parameters: EditToolParameters;
selectionReady: boolean;
onParameterChange: (name: string, value: string | number) => void;
onSubmit: () => void;
onCancel: () => void;
}) {
const activeTool = CAD_EDIT_TOOLS.find((tool) => tool.id === activeToolId) || null;
if (!activeTool || !selectionReady) {
return null;
}
return (
<FloatingPanel
data-viewer-interaction-overlay="true"
className="absolute left-3 top-[4.25rem] z-30 w-64"
role="dialog"
aria-label={`${activeTool.label}参数`}
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div>
<div className="text-xs font-semibold">{activeTool.label}</div>
<div className="text-[10px] text-[#9aa2aa]">编辑参数</div>
</div>
<Button
size="icon-sm"
variant="ghost"
onClick={onCancel}
aria-label="取消编辑工具"
>
×
</Button>
</div>
<div className="grid gap-2">
{activeTool.parameterFields.map((field) => {
const value = parameters[field.name] ?? "";
return (
<label key={field.name} className="grid gap-1 text-[11px]">
<span className="flex items-center justify-between gap-2">
<span>{field.label}</span>
{field.unit ? <span className="text-[#9aa2aa]">{field.unit}</span> : null}
</span>
{field.type === "select" ? (
<Select
value={String(value)}
onChange={(event) => onParameterChange(field.name, event.target.value)}
>
{(field.options || []).map((option) => (
<option key={option} value={option}>{option}</option>
))}
</Select>
) : (
<Input
type={field.type === "number" ? "number" : "text"}
value={String(value)}
min={field.min}
step={field.step}
placeholder={field.placeholder}
onChange={(event) => onParameterChange(field.name, event.target.value)}
/>
)}
</label>
);
})}
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
variant="outline"
onClick={onCancel}
>
取消
</Button>
<Button
variant="primary"
onClick={onSubmit}
>
确定
</Button>
</div>
</FloatingPanel>
);
}
export function CadViewerPreview({
taskId,
artifactPath,
viewerAssetUrl,
activeEditToolId,
aiSelectionMode,
onSelectTool,
onSelectionModeChange,
onEditIntent,
onGeometrySelection,
}: {
taskId: string;
artifactPath: string;
viewerAssetUrl: string;
activeEditToolId: string;
aiSelectionMode: AiSelectionMode;
onSelectTool: (toolId: string) => void;
onSelectionModeChange: (mode: AiSelectionMode) => void;
onEditIntent: (payload: Record<string, unknown>) => void;
onGeometrySelection: (payload: Record<string, unknown>) => void;
}) {
const [loadState, setLoadState] = useState<LoadState>({ kind: "idle" });
const [hoveredReferenceId, setHoveredReferenceId] = useState("");
const [selectedReferenceIds, setSelectedReferenceIds] = useState<string[]>([]);
const [editToolPicks, setEditToolPicks] = useState<Array<Record<string, unknown>>>([]);
const [editToolHoverPick, setEditToolHoverPick] = useState<Record<string, unknown> | null>(null);
const [editToolSelectionReady, setEditToolSelectionReady] = useState(false);
const [editToolParameters, setEditToolParameters] = useState<EditToolParameters>(() => defaultCadEditParameters(activeEditToolId));
const [aiSelectionDraft, setAiSelectionDraft] = useState<AiSelectionDraft>(null);
const aiGeometrySelectionRef = useRef<Record<string, unknown> | null>(null);
const lastActivatedReferenceIdRef = useRef("");
useEffect(() => {
setHoveredReferenceId("");
setSelectedReferenceIds([]);
setEditToolPicks([]);
setEditToolHoverPick(null);
setEditToolSelectionReady(false);
setAiSelectionDraft(null);
aiGeometrySelectionRef.current = null;
lastActivatedReferenceIdRef.current = "";
}, [viewerAssetUrl]);
useEffect(() => {
setEditToolPicks([]);
setEditToolHoverPick(null);
setEditToolSelectionReady(false);
setEditToolParameters(defaultCadEditParameters(activeEditToolId));
}, [activeEditToolId]);
useEffect(() => {
if (aiSelectionMode !== "lasso") {
setAiSelectionDraft(null);
}
}, [aiSelectionMode]);
useEffect(() => {
if (!viewerAssetUrl) {
setLoadState({ kind: "error", message: "当前任务缺少 CAD Viewer GLB/topology 资产。" });
return undefined;
}
const controller = new AbortController();
setLoadState((current) => (
current.kind === "ready"
? { ...current, isRefreshing: true }
: { kind: "loading" }
));
void Promise.all([
loadRenderGlb(viewerAssetUrl, { signal: controller.signal }),
loadRenderSelectorBundle(viewerAssetUrl, { signal: controller.signal }).catch(() => null),
loadRenderDisplayEdgeBundle(viewerAssetUrl, { signal: controller.signal }).catch(() => null),
])
.then(([meshData, selectorBundle, displayEdgeBundle]) => {
if (controller.signal.aborted) return;
const selectorRuntime = selectorBundle
? buildSelectorRuntime(selectorBundle, {
copyCadPath: artifactPath.replace(/\.(step|stp)$/i, ""),
})
: null;
const displayEdgeRuntime = displayEdgeBundle ? buildDisplayEdgeRuntime(displayEdgeBundle) : null;
const references = Array.isArray(selectorRuntime?.references) ? selectorRuntime.references : [];
setLoadState({
kind: "ready",
viewerAssetUrl,
meshData,
selectorRuntime,
displayEdgeRuntime,
references,
isRefreshing: false,
});
})
.catch((error) => {
if (controller.signal.aborted) return;
setLoadState((current) => (
current.kind === "ready"
? { ...current, isRefreshing: false }
: { kind: "error", message: error instanceof Error ? error.message : "CAD Viewer 资产加载失败。" }
));
});
return () => controller.abort();
}, [artifactPath, viewerAssetUrl]);
const pickableFaces = useMemo(() => {
if (loadState.kind !== "ready") return [];
return loadState.references.filter((reference) => referenceKind(reference) === "face");
}, [loadState]);
const pickableEdges = useMemo(() => {
if (loadState.kind !== "ready") return [];
return loadState.references.filter((reference) => referenceKind(reference) === "edge");
}, [loadState]);
const handleEditToolPick = useCallback((payload: Record<string, unknown> | null) => {
if (!activeEditToolId || !payload) return;
const nextPicks = [...editToolPicks, payload];
setEditToolPicks(nextPicks);
setEditToolHoverPick(null);
setEditToolSelectionReady(false);
const pickedReferenceId = referenceId(payload);
if (pickedReferenceId) {
setSelectedReferenceIds([pickedReferenceId]);
}
if (!cadEditToolPickComplete(activeEditToolId, nextPicks)) {
return;
}
setEditToolSelectionReady(true);
}, [activeEditToolId, editToolPicks]);
const handleEditToolParameterChange = useCallback((name: string, value: string | number) => {
setEditToolParameters((previous) => ({
...previous,
[name]: value,
}));
}, []);
const handleEditToolCancel = useCallback(() => {
setEditToolPicks([]);
setEditToolHoverPick(null);
setEditToolSelectionReady(false);
onSelectTool("");
onSelectionModeChange("point");
}, [onSelectTool, onSelectionModeChange]);
const handleEditToolSubmit = useCallback(() => {
if (!activeEditToolId || !cadEditToolPickComplete(activeEditToolId, editToolPicks)) {
return;
}
const selection = activeEditToolId === "add_slot" ? buildSlotSelection(editToolPicks) : editToolPicks[0];
onEditIntent(buildEmbeddedCadEditIntent({
operation: activeEditToolId,
pick: selection,
parameters: coerceEditToolParameters(activeEditToolId, editToolParameters),
artifactPath,
taskId,
}));
setEditToolSelectionReady(false);
setEditToolPicks([]);
setEditToolHoverPick(null);
onSelectTool("");
onSelectionModeChange("point");
}, [activeEditToolId, artifactPath, editToolParameters, editToolPicks, onEditIntent, onSelectTool, onSelectionModeChange, taskId]);
const handleGeometrySelection = useCallback((selection: Record<string, unknown> | null) => {
if (!selection) return;
const referenceIds = selectionReferenceIds(selection, { includePick: true });
let nextSelection: Record<string, unknown> = {
...selection,
referenceIds,
};
if (selection.kind === "point_pick" && selection.multiSelect && referenceIds.length) {
const pickedReferenceId = referenceId(selection.pick) || referenceIds[0] || "";
const previousSelection = aiGeometrySelectionRef.current;
const previousReferenceIds = selectionReferenceIds(previousSelection);
const nextReferenceIds = previousReferenceIds.includes(pickedReferenceId)
? previousReferenceIds.filter((id) => id !== pickedReferenceId)
: uniqueStringList([...previousReferenceIds, pickedReferenceId]);
const entityByReferenceId = new Map<string, unknown>();
for (const entity of [
...(Array.isArray(previousSelection?.entities) ? previousSelection.entities : []),
...(Array.isArray(selection.entities) ? selection.entities : []),
]) {
const entityId = referenceId(entity);
if (entityId) entityByReferenceId.set(entityId, entity);
}
const entities = nextReferenceIds
.map((id) => entityByReferenceId.get(id))
.filter(Boolean);
nextSelection = {
...selection,
referenceIds: nextReferenceIds,
entities,
entityCount: entities.length,
};
} else if (selection.kind === "point_pick") {
nextSelection = {
...selection,
referenceIds,
entityCount: Array.isArray(selection.entities) ? selection.entities.length : referenceIds.length,
};
}
const nextReferenceIds = selectionReferenceIds(nextSelection);
nextSelection = {
...nextSelection,
referenceIds: nextReferenceIds,
};
if (Number(nextSelection.entityCount) !== nextReferenceIds.length) {
nextSelection.entityCount = Array.isArray(nextSelection.entities) ? nextSelection.entities.length : nextReferenceIds.length;
}
aiGeometrySelectionRef.current = nextSelection;
setSelectedReferenceIds(nextReferenceIds);
onGeometrySelection(buildEmbeddedGeometrySelectionIntent({
selection: nextSelection,
artifactPath,
taskId,
}));
}, [artifactPath, onGeometrySelection, taskId]);
const handleHoverReferenceChange = useCallback((referenceId: string) => {
const normalized = String(referenceId || "").trim();
setHoveredReferenceId((current) => (current === normalized ? current : normalized));
}, []);
const handleActivateReference = useCallback((referenceId: string) => {
const normalized = String(referenceId || "").trim();
setSelectedReferenceIds((current) => {
const next = normalized ? [normalized] : [];
return current.length === next.length && current.every((id, index) => id === next[index]) ? current : next;
});
if (!normalized || loadState.kind !== "ready" || lastActivatedReferenceIdRef.current === normalized) {
return;
}
lastActivatedReferenceIdRef.current = normalized;
const reference = loadState.references.find((candidate) => runtimeReferenceId(candidate) === normalized) || {};
onGeometrySelection(buildEmbeddedGeometrySelectionIntent({
selection: buildSingleReferenceSelection(reference, normalized),
artifactPath,
taskId,
}));
}, [artifactPath, loadState, onGeometrySelection, taskId]);
const handleEditToolHover = useCallback((pick: Record<string, unknown> | null) => {
const nextSignature = editPickSignature(pick);
setEditToolHoverPick((current) => (
editPickSignature(current) === nextSignature ? current : pick
));
}, []);
const handleAiSelectionDraftChange = useCallback((draft: AiSelectionDraft) => {
setAiSelectionDraft((current) => {
const currentPoints = current?.points || [];
const nextPoints = draft?.points || [];
const samePoints = currentPoints.length === nextPoints.length &&
currentPoints.every((point, index) => (
point.x === nextPoints[index]?.x && point.y === nextPoints[index]?.y
));
if ((current?.active || false) === (draft?.active || false) && samePoints) {
return current;
}
return draft;
});
}, []);
if (loadState.kind === "idle" || loadState.kind === "loading") {
return (
<div className="grid h-full min-h-[42vh] place-items-center bg-[#0d0f11]">
<div className="flex items-center gap-2 text-[12px] text-[#c9cccf]">
<Loader2 className="size-4 animate-spin text-[#7dc8cf]" />
加载 CAD Viewer 资产...
</div>
</div>
);
}
if (loadState.kind === "error") {
return (
<div className="grid h-full min-h-[42vh] place-items-center bg-[#0d0f11] p-6">
<div className="max-w-sm rounded border border-[#7a3438] bg-[#2a171a] p-4 text-center text-[12px] leading-5 text-[#f2b0b2]">
{loadState.message}
</div>
</div>
);
}
if (loadState.kind !== "ready") {
return null;
}
return (
<div className="relative h-full min-h-[42vh] overflow-hidden bg-[#0d0f11]">
<CadViewer
meshData={loadState.meshData}
modelKey={loadState.viewerAssetUrl}
renderFormat={RENDER_FORMAT.STEP}
showEdges={true}
recomputeNormals={false}
previewMode={false}
showViewPlane={true}
viewPlaneOffsetRight={16}
viewPlaneOffsetBottom="1rem"
viewportFrameInsets={VIEWPORT_FRAME_INSETS}
isLoading={false}
renderPartsIndividually={false}
hoveredReferenceId={hoveredReferenceId}
selectedReferenceIds={selectedReferenceIds}
selectorRuntime={loadState.selectorRuntime}
displayEdgeRuntime={loadState.displayEdgeRuntime}
pickableFaces={pickableFaces}
pickableEdges={pickableEdges}
pickableVertices={EMPTY_REFERENCES}
editPointPickEnabled={Boolean(activeEditToolId)}
activeEditToolId={activeEditToolId}
editToolPickKind={cadEditToolNextPickKind(activeEditToolId, editToolPicks.length)}
editToolPicks={editToolPicks}
editToolHoverPick={editToolHoverPick}
editToolParameters={editToolParameters}
onEditToolPick={handleEditToolPick}
onEditToolHover={handleEditToolHover}
aiSelectionMode={aiSelectionMode === "none" ? "" : aiSelectionMode}
onAiSelectionComplete={handleGeometrySelection}
onAiSelectionDraftChange={handleAiSelectionDraftChange}
onHoverReferenceChange={handleHoverReferenceChange}
onActivateReference={handleActivateReference}
/>
{loadState.isRefreshing ? (
<div className="pointer-events-none absolute inset-0 z-20 grid place-items-center bg-[#0d0f11]/35">
<div className="flex items-center gap-2 rounded border border-[#2d3339] bg-[#15181b]/95 px-3 py-2 text-[12px] text-[#c9cccf]">
<Loader2 className="size-4 animate-spin text-[#7dc8cf]" />
加载 CAD Viewer 资产...
</div>
</div>
) : null}
<AiSelectionOverlay draft={aiSelectionDraft} />
<EmbeddedCadEditToolbar
activeToolId={activeEditToolId}
aiSelectionMode={aiSelectionMode}
disabled={!taskId}
onSelectTool={onSelectTool}
onSelectionModeChange={onSelectionModeChange}
/>
<EditToolParameterPanel
activeToolId={activeEditToolId}
parameters={editToolParameters}
selectionReady={editToolSelectionReady}
onParameterChange={handleEditToolParameterChange}
onSubmit={handleEditToolSubmit}
onCancel={handleEditToolCancel}
/>
</div>
);
}
@@ -0,0 +1,169 @@
"use client";
import type { ReactNode } from "react";
import {
CircleDot,
CircleDotDashed,
CornerDownRight,
Disc,
Drill,
Focus,
Grid2X2Plus,
LassoSelect,
MousePointerClick,
Orbit,
Radius,
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-[#c9cccf] transition",
active
? "border-[#39434b] bg-[#22272c] text-[#f2f4f4] shadow-[inset_0_0_0_1px_rgba(232,232,227,0.08)]"
: "border-transparent hover:border-[#39434b] hover:bg-[#22272c] hover:text-[#f2f4f4]",
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-[#39434b]/80" aria-hidden="true" />;
}
export function EmbeddedCadEditToolbar({
activeToolId,
aiSelectionMode,
disabled = false,
onSelectTool,
onSelectionModeChange,
}: {
activeToolId: string;
aiSelectionMode: AiSelectionMode;
disabled?: boolean;
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-[#2d3339] bg-[#15181b]/95 p-1.5 text-[#c9cccf] shadow-sm shadow-black/20 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;
return (
<ToolbarButton
key={tool.id}
label={tool.label}
active={active}
disabled={disabled}
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,
}: {
disabled?: boolean;
onResetView: () => void;
onScreenshot?: () => 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-[#2d3339] bg-[#15181b]/95 p-1.5 text-[#c9cccf] shadow-sm shadow-black/20 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>
</div>
);
}
@@ -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-[#7dc8cf]/35 bg-[#7dc8cf]/18 text-[#dff8fa] hover:bg-[#7dc8cf]/26",
outline: "border-[#39434b] text-[#c9cccf] hover:bg-[#22272c] hover:text-[#f2f4f4]",
ghost: "border-transparent text-[#c9cccf] hover:border-[#39434b] hover:bg-[#22272c] hover:text-[#f2f4f4]",
};
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-[#7dc8cf]/45 disabled:pointer-events-none disabled:opacity-50",
variantClassNames[variant],
sizeClassNames[size],
className,
)}
{...props}
/>
);
}
@@ -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-[#39434b] bg-[#101316] px-2 text-xs text-[#f2f4f4] outline-none placeholder:text-[#6d747b] focus-visible:ring-2 focus-visible:ring-[#7dc8cf]/45",
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-[#2d3339] bg-[#15181b]/95 p-3 text-[#f2f4f4] shadow-lg shadow-black/25 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-[#39434b] bg-[#101316] px-2 text-xs text-[#f2f4f4] outline-none focus-visible:ring-2 focus-visible:ring-[#7dc8cf]/45",
className,
)}
{...props}
/>
);
}
+218
View File
@@ -0,0 +1,218 @@
export type AiSelectionMode = "none" | "point" | "lasso";
export type CadEditTool = {
id: string;
label: string;
pickKinds: Array<"surface_point" | "edge">;
parameters: Record<string, string | number>;
parameterFields: Array<{
name: string;
label: string;
type: "number" | "select" | "string";
unit?: string;
min?: number;
step?: number;
options?: string[];
placeholder?: string;
}>;
};
export const CAD_EDIT_TOOLS: CadEditTool[] = [
{
id: "add_hole",
label: "打孔",
pickKinds: ["surface_point"],
parameters: { holeDiameter: 2, depth: "through", direction: "face_normal" },
parameterFields: [
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_counterbore",
label: "沉孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 3,
counterboreDiameter: 6,
counterboreDepth: 2,
depth: "through",
direction: "face_normal",
},
parameterFields: [
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "counterboreDiameter", label: "沉孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "counterboreDepth", label: "沉孔深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_countersink",
label: "锥沉孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 3,
countersinkDiameter: 6,
countersinkAngleDeg: 90,
depth: "through",
direction: "face_normal",
},
parameterFields: [
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "countersinkDiameter", label: "锥口直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "countersinkAngleDeg", label: "锥角", type: "number", unit: "deg", min: 1, step: 1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_slot",
label: "开槽",
pickKinds: ["surface_point", "surface_point"],
parameters: { slotWidth: 2, depth: "through", direction: "face_normal" },
parameterFields: [
{ name: "slotWidth", label: "槽宽", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_pocket",
label: "铣型腔",
pickKinds: ["surface_point"],
parameters: { shape: "rectangle", width: 10, height: 6, depth: 1, direction: "face_normal", orientation: "surface_xy" },
parameterFields: [
{ name: "width", label: "宽度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "height", label: "高度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_circular_pocket",
label: "圆形型腔",
pickKinds: ["surface_point"],
parameters: { diameter: 8, depth: 1, direction: "face_normal" },
parameterFields: [
{ name: "diameter", label: "直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_hole_pattern",
label: "阵列孔",
pickKinds: ["surface_point"],
parameters: {
holeDiameter: 2,
rows: 2,
columns: 2,
pitchX: 8,
pitchY: 8,
depth: "through",
direction: "face_normal",
orientation: "surface_xy",
},
parameterFields: [
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "rows", label: "行数", type: "number", min: 1, step: 1 },
{ name: "columns", label: "列数", type: "number", min: 1, step: 1 },
{ name: "pitchX", label: "X 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "pitchY", label: "Y 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
],
},
{
id: "add_chamfer",
label: "倒角",
pickKinds: ["edge"],
parameters: { distance: 1 },
parameterFields: [
{ name: "distance", label: "倒角距离", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
{
id: "add_fillet",
label: "圆角",
pickKinds: ["edge"],
parameters: { radius: 1 },
parameterFields: [
{ name: "radius", label: "圆角半径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
],
},
];
export function cadEditToolForOperation(operation: string) {
return CAD_EDIT_TOOLS.find((tool) => tool.id === operation) || CAD_EDIT_TOOLS[0];
}
export function cadEditToolPickKind(operation: string) {
return cadEditToolForOperation(operation).pickKinds[0] || "surface_point";
}
export function cadEditToolNextPickKind(operation: string, pickCount: number) {
const pickKinds = cadEditToolForOperation(operation).pickKinds;
return pickKinds[Math.min(Math.max(Math.floor(pickCount), 0), pickKinds.length - 1)] || "surface_point";
}
export function cadEditToolPickComplete(operation: string, picks: unknown[]) {
return picks.length >= cadEditToolForOperation(operation).pickKinds.length;
}
export function defaultCadEditParameters(operation: string) {
return { ...cadEditToolForOperation(operation).parameters };
}
export function buildEmbeddedCadEditIntent({
operation,
pick,
parameters,
artifactPath,
taskId,
}: {
operation: string;
pick: Record<string, unknown>;
parameters?: Record<string, unknown>;
artifactPath: string;
taskId: string;
}) {
const tool = cadEditToolForOperation(operation);
return {
schema: "cad-edit-intent.v1",
operation: tool.id,
source: {
entryFile: artifactPath,
stepPath: artifactPath,
sourcePath: "",
backend: "agent-studio",
units: "mm",
taskId,
},
selection: pick,
parameters: {
...defaultCadEditParameters(tool.id),
...(parameters || {}),
},
};
}
export function buildEmbeddedGeometrySelectionIntent({
selection,
artifactPath,
taskId,
}: {
selection: Record<string, unknown>;
artifactPath: string;
taskId: string;
}) {
return {
schema: "cad-ai-geometry-selection.v1",
intent: "modify_selected_geometry",
source: {
entryFile: artifactPath,
stepPath: artifactPath,
sourcePath: "",
backend: "agent-studio",
units: "mm",
taskId,
},
selection,
instruction: "用户通过 CAD Agent Studio 内置预览指定了要修改的几何区域。请结合 selection 中的屏幕坐标、近似模型坐标和当前视角理解位置,再修改可编辑源文件并重新生成 STEP 与预览资产。",
};
}
+216
View File
@@ -0,0 +1,216 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { artifactUrl, createStepGlbPreview, readArtifact } from "@/lib/preview-artifacts";
import { enginePath } from "@/lib/paths";
import { ensureTask, sanitizeFilename, upsertManifest } from "@/lib/task-store";
const execFileAsync = promisify(execFile);
export type CadGenerationResult = {
handled: true;
taskId: string;
artifactPath: string;
artifactUrl: string;
previewPath: string;
previewUrl: string;
viewerAssetPath: string;
viewerAssetUrl: string;
sourcePath: string;
summary: string;
};
function ensureRelativeFilename(filename: string, extension: string, fallback: string) {
const safeName = sanitizeFilename(filename || fallback);
return safeName.toLowerCase().endsWith(extension) ? safeName : `${safeName}${extension}`;
}
function pythonExecutable() {
return process.env.CAD_PYTHON
|| enginePath("text-to-cad", ".venv", "bin", "python");
}
function pipelineScript() {
return enginePath("designir-pipeline", "scripts", "designir_pipeline.py");
}
function routerScript() {
return enginePath("text-to-cad", "skills", "cad-router", "scripts", "route.py");
}
async function runJson(command: string, args: string[], cwd: string) {
const { stdout } = await execFileAsync(command, args, {
cwd,
timeout: 120_000,
maxBuffer: 1024 * 1024 * 8,
});
return JSON.parse(stdout) as Record<string, unknown>;
}
export async function executeDesignIR({
prompt,
summary,
designir,
designirFilename,
stepFilename,
selectedTaskId,
assumptions = [],
teacherTaskId,
teacherPath,
}: {
prompt: string;
summary: string;
designir: Record<string, unknown>;
designirFilename?: string;
stepFilename?: string;
selectedTaskId?: string;
assumptions?: string[];
teacherTaskId?: string;
teacherPath?: string;
}): Promise<CadGenerationResult> {
const task = await ensureTask(selectedTaskId || undefined);
const sourcePath = ensureRelativeFilename(
designirFilename || "model.designir.json",
".designir.json",
"model.designir.json",
);
const artifactPath = ensureRelativeFilename(stepFilename || "model.step", ".step", "model.step");
const sourceAbsolutePath = path.join(task.dir, sourcePath);
const artifactAbsolutePath = path.join(task.dir, artifactPath);
await fs.writeFile(sourceAbsolutePath, `${JSON.stringify(designir, null, 2)}\n`);
const route = await runJson(
pythonExecutable(),
[routerScript(), prompt],
task.dir,
);
const selectedBackend = String(route.selected_backend || "build123d");
const normalizedDesignIR = {
...designir,
backend_hint: selectedBackend,
};
await fs.writeFile(sourceAbsolutePath, `${JSON.stringify(normalizedDesignIR, null, 2)}\n`);
const antiCheat = await runJson(
pythonExecutable(),
[pipelineScript(), "anti-cheat", sourceAbsolutePath],
task.dir,
);
if (antiCheat.valid !== true) {
throw new Error(`DesignIR anti-cheat validation failed: ${JSON.stringify(antiCheat.violations || [])}`);
}
const rebuild = await runJson(
pythonExecutable(),
[
pipelineScript(),
"isolated-rebuild",
sourceAbsolutePath,
"--output",
artifactAbsolutePath,
"--backend",
selectedBackend,
],
task.dir,
);
if (rebuild.valid !== true) {
throw new Error("Isolated DesignIR rebuild failed.");
}
const validation: string[] = [
"CAD Router selected the backend before compilation.",
"DesignIR anti-cheat validation passed.",
"STEP rebuilt in an isolated workspace without teacher STEP access.",
];
const artifacts: Array<{ path: string; role: string; kind?: string }> = [
{ path: sourcePath, role: "editable_contract", kind: "designir-2.0" },
{ path: artifactPath, role: "primary", kind: "step" },
];
if (typeof rebuild.model_json === "string" && rebuild.model_json) {
const simpleCadModelPath = path.basename(rebuild.model_json);
artifacts.push({
path: simpleCadModelPath,
role: "backend_editable_graph",
kind: "simplecad-model-json",
});
}
if (teacherTaskId && teacherPath) {
const { absolutePath: teacherAbsolutePath } = await readArtifact(
teacherTaskId,
teacherPath,
);
const reportPath = "acceptance-report.json";
const reportAbsolutePath = path.join(task.dir, reportPath);
const acceptance = await runJson(
pythonExecutable(),
[
pipelineScript(),
"accept",
"--teacher",
teacherAbsolutePath,
"--designir",
sourceAbsolutePath,
"--rebuilt",
artifactAbsolutePath,
"--report",
reportAbsolutePath,
],
task.dir,
);
artifacts.push({ path: reportPath, role: "acceptance", kind: "json" });
validation.push(`Independent acceptance verdict: ${String(acceptance.verdict || "unknown")}.`);
}
const viewerAssetPath = await createStepGlbPreview({
taskId: task.taskId,
stepPath: artifactPath,
});
artifacts.push({ path: viewerAssetPath, role: "viewer", kind: "glb" });
const artifactVersion = Date.now().toString(36);
await upsertManifest(task.taskId, {
request: prompt,
route: {
selected_backend: selectedBackend,
runner_skill: String(route.runner_skill || "cad"),
project: String(route.project || "text-to-cad"),
fallback_order: Array.isArray(route.fallback_order) ? route.fallback_order : [],
workflow_profiles: Array.isArray(route.workflow_profiles) ? route.workflow_profiles : [],
},
source: {
path: sourcePath,
format: "designir-2.0",
backend: selectedBackend,
source_editability: "editable_source",
generated_by: "llm_designir_tool_call",
},
artifacts,
assumptions,
validation,
studio: {
latestArtifact: artifactPath,
latestPreview: artifactPath,
latestViewerAsset: viewerAssetPath,
latestVersion: artifactVersion,
},
});
const stepUrl = artifactUrl(task.taskId, artifactPath, artifactVersion);
return {
handled: true,
taskId: task.taskId,
artifactPath,
artifactUrl: stepUrl,
previewPath: artifactPath,
previewUrl: stepUrl,
viewerAssetPath,
viewerAssetUrl: artifactUrl(task.taskId, viewerAssetPath, artifactVersion),
sourcePath,
summary: summary || "CAD model generated from executable DesignIR 2.0.",
};
}
export function cadResultDirective(result: CadGenerationResult) {
return `::cad-result{base64="${Buffer.from(JSON.stringify(result), "utf8").toString("base64")}"}`;
}
+841
View File
@@ -0,0 +1,841 @@
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import {
createUIMessageStream,
createUIMessageStreamResponse,
isStepCount,
streamText,
tool,
type ModelMessage,
type UIMessage,
type UserContent,
} from "ai";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod/v4";
import { executeDesignIR, type CadGenerationResult } from "@/lib/cad-generator";
import { loadLlmConfig, resolveProviderApiKey, selectedModelId, type LlmConfig } from "@/lib/config";
import { readManifest, taskDir } from "@/lib/task-store";
const execFileAsync = promisify(execFile);
type ChatMessage = {
role: "user" | "assistant" | "system";
content: string;
};
type CadProgressStatus = "pending" | "running" | "success" | "error";
type CadProgressPayload = {
step: string;
label: string;
status: CadProgressStatus;
message?: string;
};
type AttachmentRecord = {
id?: string;
taskId?: string;
name?: string;
kind?: string;
path?: string;
size?: number;
sha256?: string;
mime?: string;
};
function writeTextPart(writer: { write: (part: any) => void }, text: string) {
const id = `text_${Math.random().toString(36).slice(2, 10)}`;
writer.write({ type: "text-start", id });
for (const token of text.match(/.{1,48}/g) || []) {
writer.write({ type: "text-delta", id, delta: token });
}
writer.write({ type: "text-end", id });
}
function uiTextResponse(text: string) {
const stream = createUIMessageStream<UIMessage>({
execute: ({ writer }) => {
writeTextPart(writer, text);
},
});
return createUIMessageStreamResponse({
stream,
headers: {
"Cache-Control": "no-store",
},
});
}
function compactJson(value: unknown, maxLength: number) {
const source = JSON.stringify(value, null, 2);
return source.length > maxLength ? `${source.slice(0, maxLength)}\n...<truncated>` : source;
}
function normalizeAttachment(value: unknown): AttachmentRecord | null {
if (!value || typeof value !== "object") return null;
const record = value as Record<string, unknown>;
const taskId = String(record.taskId || "").trim();
const filePath = String(record.path || "").trim();
if (!taskId || !filePath) return null;
return {
id: String(record.id || "").trim(),
taskId,
name: String(record.name || path.basename(filePath)).trim(),
kind: String(record.kind || "").trim(),
path: filePath,
size: Number(record.size || 0),
sha256: String(record.sha256 || "").trim(),
mime: String(record.mime || "").trim() || "application/octet-stream",
};
}
function normalizedAttachments(attachments: unknown[]) {
return attachments.map(normalizeAttachment).filter((attachment): attachment is AttachmentRecord => Boolean(attachment));
}
function messageContent(value: unknown) {
if (!value || typeof value !== "object") return "";
const record = value as Record<string, unknown>;
if (typeof record.content === "string") return record.content;
if (Array.isArray(record.parts)) {
return record.parts.map((part) => {
if (!part || typeof part !== "object") return "";
const partRecord = part as Record<string, unknown>;
if (partRecord.type === "text") return String(partRecord.text || "");
if (typeof partRecord.text === "string") return partRecord.text;
return "";
}).join("");
}
return "";
}
function normalizedMessages(messages: unknown[]): ChatMessage[] {
return messages.map((message) => {
const record = (message && typeof message === "object" ? message : {}) as Record<string, unknown>;
const role: ChatMessage["role"] = record.role === "assistant" || record.role === "system" ? record.role : "user";
return {
role,
content: messageContent(record),
};
}).filter((message) => message.content.trim() || message.role !== "user");
}
function attachmentKey(attachment: AttachmentRecord) {
return [attachment.id, attachment.taskId, attachment.path].filter(Boolean).join(":");
}
function findAttachment(attachments: AttachmentRecord[], query: { id?: string; taskId?: string; path?: string }) {
const id = String(query.id || "").trim();
const task = String(query.taskId || "").trim();
const filePath = String(query.path || "").trim();
return attachments.find((attachment) => (
(id && attachment.id === id) ||
(task && filePath && attachment.taskId === task && attachment.path === filePath) ||
(filePath && attachment.path === filePath)
)) || null;
}
function attachmentAbsolutePath(attachment: AttachmentRecord) {
if (!attachment.taskId || !attachment.path) {
throw new Error("Attachment is missing taskId or path.");
}
const root = path.resolve(taskDir(attachment.taskId));
const absolutePath = path.resolve(root, attachment.path);
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) {
throw new Error("Attachment path escapes task directory.");
}
return absolutePath;
}
function attachmentMediaType(attachment: AttachmentRecord) {
const mime = String(attachment.mime || "").trim().toLowerCase();
if (mime && mime !== "application/octet-stream") return mime;
const lowerName = String(attachment.name || attachment.path || "").toLowerCase();
if (lowerName.endsWith(".png")) return "image/png";
if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) return "image/jpeg";
if (lowerName.endsWith(".webp")) return "image/webp";
if (lowerName.endsWith(".pdf")) return "application/pdf";
if (lowerName.endsWith(".json")) return "application/json";
if (lowerName.endsWith(".csv")) return "text/csv";
if (lowerName.endsWith(".md")) return "text/markdown";
if (lowerName.endsWith(".txt") || lowerName.endsWith(".py") || lowerName.endsWith(".scad")) return "text/plain";
if (lowerName.endsWith(".step") || lowerName.endsWith(".stp")) return "model/step";
return "application/octet-stream";
}
function isImageAttachment(attachment: AttachmentRecord) {
return attachment.kind === "image" || attachmentMediaType(attachment).startsWith("image/");
}
function isStepAttachment(attachment: AttachmentRecord) {
const lowerName = String(attachment.name || attachment.path || "").toLowerCase();
return attachment.kind === "step" || lowerName.endsWith(".step") || lowerName.endsWith(".stp");
}
function isTextLikeAttachment(attachment: AttachmentRecord) {
const mediaType = attachmentMediaType(attachment);
const lowerName = String(attachment.name || attachment.path || "").toLowerCase();
return mediaType.startsWith("text/") ||
mediaType === "application/json" ||
mediaType === "application/xml" ||
mediaType === "application/x-yaml" ||
lowerName.endsWith(".json") ||
lowerName.endsWith(".yaml") ||
lowerName.endsWith(".yml") ||
lowerName.endsWith(".csv") ||
lowerName.endsWith(".txt") ||
lowerName.endsWith(".md") ||
lowerName.endsWith(".py") ||
lowerName.endsWith(".scad");
}
async function readTextExcerpt(attachment: AttachmentRecord, maxBytes = 80_000) {
const absolutePath = attachmentAbsolutePath(attachment);
const data = await fs.readFile(absolutePath);
const truncated = data.byteLength > maxBytes;
return {
text: data.subarray(0, maxBytes).toString("utf8"),
truncated,
bytesRead: Math.min(data.byteLength, maxBytes),
totalBytes: data.byteLength,
};
}
async function buildAttachmentUserContent(lastUserContent: string, attachments: AttachmentRecord[]): Promise<UserContent> {
const parts: Exclude<UserContent, string> = [{ type: "text", text: lastUserContent }];
const attachedFileLines = [];
const seen = new Set<string>();
for (const attachment of attachments) {
const key = attachmentKey(attachment);
if (seen.has(key)) continue;
seen.add(key);
const mediaType = attachmentMediaType(attachment);
attachedFileLines.push(`- ${attachment.name || attachment.path} (${mediaType}, ${attachment.size || "unknown"} bytes), id=${attachment.id || ""}, taskId=${attachment.taskId}, path=${attachment.path}`);
if (isImageAttachment(attachment)) {
const data = await fs.readFile(attachmentAbsolutePath(attachment));
parts.push({
type: "file",
data,
mediaType,
filename: attachment.name || path.basename(String(attachment.path || "image")),
});
} else if (isTextLikeAttachment(attachment) && !isStepAttachment(attachment)) {
const excerpt = await readTextExcerpt(attachment);
parts.push({
type: "text",
text: [
`\nAttached text file: ${attachment.name || attachment.path}`,
`id=${attachment.id || ""}, taskId=${attachment.taskId}, path=${attachment.path}`,
excerpt.truncated ? `Showing first ${excerpt.bytesRead} of ${excerpt.totalBytes} bytes.` : `Full file content (${excerpt.totalBytes} bytes):`,
"```",
excerpt.text,
"```",
].join("\n"),
});
}
}
if (attachedFileLines.length) {
parts.push({
type: "text",
text: [
"\nUploaded files available to this turn:",
...attachedFileLines,
"Images are attached as model-visible image parts when the selected provider/model supports vision.",
"For STEP/STP or binary files, call inspect_uploaded_file with id/taskId/path before using their contents.",
].join("\n"),
});
}
return parts.length === 1 ? lastUserContent : parts;
}
async function buildModelMessages(messages: ChatMessage[], attachments: AttachmentRecord[]): Promise<ModelMessage[]> {
const lastUserIndex = messages.map((message) => message.role).lastIndexOf("user");
return Promise.all(messages.map(async (message, index) => {
if (message.role === "user" && index === lastUserIndex && attachments.length) {
return {
role: "user",
content: await buildAttachmentUserContent(message.content, attachments),
};
}
return {
role: message.role,
content: message.content,
} as ModelMessage;
}));
}
async function buildTaskContext(selectedTaskId?: string) {
if (!selectedTaskId) {
return "";
}
const manifest = await readManifest(selectedTaskId).catch(() => null);
if (!manifest) {
return `Current task id: ${selectedTaskId}\nNo cad-task.json was found.`;
}
const sourcePath = String(manifest.source?.path || "");
let sourceText = "";
if (sourcePath && (sourcePath.endsWith(".py") || sourcePath.endsWith(".scad") || sourcePath.endsWith(".json"))) {
const absoluteSourcePath = path.join(taskDir(selectedTaskId), sourcePath);
sourceText = await fs.readFile(absoluteSourcePath, "utf8").catch(() => "");
}
return [
`Current task id: ${selectedTaskId}`,
"Current cad-task.json:",
compactJson(manifest, 10000),
sourceText ? "Current editable source of truth:" : "",
sourceText ? sourceText.slice(0, 18000) : "",
].filter(Boolean).join("\n");
}
function viewerContextSummary(viewerContext: unknown[]) {
if (!viewerContext.length) {
return "";
}
return [
"Current viewer context JSON from the right-side CAD preview:",
compactJson(viewerContext, 14000),
].join("\n");
}
function attachmentSummary(attachments: unknown[]) {
if (!attachments.length) {
return "";
}
return [
"Current uploaded attachment JSON available to the agent:",
compactJson(attachments, 14000),
"If a STEP/STP attachment is present, inspect its deterministic evidence, then author DesignIR without reading or importing the teacher STEP.",
].join("\n");
}
function buildSystemPrompt(viewerContext: unknown[], attachments: unknown[], taskContext = "") {
return [
"You are the CAD Agent Studio assistant.",
"You help create and modify CAD models through a real server-side CAD generation tool.",
"Strict honesty rule: never claim that a CAD file/model was generated unless the generate_cad tool has succeeded and returned artifact URLs.",
"Never return only code when the user asks to generate CAD. Use the generate_cad tool instead.",
"Do not rely on hardcoded templates, canned examples, mock data, fake filenames, or imaginary viewer state.",
"The generate_cad tool validates executable DesignIR, selects a backend through CAD Router, and rebuilds STEP in a teacher-free sandbox.",
"When STEP files are attached, the source is teacher and acceptance truth only. Reconstruction consumes deterministic evidence, never source B-Rep.",
"All uploaded files are available to you. Images are attached directly to the latest user message when the selected provider/model supports vision. STEP/STP and binary files are available through inspect_uploaded_file.",
"When the user asks to model from an uploaded image, inspect the image directly from the image part. If the selected provider/model cannot process images, say that clearly and ask the user to switch to a vision-capable OpenAI-compatible model.",
"When the user asks to use an uploaded STEP/STP or other non-image file, call inspect_uploaded_file before making claims about its geometry or contents.",
"When viewer context is present, use its cad-edit-intent.v1 or cad-ai-geometry-selection.v1 payload as precise geometry context.",
"If viewer context selection.scope is selected_reference_only, modify only that selected topology/feature. Do not change a global pattern parameter or all repeated/symmetric features unless the user explicitly asks for all of them.",
"For selected holes, use the selected reference center/surface/bbox/adjacent selectors to identify the individual feature. If the editable source models a repeated pattern with one shared diameter variable, split out or override the selected instance instead of changing the shared variable.",
"Prefer editing the DesignIR recorded in cad-task.json before regenerating STEP and viewer assets.",
"For CAD generation, author complete DesignIR 2.0 with parameters, datums, features, constraints, edit interface, and validation perturbations.",
"Never embed STEP/B-Rep/mesh data or source topology references in DesignIR.",
"If CAD generation is requested and the generate_cad tool is available, call generate_cad.",
"For modifications, use the current editable source of truth when provided. Preserve unrelated geometry and change only the selected/requested feature.",
"",
`Viewer context count: ${viewerContext.length}`,
`Attachment count: ${attachments.length}`,
"",
attachmentSummary(attachments),
"",
viewerContextSummary(viewerContext),
"",
taskContext,
].join("\n");
}
function lastUserText(messages: ChatMessage[]) {
return [...messages].reverse().find((message) => message.role === "user")?.content || "";
}
function hostnameForUrl(value: string) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return "";
}
}
function isOfficialOpenAIBaseURL(value: string) {
const hostname = hostnameForUrl(value);
return hostname === "api.openai.com";
}
function buildLanguageModel({
apiKey,
provider,
providerConfig,
model,
}: {
apiKey: string;
provider: string;
providerConfig: LlmConfig["providers"][string];
model: string;
}) {
if (providerConfig.type === "openai" && isOfficialOpenAIBaseURL(providerConfig.baseURL)) {
return createOpenAI({ apiKey, baseURL: providerConfig.baseURL })(model);
}
return createOpenAICompatible({
name: provider,
apiKey,
baseURL: providerConfig.baseURL,
})(model);
}
function errorDetail(error: unknown) {
if (!error || typeof error !== "object") {
return "";
}
const record = error as Record<string, unknown>;
const statusCode = record.statusCode || record.status || record.responseStatus;
const data = record.data || record.responseBody || record.body;
const parts = [];
if (statusCode) {
parts.push(`status=${String(statusCode)}`);
}
if (typeof data === "string") {
parts.push(data.slice(0, 500));
} else if (data && typeof data === "object") {
const maybeError = (data as { error?: { message?: unknown } }).error;
const message = maybeError?.message || (data as { message?: unknown }).message;
if (message) {
parts.push(String(message));
}
}
return parts.join("; ");
}
function formatModelError({
error,
provider,
model,
providerConfig,
}: {
error: unknown;
provider: string;
model: string;
providerConfig: LlmConfig["providers"][string];
}) {
const baseHost = hostnameForUrl(providerConfig.baseURL) || "unknown-host";
const message = error instanceof Error ? error.message : "unknown error";
const detail = errorDetail(error);
return [
`provider: ${provider}`,
`model: ${model}`,
`baseURL host: ${baseHost}`,
detail ? `${message} (${detail})` : message,
].join("\n");
}
function localAssistantReply({
messages,
attachments,
viewerContext,
selectedTaskId,
}: {
messages: ChatMessage[];
attachments: unknown[];
viewerContext: unknown[];
selectedTaskId?: string;
}) {
const lastUser = [...messages].reverse().find((message) => message.role === "user")?.content || "";
const lines = [
"已收到请求。我现在可以读取上传文件、当前 task、以及 CAD Viewer 工具栏发送过来的结构化几何上下文。",
selectedTaskId ? `当前任务: ${selectedTaskId}` : "当前还没有绑定 CAD task。",
attachments.length ? `本次消息附件: ${attachments.length} 个。` : "本次消息没有新附件。",
viewerContext.length ? `Viewer 上下文: ${viewerContext.length} 条,下一步会优先按这些选区/编辑 intent 修改源文件。` : "Viewer 尚未发送选区或编辑 intent。",
"",
lastUser ? `用户指令: ${lastUser}` : "请描述要生成或修改的 CAD 模型。",
"",
"提示: 配置 OPENAI_API_KEY 或 DEEPSEEK_API_KEY 后,这里会切换为真实模型 streaming 回复。",
];
return lines.join("\n");
}
async function inspectStepAttachment(attachment: AttachmentRecord) {
const absolutePath = attachmentAbsolutePath(attachment);
const script = [
"from build123d import import_step",
"import json, sys",
"path = sys.argv[1]",
"shape = import_step(path)",
"def vec(value):",
" if value is None:",
" return None",
" for names in (('X','Y','Z'), ('x','y','z')):",
" try:",
" return [float(getattr(value, name)) for name in names]",
" except Exception:",
" pass",
" try:",
" return [float(value[i]) for i in range(3)]",
" except Exception:",
" return str(value)",
"def count(method_name):",
" try:",
" return len(getattr(shape, method_name)())",
" except Exception:",
" return None",
"bbox = None",
"try:",
" bb = shape.bounding_box()",
" center = getattr(bb, 'center', None)",
" center = center() if callable(center) else center",
" bbox = {'min': vec(bb.min), 'max': vec(bb.max), 'size': vec(bb.size), 'center': vec(center)}",
"except Exception as exc:",
" bbox = {'error': str(exc)}",
"payload = {",
" 'type': str(type(shape)),",
" 'bbox': bbox,",
" 'volume': float(getattr(shape, 'volume')) if getattr(shape, 'volume', None) is not None else None,",
" 'solids': count('solids'),",
" 'faces': count('faces'),",
" 'edges': count('edges'),",
" 'vertices': count('vertices'),",
"}",
"print(json.dumps(payload, ensure_ascii=False))",
].join("\n");
const [{ stdout }, excerpt] = await Promise.all([
execFileAsync("python3", ["-c", script, absolutePath], {
timeout: 30_000,
maxBuffer: 1024 * 1024,
}),
readTextExcerpt(attachment, 24_000).catch(() => null),
]);
const geometry = JSON.parse(stdout.trim().split(/\r?\n/).pop() || "{}");
return {
attachment,
inspectionKind: "step_geometry",
geometry,
stepTextExcerpt: excerpt ? {
text: excerpt.text,
truncated: excerpt.truncated,
bytesRead: excerpt.bytesRead,
totalBytes: excerpt.totalBytes,
} : null,
};
}
async function inspectGenericAttachment(attachment: AttachmentRecord, includeBase64 = false) {
if (isStepAttachment(attachment)) {
return inspectStepAttachment(attachment);
}
if (isTextLikeAttachment(attachment)) {
const excerpt = await readTextExcerpt(attachment, 120_000);
return {
attachment,
inspectionKind: "text",
text: excerpt.text,
truncated: excerpt.truncated,
bytesRead: excerpt.bytesRead,
totalBytes: excerpt.totalBytes,
};
}
const absolutePath = attachmentAbsolutePath(attachment);
const data = await fs.readFile(absolutePath);
const prefix = data.subarray(0, Math.min(data.byteLength, 64_000));
return {
attachment,
inspectionKind: isImageAttachment(attachment) ? "image_binary" : "binary",
mediaType: attachmentMediaType(attachment),
totalBytes: data.byteLength,
note: isImageAttachment(attachment)
? "This image should also be attached directly as an image part in the user message for vision-capable models."
: "Binary content is not directly human-readable. Use metadata and request user clarification unless the format is explicitly supported.",
base64Prefix: includeBase64 ? prefix.toString("base64") : undefined,
prefixBytes: includeBase64 ? prefix.byteLength : undefined,
};
}
export async function streamAgentResponse({
messages,
attachments,
viewerContext,
provider,
model,
selectedTaskId,
}: {
messages: unknown[];
attachments: unknown[];
viewerContext: unknown[];
provider?: string;
model?: string;
selectedTaskId?: string;
}) {
const config = loadLlmConfig();
const selected = selectedModelId(provider, model, config);
const apiKey = resolveProviderApiKey(selected.providerConfig);
const chatMessages = normalizedMessages(messages);
const uploadedAttachments = normalizedAttachments(attachments);
if (!apiKey) {
return uiTextResponse(localAssistantReply({ messages: chatMessages, attachments: uploadedAttachments, viewerContext, selectedTaskId }));
}
const languageModel = buildLanguageModel({
apiKey,
provider: selected.provider,
providerConfig: selected.providerConfig,
model: selected.model,
});
let cadGeneration: CadGenerationResult | null = null;
let streamWriter: { write: (part: any) => void } | null = null;
const transientProgressSteps = new Set(["analyze_request", "agent_stream"]);
const writeCadProgress = (payload: CadProgressPayload) => {
streamWriter?.write({
type: "data-cad-progress",
data: payload,
id: `cad_progress_${payload.step}`,
transient: transientProgressSteps.has(payload.step),
});
};
const writeCadError = (stage: string, message: string) => {
streamWriter?.write({
type: "data-cad-error",
data: { stage, message },
id: `cad_error_${stage}`,
});
};
let agentStreamFailed = false;
let agentStreamCompleted = false;
const completeAgentStream = (message: string) => {
if (agentStreamFailed || agentStreamCompleted) return;
agentStreamCompleted = true;
writeCadProgress({
step: "agent_stream",
label: "调用模型和工具",
status: "success",
message,
});
};
const failAgentStream = (message: string) => {
agentStreamFailed = true;
writeCadProgress({
step: "agent_stream",
label: "调用模型和工具",
status: "error",
message,
});
writeCadError("agent_stream", message);
};
const taskContext = await buildTaskContext(selectedTaskId);
const cadTools = {
inspect_uploaded_file: tool({
description: [
"Inspect a user-uploaded file before using it for CAD generation.",
"Use this for STEP/STP, text, source, PDF, or binary attachments.",
"For STEP/STP this returns build123d geometry facts such as bbox, volume, and topology counts plus a STEP text excerpt.",
].join(" "),
inputSchema: z.object({
id: z.string().optional().describe("Attachment id from the uploaded file list."),
taskId: z.string().optional().describe("Attachment taskId."),
path: z.string().optional().describe("Attachment path inside the task directory."),
includeBase64: z.boolean().default(false).describe("For binary files, include a base64 prefix. Avoid unless needed."),
}),
execute: async ({ id, taskId, path: attachmentPath, includeBase64 }) => {
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "running",
message: "正在读取附件元数据和可解析内容。",
});
const attachment = findAttachment(uploadedAttachments, { id, taskId, path: attachmentPath });
if (!attachment) {
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "error",
message: "没有找到模型请求引用的附件。",
});
return {
ok: false,
error: "Attachment not found. Use an id/taskId/path from the uploaded attachment JSON.",
availableAttachments: uploadedAttachments.map((item) => ({
id: item.id,
taskId: item.taskId,
path: item.path,
name: item.name,
kind: item.kind,
mime: item.mime,
size: item.size,
})),
};
}
try {
const inspected = {
ok: true,
...(await inspectGenericAttachment(attachment, includeBase64)),
};
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "success",
message: `${attachment.name || attachment.path} 已读取。`,
});
return inspected;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to inspect uploaded file.";
writeCadProgress({
step: "inspect_uploaded_file",
label: "检查上传文件",
status: "error",
message,
});
return {
ok: false,
attachment,
error: message,
};
}
},
}),
generate_cad: tool({
description: [
"Generate a real CAD model from agent-authored executable DesignIR 2.0.",
"Call this only when the user wants CAD generation or a model modification.",
"CAD Router selects the backend and the server rebuilds STEP without teacher geometry.",
].join(" "),
inputSchema: z.object({
summary: z.string().describe("Concise user-facing summary of the generated CAD model."),
designirFilename: z.string().describe("DesignIR filename, e.g. model.designir.json."),
stepFilename: z.string().describe("STEP output filename, e.g. model.step."),
designir: z.record(z.string(), z.unknown()).describe("Complete executable DesignIR 2.0 JSON object."),
teacherTaskId: z.string().optional().describe("Uploaded teacher STEP task id for independent acceptance only."),
teacherPath: z.string().optional().describe("Uploaded teacher STEP path for independent acceptance only."),
assumptions: z.array(z.string()).default([]).describe("Assumptions made because the user did not specify every dimension."),
}),
execute: async ({
summary,
designirFilename,
stepFilename,
designir,
teacherTaskId,
teacherPath,
assumptions,
}) => {
writeCadProgress({
step: "generate_designir",
label: "生成 DesignIR",
status: "success",
message: designirFilename,
});
writeCadProgress({
step: "execute_cad",
label: "隔离重建 STEP",
status: "running",
message: "正在进行防作弊检查、CAD Router 选路和无教师重建。",
});
try {
cadGeneration = await executeDesignIR({
prompt: lastUserText(chatMessages),
summary,
designirFilename,
stepFilename,
designir,
teacherTaskId,
teacherPath,
assumptions,
selectedTaskId,
});
} catch (error) {
const message = error instanceof Error ? error.message : "CAD generation failed.";
writeCadProgress({
step: "execute_cad",
label: "隔离重建 STEP",
status: "error",
message,
});
failAgentStream(message);
throw error;
}
writeCadProgress({
step: "execute_cad",
label: "隔离重建 STEP",
status: "success",
message: cadGeneration.artifactPath,
});
writeCadProgress({
step: "update_preview",
label: "更新右侧预览",
status: "success",
message: cadGeneration.viewerAssetUrl ? "预览网格已准备完成。" : "CAD 文件已生成。",
});
completeAgentStream("模型和 CAD 工具执行已完成。");
streamWriter?.write({
type: "data-cad-result",
data: {
taskId: cadGeneration.taskId,
sourcePath: cadGeneration.sourcePath,
artifactPath: cadGeneration.artifactPath,
artifactUrl: cadGeneration.artifactUrl,
previewPath: cadGeneration.previewPath,
previewUrl: cadGeneration.previewUrl,
viewerAssetPath: cadGeneration.viewerAssetPath,
viewerAssetUrl: cadGeneration.viewerAssetUrl,
summary: cadGeneration.summary,
},
id: `cad_result_${cadGeneration.taskId}`,
});
return cadGeneration;
},
}),
};
const formatCurrentModelError = (error: unknown) => formatModelError({
error,
provider: selected.provider,
model: selected.model,
providerConfig: selected.providerConfig,
});
const stream = createUIMessageStream<UIMessage>({
execute: async ({ writer }) => {
streamWriter = writer;
writeCadProgress({
step: "analyze_request",
label: "分析需求",
status: "running",
message: "正在整理消息、附件、当前 task 和 Viewer 上下文。",
});
try {
const modelMessages = await buildModelMessages(chatMessages, uploadedAttachments);
writeCadProgress({
step: "analyze_request",
label: "分析需求",
status: "success",
message: `${modelMessages.length} 条上下文消息已准备。`,
});
writeCadProgress({
step: "agent_stream",
label: "调用模型和工具",
status: "running",
message: `${selected.provider}:${selected.model}`,
});
const result = streamText({
model: languageModel,
system: buildSystemPrompt(viewerContext, uploadedAttachments, taskContext),
tools: cadTools,
stopWhen: isStepCount(3),
messages: modelMessages,
});
writer.merge(result.toUIMessageStream({
onEnd: () => {
completeAgentStream(cadGeneration ? "模型和 CAD 工具执行已完成。" : "模型回复已完成。");
},
onError: (error) => {
const message = formatCurrentModelError(error);
failAgentStream(message);
return message;
},
}));
} catch (error) {
const message = formatCurrentModelError(error);
failAgentStream(message);
writeTextPart(writer, `Agent 请求没有成功,未写入任何可预览结果。\n\n原因:${message}`);
}
},
onError: formatCurrentModelError,
});
return createUIMessageStreamResponse({
stream,
headers: {
"Cache-Control": "no-store",
},
});
}
+73
View File
@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadLlmConfig, publicConfig, resolveProviderApiKey, selectedModelId } from "./config";
test("llm config loads OpenAI and DeepSeek providers without exposing keys", () => {
const config = loadLlmConfig();
assert.equal(config.defaultProvider, "deepseek");
assert.ok(config.providers.openai);
assert.ok(config.providers.deepseek);
const metadata = publicConfig(config);
assert.equal("apiKeyEnv" in metadata.providers.openai, false);
});
test("environment variables cannot override the configured OpenAI baseURL", () => {
const configuredBaseURL = loadLlmConfig().providers.openai.baseURL;
const previous = process.env.OPENAI_BASE_URL;
process.env.OPENAI_BASE_URL = "https://example.test/v1";
try {
const config = loadLlmConfig();
assert.equal(config.providers.openai.baseURL, configuredBaseURL);
} finally {
if (previous === undefined) {
delete process.env.OPENAI_BASE_URL;
} else {
process.env.OPENAI_BASE_URL = previous;
}
}
});
test("selectedModelId falls back to configured provider default", () => {
const config = loadLlmConfig();
const selected = selectedModelId("missing", "", config);
assert.equal(selected.provider, "deepseek");
assert.equal(selected.model, config.providers.deepseek.models.default);
});
test("resolveProviderApiKey reads only the named environment variable", () => {
const previous = process.env.TEST_MODEL_API_KEY;
process.env.TEST_MODEL_API_KEY = "test-secret";
try {
const apiKey = resolveProviderApiKey({
type: "openai-compatible",
apiKeyEnv: "TEST_MODEL_API_KEY",
baseURL: "https://example.test/v1",
models: { default: "model-test" },
});
assert.equal(apiKey, "test-secret");
} finally {
if (previous === undefined) {
delete process.env.TEST_MODEL_API_KEY;
} else {
process.env.TEST_MODEL_API_KEY = previous;
}
}
});
test("a private direct key is usable but absent from public config", () => {
const config = loadLlmConfig();
const provider = {
type: "openai-compatible" as const,
apiKey: "private-test-key",
baseURL: "https://example.test/v1",
models: { default: "model-test" },
};
assert.equal(resolveProviderApiKey(provider), "private-test-key");
const metadata = publicConfig({
...config,
providers: { test: provider },
defaultProvider: "test",
defaultModel: "test:model-test",
});
assert.equal(JSON.stringify(metadata).includes("private-test-key"), false);
});
+119
View File
@@ -0,0 +1,119 @@
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import { z } from "zod";
import { appPath, appRoot, enginePath } from "@/lib/paths";
const modelMapSchema = z.record(z.string(), z.string());
const providerSchema = z.object({
type: z.enum(["openai", "openai-compatible"]),
apiKey: z.string().min(1).optional(),
apiKeyEnv: z.string().min(1).optional(),
baseURL: z.string().min(1),
models: modelMapSchema,
}).refine((provider) => Boolean(provider.apiKey || provider.apiKeyEnv), {
message: "provider requires apiKey or apiKeyEnv",
});
const llmConfigSchema = z.object({
defaultProvider: z.string().min(1),
defaultModel: z.string().min(1),
providers: z.record(z.string(), providerSchema),
uploads: z.object({
maxImageMB: z.number().positive(),
maxStepMB: z.number().positive(),
maxOtherMB: z.number().positive().default(50),
allowed: z.array(z.string().min(1)),
}),
cad: z.object({
taskRoot: z.string().min(1),
viewer: z.object({
mode: z.literal("iframe"),
allowedOrigins: z.array(z.string().min(1)).default([]),
}),
}),
});
export type LlmConfig = z.infer<typeof llmConfigSchema>;
function expandEnvTemplate(value: string) {
return value.replace(/\$\{([A-Z0-9_]+)(?::-([^}]*))?\}/gi, (_match, name: string, fallback = "") => {
const envValue = process.env[name];
return envValue === undefined || envValue === "" ? fallback : envValue;
});
}
function expandConfig(value: unknown): unknown {
if (typeof value === "string") {
return expandEnvTemplate(value);
}
if (Array.isArray(value)) {
return value.map(expandConfig);
}
if (value && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, nextValue]) => [key, expandConfig(nextValue)]));
}
return value;
}
export function loadLlmConfig(): LlmConfig {
const candidates = [
process.env.CAD_AGENT_STUDIO_CONFIG
? path.resolve(process.env.CAD_AGENT_STUDIO_CONFIG)
: null,
enginePath("llm.config.yaml"),
appPath("config", "llm.config.yaml"),
appPath("config", "llm.config.yaml.example"),
].filter((candidate): candidate is string => Boolean(candidate));
const configPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!configPath) {
throw new Error("No LLM configuration file was found.");
}
const source = fs.readFileSync(configPath, "utf8");
return llmConfigSchema.parse(expandConfig(YAML.parse(source)));
}
export function resolveTaskRoot(config = loadLlmConfig()) {
return path.resolve(appRoot(), config.cad.taskRoot);
}
export function publicConfig(config = loadLlmConfig()) {
return {
defaultProvider: config.defaultProvider,
defaultModel: config.defaultModel,
providers: Object.fromEntries(Object.entries(config.providers).map(([id, provider]) => [
id,
{
type: provider.type,
baseURL: provider.baseURL,
models: provider.models,
configured: Boolean(resolveProviderApiKey(provider)),
},
])),
uploads: config.uploads,
cad: {
viewer: config.cad.viewer,
},
};
}
export function resolveProviderApiKey(provider: LlmConfig["providers"][string]) {
const directKey = String(provider.apiKey || "").trim();
if (directKey) {
return directKey;
}
const envName = String(provider.apiKeyEnv || "").trim();
if (!envName) {
return "";
}
return process.env[envName] || "";
}
export function selectedModelId(providerId: string | undefined, modelId: string | undefined, config = loadLlmConfig()) {
const provider = providerId && config.providers[providerId] ? providerId : config.defaultProvider;
const providerConfig = config.providers[provider];
const candidate = String(modelId || "").trim();
const model = candidate || providerConfig.models.default || config.defaultModel.split(":").pop() || "";
return { provider, model, providerConfig };
}
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
export function appRoot() {
return path.resolve(process.env.CAD_AGENT_STUDIO_ROOT || process.cwd());
}
export function appPath(...segments: string[]) {
return path.join(appRoot(), ...segments);
}
export function engineRoot() {
return path.resolve(
process.env.CAD_ENGINE_ROOT
|| path.join(/* turbopackIgnore: true */ appRoot(), ".."),
);
}
export function enginePath(...segments: string[]) {
return path.join(engineRoot(), ...segments);
}
export function relativeToApp(absolutePath: string) {
return path.relative(appRoot(), absolutePath);
}
@@ -0,0 +1,87 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { engineRoot } from "@/lib/paths";
import { safeTaskId, taskDir } from "@/lib/task-store";
const execFileAsync = promisify(execFile);
export function artifactUrl(taskId: string, relativePath: string, version?: string | number) {
const url = `/api/tasks/${encodeURIComponent(safeTaskId(taskId))}/artifacts/${relativePath
.split(/[\\/]+/)
.map((part) => encodeURIComponent(part))
.join("/")}`;
const normalizedVersion = String(version || "").trim();
return normalizedVersion ? `${url}?v=${encodeURIComponent(normalizedVersion)}` : url;
}
export function safeArtifactPath(relativePath: string) {
const normalized = path.normalize(String(relativePath || ""));
if (!normalized || path.isAbsolute(normalized) || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
throw new Error("Invalid artifact path");
}
return normalized;
}
export async function readArtifact(taskId: string, relativePath: string) {
const root = taskDir(taskId);
const safePath = safeArtifactPath(relativePath);
const absolutePath = path.join(root, safePath);
const resolvedRoot = path.resolve(root);
const resolvedFile = path.resolve(absolutePath);
if (!resolvedFile.startsWith(`${resolvedRoot}${path.sep}`) && resolvedFile !== resolvedRoot) {
throw new Error("Artifact path escapes task directory");
}
return {
absolutePath: resolvedFile,
data: await fs.readFile(resolvedFile),
};
}
export function contentTypeForArtifact(relativePath: string) {
const lower = relativePath.toLowerCase();
if (lower.endsWith(".glb")) return "model/gltf-binary";
if (lower.endsWith(".step") || lower.endsWith(".stp")) return "model/step";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".py")) return "text/x-python; charset=utf-8";
return "application/octet-stream";
}
export async function createStepGlbPreview({
taskId,
stepPath,
}: {
taskId: string;
stepPath: string;
}) {
const safeStepPath = safeArtifactPath(stepPath);
const root = taskDir(taskId);
const stepAbsolutePath = path.join(root, safeStepPath);
const script = [
"import path from 'node:path';",
"import { compileStepTopologyArtifact } from './text-to-cad/viewer/src/server/step/stepArtifactCompiler.mjs';",
"const repoRoot = process.argv[1];",
"const stepPath = process.argv[2];",
"const result = await compileStepTopologyArtifact({ repoRoot, stepPath, force: true });",
"console.log(JSON.stringify(result));",
].join("\n");
const repoRoot = engineRoot();
const { stdout } = await execFileAsync("node", ["--input-type=module", "-e", script, repoRoot, stepAbsolutePath], {
cwd: repoRoot,
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
const jsonLine = stdout.trim().split(/\r?\n/).reverse().find((line) => line.trim().startsWith("{"));
if (!jsonLine) {
throw new Error("STEP GLB preview generation did not return JSON.");
}
const result = JSON.parse(jsonLine) as { ok?: boolean; glbPath?: string; error?: string };
if (!result.ok || !result.glbPath) {
throw new Error(result.error || "STEP GLB preview generation failed.");
}
return path.relative(root, result.glbPath);
}
+289
View File
@@ -0,0 +1,289 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolveTaskRoot } from "@/lib/config";
export type ArtifactKind = "image" | "step" | "source" | "manifest" | "other";
export type StudioAttachment = {
id: string;
taskId: string;
name: string;
kind: ArtifactKind;
path: string;
size: number;
sha256: string;
mime: string;
};
export type TaskManifest = {
schema_version: "1.0";
request: string;
route: Record<string, unknown>;
source: Record<string, unknown>;
artifacts: Array<{ path: string; role: string; kind?: string }>;
parameters: Record<string, unknown>;
experience: Record<string, unknown>;
assumptions: string[];
validation: string[];
viewer_links: string[];
studio?: Record<string, unknown>;
};
export function newTaskId(prefix = "cad") {
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
}
export function safeTaskId(taskId: string) {
const normalized = String(taskId || "").trim();
if (!/^[a-zA-Z0-9_-]{4,80}$/.test(normalized)) {
throw new Error("Invalid task id");
}
return normalized;
}
export function taskDir(taskId: string) {
return path.join(resolveTaskRoot(), safeTaskId(taskId));
}
function taskArtifactUrl(taskId: string, relativePath: string, version?: string | number) {
const url = `/api/tasks/${encodeURIComponent(safeTaskId(taskId))}/artifacts/${relativePath
.split(/[\\/]+/)
.map((part) => encodeURIComponent(part))
.join("/")}`;
const normalizedVersion = String(version || "").trim();
return normalizedVersion ? `${url}?v=${encodeURIComponent(normalizedVersion)}` : url;
}
export async function ensureTask(taskId = newTaskId()) {
const id = safeTaskId(taskId);
const dir = taskDir(id);
await fs.mkdir(path.join(dir, "uploads"), { recursive: true });
await fs.mkdir(path.join(dir, "revisions"), { recursive: true });
return { taskId: id, dir };
}
export function sanitizeFilename(filename: string) {
const base = path.basename(String(filename || "upload.bin"));
return base.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+/, "") || "upload.bin";
}
export function classifyUpload(filename: string, mime = ""): ArtifactKind {
const lowerName = filename.toLowerCase();
const lowerMime = mime.toLowerCase();
if (lowerMime.startsWith("image/")) {
return "image";
}
if (lowerName.endsWith(".step") || lowerName.endsWith(".stp")) {
return "step";
}
return "other";
}
export async function sha256(buffer: Buffer) {
return crypto.createHash("sha256").update(buffer).digest("hex");
}
export async function writeUploadedFile({
taskId,
filename,
mime,
data,
}: {
taskId?: string;
filename: string;
mime: string;
data: Buffer;
}): Promise<StudioAttachment> {
const task = await ensureTask(taskId);
const safeName = sanitizeFilename(filename);
const digest = await sha256(data);
const id = `file_${digest.slice(0, 16)}`;
const relativePath = path.join("uploads", `${id}_${safeName}`);
const absolutePath = path.join(task.dir, relativePath);
await fs.writeFile(absolutePath, data);
const attachment = {
id,
taskId: task.taskId,
name: safeName,
kind: classifyUpload(safeName, mime),
path: relativePath,
size: data.byteLength,
sha256: digest,
mime,
};
if (attachment.kind === "step") {
await upsertManifest(task.taskId, {
request: `Uploaded ${safeName} for CAD reconstruction`,
source: {
path: relativePath,
format: "step",
backend: "uploaded",
source_editability: "reconstruction_reference",
},
artifacts: [{ path: relativePath, role: "reference", kind: "step" }],
validation: [
"Uploaded STEP file is a reconstruction reference for the LLM/tool workflow.",
"Uploaded STEP files are not opened directly in the right-side preview.",
],
studio: {
uploadedAt: new Date().toISOString(),
uploadPurpose: "llm_reconstruct_cad_model",
reconstructionReference: relativePath,
},
});
}
return attachment;
}
export async function manifestPath(taskId: string) {
return path.join(taskDir(taskId), "cad-task.json");
}
export function defaultManifest(request = ""): TaskManifest {
return {
schema_version: "1.0",
request,
route: {
selected_backend: "agent-studio",
runner_skill: "cad-router",
project: "text-to-cad",
fallback_order: ["build123d", "simplecadapi"],
workflow_profiles: [],
},
source: {},
artifacts: [],
parameters: {},
experience: {
context_kind: "agent_studio_session",
methods_applied: [],
},
assumptions: [],
validation: [],
viewer_links: [],
studio: {},
};
}
export async function readManifest(taskId: string): Promise<TaskManifest | null> {
try {
const source = await fs.readFile(await manifestPath(taskId), "utf8");
return JSON.parse(source) as TaskManifest;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw error;
}
}
export async function writeManifest(taskId: string, manifest: TaskManifest) {
await ensureTask(taskId);
await fs.writeFile(await manifestPath(taskId), `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
export async function upsertManifest(taskId: string, patch: Partial<TaskManifest>) {
const current = (await readManifest(taskId)) || defaultManifest(patch.request || "");
const next: TaskManifest = {
...current,
...patch,
route: { ...current.route, ...(patch.route || {}) },
source: { ...current.source, ...(patch.source || {}) },
parameters: { ...current.parameters, ...(patch.parameters || {}) },
experience: { ...current.experience, ...(patch.experience || {}) },
studio: { ...(current.studio || {}), ...(patch.studio || {}) },
artifacts: patch.artifacts || current.artifacts,
assumptions: patch.assumptions || current.assumptions,
validation: patch.validation || current.validation,
viewer_links: patch.viewer_links || current.viewer_links,
};
return writeManifest(taskId, next);
}
export async function readTask(taskId: string) {
const dir = taskDir(taskId);
const manifest = await readManifest(taskId);
const safeId = safeTaskId(taskId);
const artifactVersion = String(manifest?.studio?.latestVersion || "").trim();
return {
taskId: safeId,
dir,
manifest,
artifacts: (manifest?.artifacts || []).map((artifact) => ({
...artifact,
url: taskArtifactUrl(safeId, artifact.path),
})),
latestArtifact: manifest?.studio?.latestArtifact
? {
path: String(manifest.studio.latestArtifact),
url: taskArtifactUrl(safeId, String(manifest.studio.latestArtifact), artifactVersion),
}
: null,
latestPreview: manifest?.studio?.latestPreview
? {
path: String(manifest.studio.latestPreview),
url: taskArtifactUrl(safeId, String(manifest.studio.latestPreview), artifactVersion),
}
: null,
latestViewerAsset: manifest?.studio?.latestViewerAsset
? {
path: String(manifest.studio.latestViewerAsset),
url: taskArtifactUrl(safeId, String(manifest.studio.latestViewerAsset), artifactVersion),
}
: null,
viewerUrl: "",
};
}
export async function nextRevisionId(taskId: string) {
const revisionsDir = path.join(taskDir(taskId), "revisions");
await fs.mkdir(revisionsDir, { recursive: true });
const entries = await fs.readdir(revisionsDir).catch(() => []);
const nextNumber = entries
.map((entry) => Number(/^rev_(\d+)$/.exec(entry)?.[1]))
.filter(Number.isFinite)
.reduce((max, value) => Math.max(max, value), 0) + 1;
return `rev_${String(nextNumber).padStart(3, "0")}`;
}
export async function createModificationRevision({
taskId,
instruction,
viewerContext,
}: {
taskId: string;
instruction: string;
viewerContext: unknown[];
}) {
const current = (await readManifest(taskId)) || defaultManifest(instruction);
const revisionId = await nextRevisionId(taskId);
const revisionDir = path.join(taskDir(taskId), "revisions", revisionId);
await fs.mkdir(revisionDir, { recursive: true });
const editRequestPath = path.join("revisions", revisionId, "edit-request.json");
await fs.writeFile(path.join(taskDir(taskId), editRequestPath), `${JSON.stringify({
instruction,
viewerContext,
createdAt: new Date().toISOString(),
}, null, 2)}\n`);
const sourcePath = String(current.source?.path || "");
const editable = sourcePath.endsWith(".py") || sourcePath.endsWith(".json");
const nextManifest = await upsertManifest(taskId, {
request: instruction || current.request,
source: {
...current.source,
source_editability: editable ? "editable_source" : "reconstructed",
},
artifacts: current.artifacts,
validation: [
...current.validation,
`Agent Studio captured modification ${revisionId}; generation backend must apply edit-request.json to the source of truth.`,
],
studio: {
...(current.studio || {}),
latestRevision: revisionId,
latestEditRequest: editRequestPath,
},
});
return { revisionId, manifest: nextManifest, editRequestPath };
}
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateUpload } from "./upload-validation";
test("upload validation allows images", () => {
const result = validateUpload("reference.png", "image/png", 128);
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.kind, "image");
});
test("upload validation allows STEP files by extension", () => {
const result = validateUpload("part.step", "application/octet-stream", 128);
assert.equal(result.ok, true);
if (result.ok) assert.equal(result.kind, "step");
});
test("upload validation rejects files outside image and STEP scope", () => {
const result = validateUpload("notes.txt", "text/plain", 128);
assert.equal(result.ok, false);
});
@@ -0,0 +1,27 @@
import { loadLlmConfig } from "@/lib/config";
import { classifyUpload } from "@/lib/task-store";
export function validateUpload(filename: string, mime: string, size: number) {
const config = loadLlmConfig();
const kind = classifyUpload(filename, mime);
const lowerName = filename.toLowerCase();
const lowerMime = mime.toLowerCase() || "application/octet-stream";
const allowed = config.uploads.allowed.some((entry) => {
const normalized = entry.toLowerCase();
if (normalized === "*" || normalized === "*/*") return true;
if (normalized.endsWith("/*")) return lowerMime.startsWith(normalized.slice(0, -1));
return normalized.startsWith(".") ? lowerName.endsWith(normalized) : lowerMime === normalized;
});
if (!allowed) {
return { ok: false as const, error: "This file type is not enabled in config/llm.config.yaml uploads.allowed." };
}
const maxOtherMB = "maxOtherMB" in config.uploads && typeof config.uploads.maxOtherMB === "number"
? config.uploads.maxOtherMB
: config.uploads.maxStepMB;
const maxMB = kind === "image" ? config.uploads.maxImageMB : kind === "step" ? config.uploads.maxStepMB : maxOtherMB;
const maxBytes = maxMB * 1024 * 1024;
if (size > maxBytes) {
return { ok: false as const, error: `${kind === "image" ? "Image" : kind === "step" ? "STEP" : "File"} upload is too large.` };
}
return { ok: true as const, kind };
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+5
View File
@@ -0,0 +1,5 @@
declare module "cadjs/*";
declare module "@/viewer-port/*";
declare module "@/viewer-port/components/*";
declare module "@/viewer-port/components/viewer/*";
declare module "@/viewer-port/components/viewer/hooks/*";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,730 @@
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";
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 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);
scene.update({
theme: {
...theme,
materials: {
...theme.materials,
defaultColor: "#ff0000",
fillColors: ["#ff0000"]
}
}
});
assert.equal(scene.displayRecords[0].mesh, firstMesh);
assert.equal(scene.displayRecords[0].mesh.geometry, firstGeometry);
assert.equal(scene.displayRecords[0].material.color.getHexString(), "ff0000");
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 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;
}
@@ -0,0 +1,545 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
assemblyBreadcrumb,
assemblyInspectionNode,
buildAssemblyLeafToNodePickMap,
buildSelfContainedAssemblyMeshData,
descendantLeafPartIds,
findAssemblyNode,
focusedLeafPartIdsForAssemblyInspection,
flattenAssemblyNodes,
flattenAssemblyLeafParts,
leafPartIdsForAssemblySelection,
normalizeAssemblyInspectionNodeId,
representativeAssemblyLeafPartId,
rootAssemblyInspectionNodeId,
selectableAssemblyNodeIdsForInspection,
treeSelectableAssemblyNodeIdsForInspection,
resolveAssemblyPickedPartId
} from "./meshData.js";
test("self-contained assembly mesh data maps GLB node parts by occurrence id", () => {
const topology = {
assembly: {
mesh: {
url: ".assembly.step.glb?v=abc",
addressing: "gltf-node-extras",
occurrenceIdKey: "cadOccurrenceId"
},
root: {
id: "root",
nodeType: "assembly",
children: [
{
id: "o1.2",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "sample_part",
sourcePath: "parts/sample_part.step",
worldTransform: [
1, 0, 0, 10,
0, 1, 0, 20,
0, 0, 1, 30,
0, 0, 0, 1
],
bbox: {
min: [10, 20, 30],
max: [11, 21, 30]
},
children: []
}
]
}
}
};
const parsedGlbMeshData = {
vertices: new Float32Array([
10, 20, 30,
11, 20, 30,
10, 21, 30
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: {
min: [10, 20, 30],
max: [11, 21, 30]
},
parts: [
{
id: "o1.2",
occurrenceId: "o1.2",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: {
min: [10, 20, 30],
max: [11, 21, 30]
}
}
]
};
const meshData = buildSelfContainedAssemblyMeshData(topology, parsedGlbMeshData);
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.partTransformsBaked, true);
assert.equal(meshData.parts[0].id, "o1.2");
assert.equal(meshData.parts[0].label, "sample_part");
assert.equal(meshData.parts[0].partSourcePath, "parts/sample_part.step");
assert.equal(meshData.parts[0].vertexOffset, 0);
assert.deepEqual(meshData.parts[0].bounds, {
min: [10, 20, 30],
max: [11, 21, 30]
});
assert.deepEqual(Array.from(meshData.vertices), [
10, 20, 30,
11, 20, 30,
10, 21, 30
]);
});
test("self-contained assembly mesh data groups descendant GLB nodes under topology leaves", () => {
const topology = {
assembly: {
mesh: {
url: ".assembly.step.glb?v=abc",
addressing: "gltf-node-extras",
occurrenceIdKey: "cadOccurrenceId"
},
root: {
id: "root",
nodeType: "assembly",
children: [
{
id: "o1.2",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "compound_part",
children: []
}
]
}
}
};
const parsedGlbMeshData = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0,
10, 0, 0,
11, 0, 0,
10, 1, 0
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2, 3, 4, 5]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
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,
primitiveIndex: 0,
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,
primitiveIndex: 2,
bounds: {
min: [10, 0, 0],
max: [11, 1, 0]
}
}
]
};
const meshData = buildSelfContainedAssemblyMeshData(topology, parsedGlbMeshData);
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].id, "o1.2");
assert.equal(meshData.parts[0].vertexCount, 6);
assert.equal(meshData.parts[0].triangleCount, 2);
assert.deepEqual(meshData.parts[0].sourcePartRanges, [
{
occurrenceId: "o1.2.1",
primitiveIndex: 0,
triangleOffset: 0,
triangleCount: 1
},
{
occurrenceId: "o1.2.2",
primitiveIndex: 2,
triangleOffset: 1,
triangleCount: 1
}
]);
assert.deepEqual(Array.from(meshData.indices), [0, 1, 2, 3, 4, 5]);
assert.deepEqual(meshData.parts[0].sourceBounds, {
min: [0, 0, 0],
max: [11, 1, 0]
});
});
test("self-contained assembly mesh data skips topology leaves without GLB triangles", () => {
const topology = {
assembly: {
mesh: {
url: ".assembly.step.glb?v=abc",
addressing: "gltf-node-extras",
occurrenceIdKey: "cadOccurrenceId"
},
root: {
id: "root",
nodeType: "assembly",
children: [
{
id: "o1.1",
occurrenceId: "o1.1",
nodeType: "part",
displayName: "rendered_part",
children: []
},
{
id: "o1.2",
occurrenceId: "o1.2",
nodeType: "part",
displayName: "wire_only_part",
children: []
}
]
}
}
};
const parsedGlbMeshData = {
vertices: new Float32Array([
0, 0, 0,
1, 0, 0,
0, 1, 0
]),
normals: new Float32Array([
0, 0, 1,
0, 0, 1,
0, 0, 1
]),
indices: new Uint32Array([0, 1, 2]),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
},
parts: [
{
id: "o1.1",
occurrenceId: "o1.1",
vertexOffset: 0,
vertexCount: 3,
triangleOffset: 0,
triangleCount: 1,
bounds: {
min: [0, 0, 0],
max: [1, 1, 0]
}
}
]
};
const meshData = buildSelfContainedAssemblyMeshData(topology, parsedGlbMeshData);
assert.deepEqual(meshData.parts.map((part) => part.id), ["o1.1"]);
assert.deepEqual(meshData.meshlessLeafPartIds, ["o1.2"]);
assert.equal(meshData.indices.length, 3);
});
test("assembly helpers navigate nested assemblies down to leaf parts", () => {
const root = {
id: "root",
nodeType: "assembly",
displayName: "sample_root",
children: [
{
id: "sample_module",
nodeType: "assembly",
displayName: "sample_module",
children: [
{
id: "sample_part",
nodeType: "part",
displayName: "sample_part",
children: []
}
]
}
]
};
assert.deepEqual(flattenAssemblyLeafParts(root).map((part) => part.id), ["sample_part"]);
assert.deepEqual(flattenAssemblyNodes(root).map((node) => node.id), ["root", "sample_module", "sample_part"]);
assert.equal(findAssemblyNode(root, "sample_module")?.displayName, "sample_module");
assert.deepEqual(assemblyBreadcrumb(root, "sample_part").map((node) => node.id), ["root", "sample_module", "sample_part"]);
assert.deepEqual(descendantLeafPartIds(root.children[0]), ["sample_part"]);
assert.equal(representativeAssemblyLeafPartId(root.children[0]), "sample_part");
});
test("assembly picking maps rendered leaves to scoped assembly nodes", () => {
const root = {
id: "root",
nodeType: "assembly",
children: [
{
id: "module",
occurrenceId: "o1.1",
nodeType: "assembly",
leafPartIds: ["leaf_a", "leaf_b"],
children: [
{
id: "leaf_a",
occurrenceId: "o1.1.1",
nodeType: "part",
sourcePath: "parts/a.step",
children: []
},
{
id: "leaf_b",
occurrenceId: "o1.1.2",
nodeType: "part",
children: []
}
]
}
]
};
const pickPartIdMap = buildAssemblyLeafToNodePickMap(root.children);
assert.deepEqual(
[...pickPartIdMap.entries()],
[
["leaf_a", "module"],
["leaf_b", "module"]
]
);
assert.equal(
resolveAssemblyPickedPartId("leaf_a", {
pickPartIdMap,
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
"module"
);
assert.equal(
resolveAssemblyPickedPartId("legacy_mesh_leaf", {
pickPartIdMap: new Map([["legacy_mesh_leaf", "module"]]),
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
"module"
);
const assemblyPartMap = new Map(flattenAssemblyNodes(root).map((node) => [node.id, node]));
assert.deepEqual(
leafPartIdsForAssemblySelection("module", {
assemblyPartMap,
fallbackPartId: "leaf_a",
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
["leaf_a", "leaf_b"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("leaf_a", {
assemblyPartMap,
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
["leaf_a"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("missing", {
assemblyPartMap,
fallbackPartId: "leaf_b",
validLeafPartIds: ["leaf_a", "leaf_b"]
}),
["leaf_b"]
);
assert.equal(representativeAssemblyLeafPartId(root.children[0]), "leaf_a");
});
test("nested assembly selection resolves descendant render leaves without loading sibling topology", () => {
const root = {
id: "root",
nodeType: "assembly",
children: [
{
id: "outer",
nodeType: "assembly",
children: [
{
id: "inner",
nodeType: "assembly",
children: [
{
id: "leaf_a",
nodeType: "part",
occurrenceId: "o1.1.1.1",
children: []
},
{
id: "leaf_b",
nodeType: "part",
occurrenceId: "o1.1.1.2",
children: []
}
]
}
]
},
{
id: "sibling_leaf",
nodeType: "part",
occurrenceId: "o1.2",
children: []
}
]
};
const assemblyPartMap = new Map(flattenAssemblyNodes(root).map((node) => [node.id, node]));
const validLeafPartIds = flattenAssemblyLeafParts(root).map((node) => node.id);
assert.deepEqual(
leafPartIdsForAssemblySelection("inner", {
assemblyPartMap,
validLeafPartIds
}),
["leaf_a", "leaf_b"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("outer", {
assemblyPartMap,
validLeafPartIds
}),
["leaf_a", "leaf_b"]
);
assert.deepEqual(
leafPartIdsForAssemblySelection("root", {
assemblyPartMap,
validLeafPartIds
}),
["leaf_a", "leaf_b", "sibling_leaf"]
);
});
test("assembly inspection helpers keep one inspected node and limit selectable children", () => {
const root = {
id: "root",
nodeType: "assembly",
children: [
{
id: "module",
nodeType: "assembly",
children: [
{
id: "leaf_a",
nodeType: "part",
children: []
},
{
id: "leaf_b",
nodeType: "part",
children: []
}
]
},
{
id: "compound_part",
nodeType: "part",
children: [
{
id: "leaf_c",
nodeType: "part",
children: []
},
{
id: "leaf_d",
nodeType: "part",
children: []
}
]
},
{
id: "sibling",
nodeType: "part",
children: []
}
]
};
assert.equal(rootAssemblyInspectionNodeId(root), "root");
assert.equal(normalizeAssemblyInspectionNodeId(root, ""), "root");
assert.equal(normalizeAssemblyInspectionNodeId(root, "missing"), "root");
assert.equal(normalizeAssemblyInspectionNodeId(root, "leaf_a"), "leaf_a");
assert.equal(assemblyInspectionNode(root, "module")?.id, "module");
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, ""), ["module", "compound_part", "sibling"]);
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, "module"), ["leaf_a", "leaf_b"]);
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, "compound_part"), ["leaf_c", "leaf_d"]);
assert.deepEqual(selectableAssemblyNodeIdsForInspection(root, "leaf_a"), []);
assert.equal(selectableAssemblyNodeIdsForInspection(root, "module").includes("sibling"), false);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, ""), ["module", "compound_part", "sibling"]);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, "module"), ["leaf_a", "leaf_b"]);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, "compound_part"), ["leaf_c", "leaf_d"]);
assert.deepEqual(treeSelectableAssemblyNodeIdsForInspection(root, "leaf_a"), []);
assert.equal(treeSelectableAssemblyNodeIdsForInspection(root, "module").includes("sibling"), false);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, ""), []);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, "module"), ["leaf_a", "leaf_b"]);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, "compound_part"), ["leaf_c", "leaf_d"]);
assert.deepEqual(focusedLeafPartIdsForAssemblyInspection(root, "leaf_a"), ["leaf_a"]);
});
test("assembly picking maps rendered leaves to the current selectable node before accepting leaf ids", () => {
const pickPartIdMap = new Map([
["leaf_a", "module"],
["leaf_b", "module"],
["sibling", "sibling"]
]);
const validLeafPartIds = ["leaf_a", "leaf_b", "sibling"];
assert.equal(
resolveAssemblyPickedPartId("leaf_a", { pickPartIdMap, validLeafPartIds }),
"module"
);
assert.equal(
resolveAssemblyPickedPartId("sibling", { pickPartIdMap, validLeafPartIds }),
"sibling"
);
assert.equal(
resolveAssemblyPickedPartId("unknown", { pickPartIdMap, validLeafPartIds }),
"unknown"
);
});
@@ -0,0 +1,20 @@
export async function mapWithConcurrency(items, concurrency, mapper) {
const list = Array.isArray(items) ? items : [];
const workerCount = Math.min(
list.length,
Math.max(1, Math.floor(Number(concurrency) || 1))
);
const results = new Array(list.length);
let nextIndex = 0;
async function runWorker() {
while (nextIndex < list.length) {
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await mapper(list[currentIndex], currentIndex);
}
}
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
return results;
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mapWithConcurrency } from "./concurrency.js";
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
test("mapWithConcurrency preserves result order while capping active work", async () => {
let activeCount = 0;
let peakActiveCount = 0;
const results = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (value) => {
activeCount += 1;
peakActiveCount = Math.max(peakActiveCount, activeCount);
await delay(5);
activeCount -= 1;
return value * 10;
});
assert.deepEqual(results, [10, 20, 30, 40, 50]);
assert.equal(peakActiveCount, 2);
});
test("mapWithConcurrency handles empty input", async () => {
const results = await mapWithConcurrency([], 4, async () => {
throw new Error("mapper should not run for empty input");
});
assert.deepEqual(results, []);
});
@@ -0,0 +1,174 @@
const CAD_TOKEN_RE = /^\s*#([^\s]*)/;
const OCCURRENCE_SELECTOR_RE = /^o((?:\d+)(?:\.\d+)*)$/;
const OCCURRENCE_ENTITY_SELECTOR_RE = /^o((?:\d+)(?:\.\d+)*)\.([sfev])(\d+)$/;
const ENTITY_SELECTOR_RE = /^([sfev])(\d+)$/;
function selectorTypeForKind(kind) {
if (kind === "s") {
return "shape";
}
if (kind === "f") {
return "face";
}
if (kind === "e") {
return "edge";
}
return "vertex";
}
export function normalizeCadPath(rawCadPath) {
let normalized = String(rawCadPath || "").replace(/\\/g, "/").trim().replace(/^\/+|\/+$/g, "");
if (!normalized) {
return "";
}
if (normalized.split("/").some((part) => !part || part === "." || part === "..")) {
return "";
}
return normalized;
}
function parseStructuredSelector(rawSelector, { inheritedOccurrenceId = "" } = {}) {
const selector = String(rawSelector || "").trim().replace(/^#/, "");
if (!selector) {
return null;
}
const occurrenceEntityMatch = selector.match(OCCURRENCE_ENTITY_SELECTOR_RE);
if (occurrenceEntityMatch) {
const occurrenceId = `o${occurrenceEntityMatch[1]}`;
const kind = occurrenceEntityMatch[2];
const ordinal = Number(occurrenceEntityMatch[3]);
return {
selectorType: selectorTypeForKind(kind),
occurrenceId,
ordinal,
kind,
canonical: `${occurrenceId}.${kind}${ordinal}`
};
}
const occurrenceMatch = selector.match(OCCURRENCE_SELECTOR_RE);
if (occurrenceMatch) {
return {
selectorType: "occurrence",
occurrenceId: `o${occurrenceMatch[1]}`,
ordinal: null,
kind: "",
canonical: `o${occurrenceMatch[1]}`
};
}
const entityMatch = selector.match(ENTITY_SELECTOR_RE);
if (entityMatch) {
const kind = entityMatch[1];
const ordinal = Number(entityMatch[2]);
if (inheritedOccurrenceId) {
return {
selectorType: selectorTypeForKind(kind),
occurrenceId: inheritedOccurrenceId,
ordinal,
kind,
canonical: `${inheritedOccurrenceId}.${kind}${ordinal}`
};
}
return {
selectorType: selectorTypeForKind(kind),
occurrenceId: "",
ordinal,
kind,
canonical: `${kind}${ordinal}`
};
}
return {
selectorType: "opaque",
occurrenceId: "",
ordinal: null,
kind: "",
canonical: selector
};
}
export function parseCadRefSelector(rawSelector, options = {}) {
return parseStructuredSelector(rawSelector, options);
}
export function normalizeCadRefSelectors(selectors) {
const rawSelectors = Array.isArray(selectors)
? selectors.flatMap((selector) => String(selector || "").split(","))
: String(selectors || "").split(",");
const normalizedSelectors = [];
let inheritedOccurrenceId = "";
for (const rawSelector of rawSelectors) {
const parsedSelector = parseStructuredSelector(rawSelector, { inheritedOccurrenceId });
if (!parsedSelector) {
continue;
}
normalizedSelectors.push(parsedSelector.canonical);
if (parsedSelector.occurrenceId) {
inheritedOccurrenceId = parsedSelector.occurrenceId;
}
}
return normalizedSelectors;
}
export function sortCadRefSelectors(selectors) {
const normalizedSelectors = normalizeCadRefSelectors(selectors);
const uniqueSelectors = [...new Set(normalizedSelectors.filter(Boolean))];
const rank = {
occurrence: 1,
shape: 2,
face: 3,
edge: 4,
vertex: 5,
opaque: 6
};
return uniqueSelectors
.map((selector, index) => ({ selector, index, parsed: parseStructuredSelector(selector) }))
.sort((left, right) => {
const leftOccurrence = left.parsed?.occurrenceId || "";
const rightOccurrence = right.parsed?.occurrenceId || "";
if (leftOccurrence !== rightOccurrence) {
return leftOccurrence.localeCompare(rightOccurrence);
}
const leftRank = rank[left.parsed?.selectorType] ?? 99;
const rightRank = rank[right.parsed?.selectorType] ?? 99;
if (leftRank !== rightRank) {
return leftRank - rightRank;
}
const leftOrdinal = Number(left.parsed?.ordinal || 0);
const rightOrdinal = Number(right.parsed?.ordinal || 0);
if (leftOrdinal !== rightOrdinal) {
return leftOrdinal - rightOrdinal;
}
if (left.selector !== right.selector) {
return left.selector.localeCompare(right.selector);
}
return left.index - right.index;
})
.map((item) => item.selector);
}
export function parseCadRefToken(copyText) {
const match = String(copyText || "").trim().match(CAD_TOKEN_RE);
if (!match) {
return null;
}
const selectorText = String(match[1] || "").trim();
return {
token: match[0],
cadPath: "",
selectors: normalizeCadRefSelectors(selectorText)
};
}
export function buildCadRefToken({ cadPath = "", selector = "", selectors } = {}) {
void cadPath;
const selectorList = selectors !== undefined ? sortCadRefSelectors(selectors) : sortCadRefSelectors(selector ? [selector] : []);
if (!selectorList.length) {
return "#";
}
return `#${selectorList.join(",")}`;
}
@@ -0,0 +1 @@
export * from "../common/displaySettings.js";
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildDxfPreviewMeshData } from "./buildPreviewMesh.js";
import { parseDxf } from "./parseDxf.js";
function dxfText(lines) {
return `${lines.join("\n")}\n`;
}
function line(start, end) {
return {
kind: "cut",
start,
end
};
}
function rectangle(minX, minY, maxX, maxY) {
return [
line([minX, minY], [maxX, minY]),
line([maxX, minY], [maxX, maxY]),
line([maxX, maxY], [minX, maxY]),
line([minX, maxY], [minX, minY])
];
}
test("DXF preview extrudes multiple disconnected no-bend flat contours", () => {
const dxfData = {
geometry: {
lines: [
...rectangle(0, 0, 10, 10),
...rectangle(5, 20, 15, 30)
],
arcs: [],
circles: []
},
defaultThicknessMm: 2
};
const meshData = buildDxfPreviewMeshData(dxfData, 2);
assert.equal(meshData.triangle_count > 0, true);
assert.equal(meshData.guide_line_segments.length, 0);
assert.deepEqual(meshData.bounds.min, [0, -1, 0]);
assert.deepEqual(meshData.bounds.max, [15, 1, 30]);
});
test("DXF preview extrudes bulged lwpolyline contours", () => {
const quarterBulge = Math.tan(Math.PI / 8);
const dxfData = parseDxf(dxfText([
"0", "SECTION",
"2", "ENTITIES",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "0",
"20", "0",
"10", "10",
"20", "0",
"10", "10",
"20", "10",
"10", "0",
"20", "10",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "7",
"20", "5",
"42", String(quarterBulge),
"10", "5",
"20", "7",
"42", String(quarterBulge),
"10", "3",
"20", "5",
"42", String(quarterBulge),
"10", "5",
"20", "3",
"42", String(quarterBulge),
"0", "ENDSEC",
"0", "EOF"
]));
const meshData = buildDxfPreviewMeshData(dxfData, 2);
assert.equal(meshData.triangle_count > 0, true);
assert.equal(meshData.guide_line_segments.length, 0);
assert.deepEqual(meshData.bounds.min, [0, -1, 0]);
assert.deepEqual(meshData.bounds.max, [10, 1, 10]);
});
@@ -0,0 +1,504 @@
const DXF_RENDER_SCHEMA_VERSION = 1;
const SUPPORTED_ENTITY_TYPES = new Set(["LINE", "ARC", "CIRCLE", "LWPOLYLINE"]);
const ANGLE_EPSILON = 1e-9;
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function semanticKindForLayer(layerName) {
return String(layerName || "").trim().toLowerCase().includes("bend") ? "bend" : "cut";
}
function normalizeLayerName(value) {
const text = String(value || "").trim();
return text || "0";
}
function normalizeAngle(angleDeg) {
const value = angleDeg % 360;
return value < 0 ? value + 360 : value;
}
function angleInSweep(angleDeg, startAngleDeg, sweepAngleDeg) {
const absSweepAngleDeg = Math.abs(sweepAngleDeg);
if (absSweepAngleDeg >= 360 - ANGLE_EPSILON) {
return true;
}
if (sweepAngleDeg >= 0) {
const normalizedDelta = (normalizeAngle(angleDeg) - normalizeAngle(startAngleDeg) + 360) % 360;
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
}
const normalizedDelta = (normalizeAngle(startAngleDeg) - normalizeAngle(angleDeg) + 360) % 360;
return normalizedDelta <= absSweepAngleDeg + ANGLE_EPSILON;
}
function pointOnCircle(center, radius, angleDeg) {
const radians = (angleDeg * Math.PI) / 180;
return [
center[0] + radius * Math.cos(radians),
center[1] + radius * Math.sin(radians)
];
}
function arcExtremaPoints(arc) {
const endAngleDeg = arc.startAngleDeg + arc.sweepAngleDeg;
const points = [
pointOnCircle(arc.center, arc.radius, arc.startAngleDeg),
pointOnCircle(arc.center, arc.radius, endAngleDeg)
];
for (const candidateAngle of [0, 90, 180, 270]) {
if (angleInSweep(candidateAngle, arc.startAngleDeg, arc.sweepAngleDeg)) {
points.push(pointOnCircle(arc.center, arc.radius, candidateAngle));
}
}
return points;
}
function expandBounds(current, nextBounds) {
if (!current) {
return nextBounds;
}
return {
minX: Math.min(current.minX, nextBounds.minX),
minY: Math.min(current.minY, nextBounds.minY),
maxX: Math.max(current.maxX, nextBounds.maxX),
maxY: Math.max(current.maxY, nextBounds.maxY)
};
}
function lineBounds(line) {
return {
minX: Math.min(line.start[0], line.end[0]),
minY: Math.min(line.start[1], line.end[1]),
maxX: Math.max(line.start[0], line.end[0]),
maxY: Math.max(line.start[1], line.end[1])
};
}
function circleBounds(circle) {
return {
minX: circle.center[0] - circle.radius,
minY: circle.center[1] - circle.radius,
maxX: circle.center[0] + circle.radius,
maxY: circle.center[1] + circle.radius
};
}
function arcBounds(arc) {
const points = arcExtremaPoints(arc);
const xs = points.map((point) => point[0]);
const ys = points.map((point) => point[1]);
return {
minX: Math.min(...xs),
minY: Math.min(...ys),
maxX: Math.max(...xs),
maxY: Math.max(...ys)
};
}
function screenPoint(point, { minX, maxY }) {
return [point[0] - minX, maxY - point[1]];
}
function formatNumber(value) {
const rounded = Math.round(toFiniteNumber(value) * 1_000_000) / 1_000_000;
return Math.abs(rounded) < ANGLE_EPSILON ? 0 : rounded;
}
function parseRecordPairs(text) {
const normalized = String(text || "").replace(/\r\n?/g, "\n");
const lines = normalized.split("\n");
if (lines.length && lines[lines.length - 1] === "") {
lines.pop();
}
if (lines.length % 2 !== 0) {
throw new Error("DXF group code stream is malformed");
}
const pairs = [];
for (let index = 0; index < lines.length; index += 2) {
const code = Number.parseInt(lines[index].trim(), 10);
if (!Number.isFinite(code)) {
throw new Error(`Invalid DXF group code: ${JSON.stringify(lines[index])}`);
}
pairs.push({
code,
value: lines[index + 1] ?? ""
});
}
return pairs;
}
function parseHeader(records) {
let sourceUnits = 0;
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
if (record.code !== 9) {
continue;
}
const variableName = String(record.value || "").trim();
const valueRecord = records[index + 1];
if (!valueRecord) {
continue;
}
if (variableName === "$INSUNITS") {
sourceUnits = Math.max(0, Math.trunc(toFiniteNumber(valueRecord.value, 0)));
}
}
return {
sourceUnits,
defaultThicknessMm: 0
};
}
function parseLineEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const startX = toFiniteNumber(records.find((record) => record.code === 10)?.value);
const startY = toFiniteNumber(records.find((record) => record.code === 20)?.value);
const endX = toFiniteNumber(records.find((record) => record.code === 11)?.value);
const endY = toFiniteNumber(records.find((record) => record.code === 21)?.value);
return {
layer,
start: [startX, startY],
end: [endX, endY]
};
}
function parseArcEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
if (radius <= 0) {
throw new Error("Invalid DXF arc radius");
}
const startAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 50)?.value));
const endAngleDeg = normalizeAngle(toFiniteNumber(records.find((record) => record.code === 51)?.value));
let sweepAngleDeg = (endAngleDeg - startAngleDeg + 360) % 360;
if (sweepAngleDeg <= ANGLE_EPSILON) {
sweepAngleDeg = 360;
}
return {
layer,
center: [
toFiniteNumber(records.find((record) => record.code === 10)?.value),
toFiniteNumber(records.find((record) => record.code === 20)?.value)
],
radius,
startAngleDeg,
sweepAngleDeg,
endAngleDeg: startAngleDeg + sweepAngleDeg
};
}
function parseCircleEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const radius = toFiniteNumber(records.find((record) => record.code === 40)?.value, -1);
if (radius <= 0) {
throw new Error("Invalid DXF circle radius");
}
return {
layer,
center: [
toFiniteNumber(records.find((record) => record.code === 10)?.value),
toFiniteNumber(records.find((record) => record.code === 20)?.value)
],
radius
};
}
function arcFromBulgeSegment(layer, start, end, bulge) {
const dx = end[0] - start[0];
const dy = end[1] - start[1];
const chordLength = Math.hypot(dx, dy);
if (chordLength <= ANGLE_EPSILON || Math.abs(bulge) <= ANGLE_EPSILON) {
return null;
}
const includedAngleRad = 4 * Math.atan(bulge);
const radius = (chordLength * (1 + bulge * bulge)) / (4 * Math.abs(bulge));
const midpoint = [
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2
];
const leftNormal = [-dy / chordLength, dx / chordLength];
const centerOffset = (chordLength * (1 - bulge * bulge)) / (4 * bulge);
const center = [
midpoint[0] + leftNormal[0] * centerOffset,
midpoint[1] + leftNormal[1] * centerOffset
];
return {
layer,
center,
radius,
startAngleDeg: normalizeAngle((Math.atan2(start[1] - center[1], start[0] - center[0]) * 180) / Math.PI),
sweepAngleDeg: (includedAngleRad * 180) / Math.PI
};
}
function parseLwpolylineEntity(records) {
const layer = normalizeLayerName(records.find((record) => record.code === 8)?.value);
const flags = Math.trunc(toFiniteNumber(records.find((record) => record.code === 70)?.value, 0));
const vertices = [];
let currentVertex = null;
for (const record of records) {
if (record.code === 10) {
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
vertices.push(currentVertex);
}
currentVertex = { point: [toFiniteNumber(record.value), Number.NaN], bulge: 0 };
continue;
}
if (record.code === 20 && currentVertex) {
currentVertex.point[1] = toFiniteNumber(record.value);
continue;
}
if (record.code === 42 && currentVertex) {
currentVertex.bulge = toFiniteNumber(record.value);
}
}
if (currentVertex && Number.isFinite(currentVertex.point[0]) && Number.isFinite(currentVertex.point[1])) {
vertices.push(currentVertex);
}
if (vertices.length < 2) {
throw new Error("Invalid DXF LWPOLYLINE; expected at least 2 vertices");
}
const lines = [];
const arcs = [];
const addSegment = (startVertex, endVertex) => {
const start = startVertex.point;
const end = endVertex.point;
if (start[0] === end[0] && start[1] === end[1]) {
return;
}
if (Math.abs(startVertex.bulge) > ANGLE_EPSILON) {
const arc = arcFromBulgeSegment(layer, start, end, startVertex.bulge);
if (arc) {
arcs.push(arc);
}
return;
}
lines.push({ layer, start, end });
};
for (let index = 0; index < vertices.length - 1; index += 1) {
addSegment(vertices[index], vertices[index + 1]);
}
if ((flags & 1) !== 0) {
addSegment(vertices[vertices.length - 1], vertices[0]);
}
return { lines, arcs };
}
function parseEntities(records) {
const lines = [];
const arcs = [];
const circles = [];
let index = 0;
while (index < records.length) {
const startRecord = records[index];
if (startRecord.code !== 0) {
index += 1;
continue;
}
const entityType = String(startRecord.value || "").trim().toUpperCase();
if (entityType === "ENDSEC") {
break;
}
const entityRecords = [];
index += 1;
while (index < records.length && records[index].code !== 0) {
entityRecords.push(records[index]);
index += 1;
}
if (!SUPPORTED_ENTITY_TYPES.has(entityType)) {
throw new Error(`Unsupported DXF entity ${entityType}`);
}
if (entityType === "LINE") {
lines.push(parseLineEntity(entityRecords));
continue;
}
if (entityType === "ARC") {
arcs.push(parseArcEntity(entityRecords));
continue;
}
if (entityType === "CIRCLE") {
circles.push(parseCircleEntity(entityRecords));
continue;
}
const polyline = parseLwpolylineEntity(entityRecords);
lines.push(...polyline.lines);
arcs.push(...polyline.arcs);
}
if (!lines.length && !arcs.length && !circles.length) {
throw new Error("No supported DXF entities found");
}
return { lines, arcs, circles };
}
function splitSections(records) {
const sections = new Map();
let index = 0;
while (index < records.length) {
const record = records[index];
if (record.code !== 0 || String(record.value || "").trim().toUpperCase() !== "SECTION") {
index += 1;
continue;
}
const nameRecord = records[index + 1];
const sectionName = String(nameRecord?.value || "").trim().toUpperCase();
index += 2;
const sectionRecords = [];
while (index < records.length) {
const nextRecord = records[index];
if (nextRecord.code === 0 && String(nextRecord.value || "").trim().toUpperCase() === "ENDSEC") {
index += 1;
break;
}
sectionRecords.push(nextRecord);
index += 1;
}
sections.set(sectionName, sectionRecords);
}
return sections;
}
function buildPathRecord(layerName, kind, pathData) {
return {
layer: layerName,
kind,
d: pathData
};
}
function touchLayer(layerSummary, layerName) {
const existing = layerSummary.get(layerName);
if (existing) {
return existing;
}
const next = {
name: layerName,
kind: semanticKindForLayer(layerName),
pathCount: 0,
circleCount: 0
};
layerSummary.set(layerName, next);
return next;
}
export function parseDxf(dxfText, { fileRef = "", sourceUrl = "" } = {}) {
const records = parseRecordPairs(dxfText);
const sections = splitSections(records);
const header = parseHeader(sections.get("HEADER") || []);
const entities = parseEntities(sections.get("ENTITIES") || []);
let rawBounds = null;
for (const line of entities.lines) {
rawBounds = expandBounds(rawBounds, lineBounds(line));
}
for (const arc of entities.arcs) {
rawBounds = expandBounds(rawBounds, arcBounds(arc));
}
for (const circle of entities.circles) {
rawBounds = expandBounds(rawBounds, circleBounds(circle));
}
if (!rawBounds) {
throw new Error("Failed to compute DXF bounds");
}
const width = Math.max(rawBounds.maxX - rawBounds.minX, 0);
const height = Math.max(rawBounds.maxY - rawBounds.minY, 0);
const pathRecords = [];
const circleRecords = [];
const layerSummary = new Map();
for (const line of entities.lines) {
const start = screenPoint(line.start, { minX: rawBounds.minX, maxY: rawBounds.maxY });
const end = screenPoint(line.end, { minX: rawBounds.minX, maxY: rawBounds.maxY });
pathRecords.push(
buildPathRecord(
line.layer,
semanticKindForLayer(line.layer),
`M ${formatNumber(start[0])} ${formatNumber(start[1])} L ${formatNumber(end[0])} ${formatNumber(end[1])}`
)
);
touchLayer(layerSummary, line.layer).pathCount += 1;
}
for (const arc of entities.arcs) {
const start = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg), {
minX: rawBounds.minX,
maxY: rawBounds.maxY
});
const end = screenPoint(pointOnCircle(arc.center, arc.radius, arc.startAngleDeg + arc.sweepAngleDeg), {
minX: rawBounds.minX,
maxY: rawBounds.maxY
});
const largeArcFlag = Math.abs(arc.sweepAngleDeg) > 180 + ANGLE_EPSILON ? 1 : 0;
const sweepFlag = arc.sweepAngleDeg >= 0 ? 0 : 1;
pathRecords.push(
buildPathRecord(
arc.layer,
semanticKindForLayer(arc.layer),
`M ${formatNumber(start[0])} ${formatNumber(start[1])} A ${formatNumber(arc.radius)} ${formatNumber(arc.radius)} 0 ${largeArcFlag} ${sweepFlag} ${formatNumber(end[0])} ${formatNumber(end[1])}`
)
);
touchLayer(layerSummary, arc.layer).pathCount += 1;
}
for (const circle of entities.circles) {
const center = screenPoint(circle.center, { minX: rawBounds.minX, maxY: rawBounds.maxY });
circleRecords.push({
layer: circle.layer,
kind: semanticKindForLayer(circle.layer),
cx: formatNumber(center[0]),
cy: formatNumber(center[1]),
r: formatNumber(circle.radius)
});
touchLayer(layerSummary, circle.layer).circleCount += 1;
}
return {
schemaVersion: DXF_RENDER_SCHEMA_VERSION,
fileRef,
sourceUrl,
sourceUnits: header.sourceUnits,
defaultThicknessMm: formatNumber(header.defaultThicknessMm),
bounds: {
minX: 0,
minY: 0,
maxX: formatNumber(width),
maxY: formatNumber(height),
width: formatNumber(width),
height: formatNumber(height)
},
counts: {
paths: pathRecords.length,
circles: circleRecords.length,
entities: pathRecords.length + circleRecords.length
},
layers: [...layerSummary.keys()].sort().map((name) => layerSummary.get(name)),
geometry: {
lines: entities.lines.map((line) => ({
layer: line.layer,
kind: semanticKindForLayer(line.layer),
start: [formatNumber(line.start[0]), formatNumber(line.start[1])],
end: [formatNumber(line.end[0]), formatNumber(line.end[1])]
})),
arcs: entities.arcs.map((arc) => ({
layer: arc.layer,
kind: semanticKindForLayer(arc.layer),
center: [formatNumber(arc.center[0]), formatNumber(arc.center[1])],
radius: formatNumber(arc.radius),
startAngleDeg: formatNumber(arc.startAngleDeg),
sweepAngleDeg: formatNumber(arc.sweepAngleDeg)
})),
circles: entities.circles.map((circle) => ({
layer: circle.layer,
kind: semanticKindForLayer(circle.layer),
center: [formatNumber(circle.center[0]), formatNumber(circle.center[1])],
radius: formatNumber(circle.radius)
}))
},
paths: pathRecords,
circles: circleRecords
};
}
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseDxf } from "./parseDxf.js";
function dxfText(lines) {
return `${lines.join("\n")}\n`;
}
test("parseDxf normalizes closed lwpolyline, circles, and bends", () => {
const payload = parseDxf(dxfText([
"0", "SECTION",
"2", "HEADER",
"0", "ENDSEC",
"0", "SECTION",
"2", "ENTITIES",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "0",
"20", "0",
"10", "10",
"20", "0",
"10", "10",
"20", "5",
"10", "0",
"20", "5",
"0", "CIRCLE",
"8", "CUT",
"10", "5",
"20", "2.5",
"40", "1",
"0", "LINE",
"8", "BEND",
"10", "3",
"20", "0",
"11", "3",
"21", "5",
"0", "ENDSEC",
"0", "EOF"
]), { fileRef: "test/panel.dxf" });
assert.equal(payload.fileRef, "test/panel.dxf");
assert.equal(payload.defaultThicknessMm, 0);
assert.equal(payload.bounds.width, 10);
assert.equal(payload.bounds.height, 5);
assert.equal(payload.counts.paths, 5);
assert.equal(payload.counts.circles, 1);
assert.equal(payload.geometry.lines.length, 5);
assert.equal(payload.layers.length, 2);
assert.deepEqual(payload.layers.map((layer) => layer.name), ["BEND", "CUT"]);
});
test("parseDxf converts lwpolyline bulges into arcs", () => {
const quarterBulge = Math.tan(Math.PI / 8);
const payload = parseDxf(dxfText([
"0", "SECTION",
"2", "ENTITIES",
"0", "LWPOLYLINE",
"8", "CUT",
"90", "4",
"70", "1",
"10", "1",
"20", "0",
"42", String(quarterBulge),
"10", "0",
"20", "1",
"42", String(quarterBulge),
"10", "-1",
"20", "0",
"42", String(quarterBulge),
"10", "0",
"20", "-1",
"42", String(quarterBulge),
"0", "ENDSEC",
"0", "EOF"
]), { fileRef: "test/bulged-hole.dxf" });
assert.equal(payload.counts.paths, 4);
assert.equal(payload.counts.circles, 0);
assert.equal(payload.geometry.lines.length, 0);
assert.equal(payload.geometry.arcs.length, 4);
assert.equal(payload.bounds.width, 2);
assert.equal(payload.bounds.height, 2);
assert.deepEqual(payload.geometry.arcs.map((arc) => arc.radius), [1, 1, 1, 1]);
assert.deepEqual(payload.geometry.arcs.map((arc) => arc.sweepAngleDeg), [90, 90, 90, 90]);
assert.match(payload.paths[0].d, /A 1 1 0 0 0 /);
});
@@ -0,0 +1,173 @@
import {
entrySourceFormat,
meshAssetKeyForEntry,
RENDER_FORMAT
} from "./fileFormats.js";
export { meshAssetKeyForEntry } from "./fileFormats.js";
function normalizeString(value) {
return String(value || "").trim();
}
function normalizedKind(entry) {
return normalizeString(entry?.kind).toLowerCase();
}
function primaryAsset(entry) {
if (!entry || typeof entry !== "object") {
return null;
}
const url = normalizeString(entry.url);
if (!url) {
return null;
}
return {
url,
hash: normalizeString(entry.hash),
bytes: Number.isFinite(Number(entry.bytes)) && Number(entry.bytes) >= 0 ? Number(entry.bytes) : 0,
};
}
function relationAsset(entry, key) {
const relation = entry?.relations?.[key];
if (!relation || typeof relation !== "object") {
return null;
}
const url = normalizeString(relation.url);
return url
? {
url,
hash: normalizeString(relation.hash),
bytes: Number.isFinite(Number(relation.bytes)) && Number(relation.bytes) >= 0 ? Number(relation.bytes) : 0,
}
: null;
}
export function entryAsset(entry, key) {
const assetKey = normalizeString(key).toLowerCase();
const kind = normalizedKind(entry);
const sourceFormat = entrySourceFormat(entry);
if (assetKey === "urdf" && kind === "srdf") {
return relationAsset(entry, "urdf");
}
if (
assetKey === kind ||
assetKey === sourceFormat ||
(sourceFormat === RENDER_FORMAT.STEP && ["glb", "topology", "selectortopology", "displayedgetopology"].includes(assetKey)) ||
(kind === "srdf" && assetKey === "srdf")
) {
return primaryAsset(entry);
}
return null;
}
export function entryAssetUrl(entry, key) {
return normalizeString(entryAsset(entry, key)?.url);
}
export function entryAssetHash(entry, key) {
return normalizeString(entryAsset(entry, key)?.hash);
}
export function entryAssetBytes(entry, key) {
const bytes = Number(entryAsset(entry, key)?.bytes);
return Number.isFinite(bytes) && bytes >= 0 ? bytes : 0;
}
export function entryMeshAssetUrl(entry) {
return entryAssetUrl(entry, meshAssetKeyForEntry(entry));
}
export function entryMeshAssetHash(entry) {
return entryAssetHash(entry, meshAssetKeyForEntry(entry));
}
export function entryMeshAssetBytes(entry) {
return entryAssetBytes(entry, meshAssetKeyForEntry(entry));
}
export function entryTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "topology") || entryAssetUrl(entry, "glb");
}
export function entrySelectorTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "selectorTopology") || entryTopologyAssetUrl(entry);
}
export function entryDisplayEdgeTopologyAssetUrl(entry) {
return entryAssetUrl(entry, "displayEdgeTopology") || entryTopologyAssetUrl(entry);
}
export function entryUrdfAssetHash(entry) {
return [
entryAssetHash(entry, "urdf"),
entryAssetHash(entry, "srdf"),
entryAssetHash(entry, "sdf")
].filter(Boolean).join(":");
}
export function entryMeshAssetSignature(entry) {
return String(
entry?.kind === "assembly"
? entryAssetHash(entry, "glb")
: entryMeshAssetHash(entry)
);
}
export function entryReferenceAssetSignature(entry) {
return entryAssetHash(entry, "selectorTopology") || normalizeString(entry?.hash);
}
export function entryHasMesh(entry) {
if (entrySourceFormat(entry) === RENDER_FORMAT.STEP) {
return Boolean(
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "glb")
);
}
const meshKey = meshAssetKeyForEntry(entry);
return Boolean(entryAssetUrl(entry, meshKey) && entryAssetHash(entry, meshKey));
}
export function entryHasUrdf(entry) {
const kind = normalizeString(entry?.kind).toLowerCase();
if (kind === RENDER_FORMAT.SDF) {
return Boolean(entryAssetUrl(entry, "sdf") && entryAssetHash(entry, "sdf"));
}
return Boolean(entryAssetUrl(entry, "urdf") && entryAssetHash(entry, "urdf"));
}
export function entryHasReferences(entry) {
return Boolean(
entrySourceFormat(entry) === RENDER_FORMAT.STEP &&
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "selectorTopology")
);
}
export function entryHasDisplayEdges(entry) {
return Boolean(
entrySourceFormat(entry) === RENDER_FORMAT.STEP &&
entryAssetUrl(entry, "glb") &&
entryAssetHash(entry, "displayEdgeTopology")
);
}
export function entryHasDxf(entry) {
return Boolean(entryAssetUrl(entry, "dxf") && entryAssetHash(entry, "dxf"));
}
export function entryHasGcode(entry) {
return Boolean(entryAssetUrl(entry, "gcode") && entryAssetHash(entry, "gcode"));
}
export function entryHasImplicitCad(entry) {
return Boolean(entryAssetUrl(entry, "implicit") && entryAssetHash(entry, "implicit"));
}
export function entryStepModuleUrl(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.STEP
? normalizeString(entry?.moduleUrl)
: "";
}
@@ -0,0 +1,104 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
entryAssetHash,
entryAssetBytes,
entryAssetUrl,
entryHasDxf,
entryHasGcode,
entryHasDisplayEdges,
entryHasMesh,
entryHasReferences,
entryHasUrdf,
entryMeshAssetBytes,
entryMeshAssetHash,
entryMeshAssetSignature,
entryReferenceAssetSignature,
entryDisplayEdgeTopologyAssetUrl,
entrySelectorTopologyAssetUrl,
entryTopologyAssetUrl,
entryStepModuleUrl,
entryUrdfAssetHash
} from "./entryAssets.js";
function stepEntry(overrides = {}) {
return {
file: "parts/bracket.step",
kind: "part",
url: "/assets/bracket.glb",
hash: "glb-hash",
bytes: 42,
...overrides
};
}
test("entry asset helpers normalize urls and hashes", () => {
const entry = stepEntry({
url: " /mesh.glb ",
hash: " hash-a "
});
assert.equal(entryAssetUrl(entry, "glb"), "/mesh.glb");
assert.equal(entryAssetHash(entry, "glb"), "hash-a");
assert.equal(entryAssetBytes(stepEntry(), "glb"), 42);
assert.equal(entryMeshAssetBytes(stepEntry()), 42);
assert.equal(entryAssetUrl(entry, "missing"), "");
assert.equal(entryAssetHash(null, "glb"), "");
assert.equal(entryAssetBytes(null, "glb"), 0);
});
test("entry mesh signatures distinguish assemblies from simple mesh sidecars", () => {
assert.equal(entryMeshAssetHash(stepEntry()), "glb-hash");
assert.equal(entryMeshAssetSignature(stepEntry()), "glb-hash");
assert.equal(entryMeshAssetSignature(stepEntry({ kind: "assembly" })), "glb-hash");
assert.equal(entryMeshAssetHash({
kind: "stl",
url: "/mesh.stl",
hash: "stl-hash"
}), "stl-hash");
});
test("entry topology urls resolve to the primary STEP GLB", () => {
assert.equal(entrySelectorTopologyAssetUrl(stepEntry()), "/assets/bracket.glb");
assert.equal(entryDisplayEdgeTopologyAssetUrl(stepEntry()), "/assets/bracket.glb");
assert.equal(entryTopologyAssetUrl(stepEntry()), "/assets/bracket.glb");
});
test("STEP module urls are explicit catalog data instead of guessed sidecars", () => {
assert.equal(entryStepModuleUrl(stepEntry()), "");
assert.equal(entryStepModuleUrl(stepEntry({ moduleUrl: " /assets/.bracket.step.js " })), "/assets/.bracket.step.js");
assert.equal(entryStepModuleUrl({ kind: "stl", moduleUrl: "/assets/not-step.js" }), "");
});
test("entry availability helpers preserve existing viewer gates", () => {
assert.equal(entryHasMesh(stepEntry()), true);
assert.equal(entryHasReferences(stepEntry()), true);
assert.equal(entryHasDisplayEdges(stepEntry()), true);
assert.equal(entryHasMesh(stepEntry({ artifact: { ok: false } })), true);
assert.equal(entryHasReferences(stepEntry({ artifact: { ok: false } })), true);
assert.equal(entryHasDisplayEdges(stepEntry({ artifact: { ok: false } })), true);
assert.equal(entryHasDisplayEdges(stepEntry({ hash: "" })), false);
assert.equal(entryHasReferences(stepEntry({ hash: "" })), false);
assert.equal(entryHasDxf({ kind: "dxf", url: "/plate.dxf", hash: "dxf-hash" }), true);
assert.equal(entryHasGcode({ kind: "gcode", url: "/part.gcode", hash: "gcode-hash" }), true);
assert.equal(entryHasUrdf({ kind: "urdf", url: "/robot.urdf", hash: "urdf-hash" }), true);
assert.equal(entryHasUrdf({ kind: "sdf", url: "/robot.sdf", hash: "sdf-hash" }), true);
});
test("robot and reference signatures match persisted session expectations", () => {
assert.equal(entryReferenceAssetSignature(stepEntry()), "glb-hash");
assert.equal(entryUrdfAssetHash({
kind: "srdf",
url: "/robot.srdf",
hash: "srdf-hash",
relations: {
urdf: { url: "/robot.urdf", hash: "urdf-hash" }
}
}), "urdf-hash:srdf-hash");
assert.equal(entryUrdfAssetHash({
kind: "sdf",
url: "/robot.sdf",
hash: "sdf-hash"
}), "sdf-hash");
});
@@ -0,0 +1,207 @@
export const RENDER_FORMAT = Object.freeze({
STEP: "step",
STL: "stl",
THREE_MF: "3mf",
GLB: "glb",
GCODE: "gcode",
DXF: "dxf",
IMPLICIT: "implicit",
URDF: "urdf",
SRDF: "srdf",
SDF: "sdf"
});
export const MESH_RENDER_FORMATS = Object.freeze([
RENDER_FORMAT.STL,
RENDER_FORMAT.THREE_MF,
RENDER_FORMAT.GLB
]);
export const ROBOT_RENDER_FORMATS = Object.freeze([
RENDER_FORMAT.URDF,
RENDER_FORMAT.SRDF,
RENDER_FORMAT.SDF
]);
export function normalizeFormat(value) {
return String(value || "").trim().toLowerCase();
}
export function normalizeRenderFormat(value, { defaultFormat = RENDER_FORMAT.STEP } = {}) {
const normalized = normalizeFormat(value || defaultFormat);
if (normalized === "stp") {
return RENDER_FORMAT.STEP;
}
if (normalized === "gltf") {
return RENDER_FORMAT.GLB;
}
if (
normalized === RENDER_FORMAT.STEP ||
normalized === RENDER_FORMAT.STL ||
normalized === RENDER_FORMAT.THREE_MF ||
normalized === RENDER_FORMAT.GLB ||
normalized === RENDER_FORMAT.GCODE ||
normalized === RENDER_FORMAT.DXF ||
normalized === RENDER_FORMAT.IMPLICIT ||
normalized === RENDER_FORMAT.URDF ||
normalized === RENDER_FORMAT.SRDF ||
normalized === RENDER_FORMAT.SDF
) {
return normalized;
}
return defaultFormat;
}
export function entryKind(entry) {
return normalizeFormat(entry?.kind);
}
export function entrySourceFormat(entry) {
const kind = entryKind(entry);
if (kind === RENDER_FORMAT.DXF) {
return RENDER_FORMAT.DXF;
}
if (kind === RENDER_FORMAT.STL) {
return RENDER_FORMAT.STL;
}
if (kind === RENDER_FORMAT.THREE_MF) {
return RENDER_FORMAT.THREE_MF;
}
if (kind === RENDER_FORMAT.GLB || kind === "gltf") {
return RENDER_FORMAT.GLB;
}
if (kind === RENDER_FORMAT.GCODE) {
return RENDER_FORMAT.GCODE;
}
if (kind === RENDER_FORMAT.IMPLICIT) {
return RENDER_FORMAT.IMPLICIT;
}
if (kind === RENDER_FORMAT.URDF) {
return RENDER_FORMAT.URDF;
}
if (kind === RENDER_FORMAT.SRDF) {
return RENDER_FORMAT.SRDF;
}
if (kind === RENDER_FORMAT.SDF) {
return RENDER_FORMAT.SDF;
}
return RENDER_FORMAT.STEP;
}
export function isMeshRenderFormat(format) {
return MESH_RENDER_FORMATS.includes(normalizeFormat(format));
}
export function isRobotRenderFormat(format) {
return ROBOT_RENDER_FORMATS.includes(normalizeFormat(format));
}
export function meshAssetKeyForFormat(format) {
const normalized = normalizeFormat(format);
return isMeshRenderFormat(normalized) ? normalized : RENDER_FORMAT.GLB;
}
export function meshAssetKeyForEntry(entry) {
return meshAssetKeyForFormat(entrySourceFormat(entry));
}
export function fileSheetKindForEntry(entry) {
if (!entry) {
return "";
}
const kind = entryKind(entry);
if (kind === RENDER_FORMAT.DXF) {
return RENDER_FORMAT.DXF;
}
if (kind === RENDER_FORMAT.URDF) {
return RENDER_FORMAT.URDF;
}
if (kind === RENDER_FORMAT.SRDF) {
return RENDER_FORMAT.SRDF;
}
if (kind === RENDER_FORMAT.SDF) {
return RENDER_FORMAT.SDF;
}
if (kind === RENDER_FORMAT.GCODE) {
return RENDER_FORMAT.GCODE;
}
if (kind === RENDER_FORMAT.IMPLICIT) {
return RENDER_FORMAT.IMPLICIT;
}
if (entrySourceFormat(entry) === RENDER_FORMAT.STEP) {
return RENDER_FORMAT.STEP;
}
if (isMeshRenderFormat(entrySourceFormat(entry))) {
return "mesh";
}
return "";
}
export function fileExtensionFromPath(value, { baseUrl = "" } = {}) {
const rawValue = String(value || "").trim();
if (!rawValue) {
return "";
}
let pathname = rawValue;
try {
pathname = new URL(rawValue, baseUrl || "http://localhost/").pathname;
} catch {
pathname = rawValue.split("?")[0].split("#")[0];
}
const normalizedPath = pathname.toLowerCase();
const slashIndex = normalizedPath.lastIndexOf("/");
const dotIndex = normalizedPath.lastIndexOf(".");
return dotIndex > slashIndex ? normalizedPath.slice(dotIndex) : "";
}
export function renderFormatFromExtension(extension) {
const normalized = normalizeFormat(extension).replace(/^\./, "");
if (normalized === "step" || normalized === "stp") {
return RENDER_FORMAT.STEP;
}
if (normalized === "stl") {
return RENDER_FORMAT.STL;
}
if (normalized === "3mf") {
return RENDER_FORMAT.THREE_MF;
}
if (normalized === "glb" || normalized === "gltf") {
return RENDER_FORMAT.GLB;
}
if (normalized === "gcode") {
return RENDER_FORMAT.GCODE;
}
if (normalized === "implicit" || normalized === "implicit.js" || normalized === "implicit.mjs") {
return RENDER_FORMAT.IMPLICIT;
}
if (normalized === "dxf") {
return RENDER_FORMAT.DXF;
}
if (normalized === "urdf") {
return RENDER_FORMAT.URDF;
}
if (normalized === "srdf") {
return RENDER_FORMAT.SRDF;
}
if (normalized === "sdf") {
return RENDER_FORMAT.SDF;
}
return "";
}
export function renderFormatFromPath(value, options = {}) {
const rawValue = String(value || "").trim();
let pathname = rawValue;
try {
pathname = new URL(rawValue, options.baseUrl || "http://localhost/").pathname;
} catch {
pathname = rawValue.split("?")[0].split("#")[0];
}
const normalizedPath = pathname.toLowerCase();
if (normalizedPath.endsWith(".implicit.js") || normalizedPath.endsWith(".implicit.mjs")) {
return RENDER_FORMAT.IMPLICIT;
}
return renderFormatFromExtension(fileExtensionFromPath(value, options));
}
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
entrySourceFormat,
fileExtensionFromPath,
fileSheetKindForEntry,
isMeshRenderFormat,
isRobotRenderFormat,
meshAssetKeyForEntry,
normalizeRenderFormat,
renderFormatFromPath,
RENDER_FORMAT
} from "./fileFormats.js";
test("entrySourceFormat maps manifest kinds to stable render formats", () => {
assert.equal(entrySourceFormat({ kind: "part" }), RENDER_FORMAT.STEP);
assert.equal(entrySourceFormat({ kind: "assembly" }), RENDER_FORMAT.STEP);
assert.equal(entrySourceFormat({ kind: "dxf" }), RENDER_FORMAT.DXF);
assert.equal(entrySourceFormat({ kind: "stl" }), RENDER_FORMAT.STL);
assert.equal(entrySourceFormat({ kind: "3mf" }), RENDER_FORMAT.THREE_MF);
assert.equal(entrySourceFormat({ kind: "glb" }), RENDER_FORMAT.GLB);
assert.equal(entrySourceFormat({ kind: "gltf" }), RENDER_FORMAT.GLB);
assert.equal(entrySourceFormat({ kind: "gcode" }), RENDER_FORMAT.GCODE);
assert.equal(entrySourceFormat({ kind: "implicit" }), RENDER_FORMAT.IMPLICIT);
assert.equal(entrySourceFormat({ kind: "urdf" }), RENDER_FORMAT.URDF);
assert.equal(entrySourceFormat({ kind: "srdf" }), RENDER_FORMAT.SRDF);
assert.equal(entrySourceFormat({ kind: "sdf" }), RENDER_FORMAT.SDF);
});
test("fileSheetKindForEntry preserves specialized sheet routing", () => {
assert.equal(fileSheetKindForEntry({ kind: "part" }), "step");
assert.equal(fileSheetKindForEntry({ kind: "assembly" }), "step");
assert.equal(fileSheetKindForEntry({ kind: "stl" }), "mesh");
assert.equal(fileSheetKindForEntry({ kind: "3mf" }), "mesh");
assert.equal(fileSheetKindForEntry({ kind: "glb" }), "mesh");
assert.equal(fileSheetKindForEntry({ kind: "gcode" }), "gcode");
assert.equal(fileSheetKindForEntry({ kind: "urdf" }), "urdf");
assert.equal(fileSheetKindForEntry({ kind: "srdf" }), "srdf");
assert.equal(fileSheetKindForEntry({ kind: "sdf" }), "sdf");
assert.equal(fileSheetKindForEntry({ kind: "dxf" }), "dxf");
assert.equal(fileSheetKindForEntry({ kind: "implicit" }), "implicit");
});
test("mesh and robot format predicates stay narrow", () => {
assert.equal(isMeshRenderFormat(RENDER_FORMAT.STL), true);
assert.equal(isMeshRenderFormat(RENDER_FORMAT.THREE_MF), true);
assert.equal(isMeshRenderFormat(RENDER_FORMAT.GLB), true);
assert.equal(isMeshRenderFormat(RENDER_FORMAT.STEP), false);
assert.equal(isRobotRenderFormat(RENDER_FORMAT.URDF), true);
assert.equal(isRobotRenderFormat(RENDER_FORMAT.SRDF), true);
assert.equal(isRobotRenderFormat(RENDER_FORMAT.SDF), true);
});
test("normalizeRenderFormat preserves tab-state format aliases and defaults", () => {
assert.equal(normalizeRenderFormat("stp"), RENDER_FORMAT.STEP);
assert.equal(normalizeRenderFormat("gltf"), RENDER_FORMAT.GLB);
assert.equal(normalizeRenderFormat("srdf"), RENDER_FORMAT.SRDF);
assert.equal(normalizeRenderFormat("3mf"), RENDER_FORMAT.THREE_MF);
assert.equal(normalizeRenderFormat("gcode"), RENDER_FORMAT.GCODE);
assert.equal(normalizeRenderFormat("implicit"), RENDER_FORMAT.IMPLICIT);
assert.equal(normalizeRenderFormat("unknown"), RENDER_FORMAT.STEP);
assert.equal(normalizeRenderFormat("unknown", { defaultFormat: RENDER_FORMAT.DXF }), RENDER_FORMAT.DXF);
});
test("meshAssetKeyForEntry chooses native mesh keys and STEP GLB sidecars", () => {
assert.equal(meshAssetKeyForEntry({ kind: "stl" }), "stl");
assert.equal(meshAssetKeyForEntry({ kind: "3mf" }), "3mf");
assert.equal(meshAssetKeyForEntry({ kind: "glb" }), "glb");
assert.equal(meshAssetKeyForEntry({ kind: "part" }), "glb");
assert.equal(meshAssetKeyForEntry({ kind: "assembly" }), "glb");
});
test("file extension parsing handles URLs, queries, and supported render formats", () => {
assert.equal(fileExtensionFromPath("/assets/bracket.step.glb?v=1"), ".glb");
assert.equal(fileExtensionFromPath("fixtures/plate.dxf#top"), ".dxf");
assert.equal(fileExtensionFromPath("https://example.test/robot.srdf?download=1"), ".srdf");
assert.equal(renderFormatFromPath("/assets/bracket.stp"), RENDER_FORMAT.STEP);
assert.equal(renderFormatFromPath("/assets/bracket.gltf"), RENDER_FORMAT.GLB);
assert.equal(renderFormatFromPath("/assets/toolpath.gcode?download=1"), RENDER_FORMAT.GCODE);
assert.equal(renderFormatFromPath("/assets/orb.implicit.js?download=1"), RENDER_FORMAT.IMPLICIT);
assert.equal(renderFormatFromPath("/assets/orb.implicit.mjs#preview"), RENDER_FORMAT.IMPLICIT);
assert.equal(renderFormatFromPath("/assets/unknown"), "");
});
@@ -0,0 +1,505 @@
const DEFAULT_EXTRUSION_WIDTH_MM = 0.42;
const DEFAULT_TRAVEL_WIDTH_MM = 0.08;
const DEFAULT_EXTRUSION_HEIGHT_MM = 0.18;
const DEFAULT_TRAVEL_HEIGHT_MM = 0.04;
export const DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET = 60000;
export const GCODE_PREVIEW_DETAIL_MIN = 1;
export const GCODE_PREVIEW_DETAIL_MAX = 7;
export const DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL = 3;
const GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS = Object.freeze({
1: 25000,
2: 40000,
3: DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET,
4: 90000,
5: 140000,
6: 220000,
7: 320000
});
const MIN_GCODE_PREVIEW_SEGMENT_BUDGET = 1;
const MIN_SEGMENT_LENGTH_MM = 1e-5;
const GCODE_PREVIEW_Z_LIFT_MM = 0.035;
const DEFAULT_LAYER_COLORS = Object.freeze([
"#2563eb",
"#16a34a",
"#0891b2",
"#ca8a04",
"#7c3aed",
"#0f766e",
"#4f46e5",
"#65a30d"
]);
const SUPPORT_COLOR = "#94a3b8";
const TRAVEL_COLOR = "#64748b";
const FEATURE_CATEGORY_Z_BIAS_MM = Object.freeze({
support: 0.001,
adhesion: 0.002,
infill: 0.003,
calibration: 0.004,
other: 0.004,
surface: 0.005,
wall: 0.006
});
const TRAVEL_Z_BIAS_MM = 0.008;
const RIBBON_VERTICES_PER_SEGMENT = 4;
const RIBBON_INDICES_PER_SEGMENT = 6;
const RIBBON_NORMAL = Object.freeze([0, 0, 1]);
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 normalizeBoolean(value, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeDetailMode(value) {
return String(value || "").trim().toLowerCase() === "full" ? "full" : "adaptive";
}
function normalizePreviewDetailLevel(value) {
return clamp(
Math.round(toFiniteNumber(value, DEFAULT_GCODE_PREVIEW_DETAIL_LEVEL)),
GCODE_PREVIEW_DETAIL_MIN,
GCODE_PREVIEW_DETAIL_MAX
);
}
function segmentBudgetForPreviewDetailLevel(value) {
const detailLevel = normalizePreviewDetailLevel(value);
return GCODE_PREVIEW_DETAIL_SEGMENT_BUDGETS[detailLevel] || DEFAULT_GCODE_PREVIEW_SEGMENT_BUDGET;
}
function coordinate(value, index) {
return toFiniteNumber(value?.[index]);
}
function emptyBounds() {
return {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity]
};
}
function finalizeBounds(bounds) {
if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [1, 1, 1]
};
}
return {
min: bounds.min,
max: bounds.max
};
}
function segmentLength(segment) {
const start = segment?.start;
const end = segment?.end;
return Math.hypot(
coordinate(end, 0) - coordinate(start, 0),
coordinate(end, 1) - coordinate(start, 1),
coordinate(end, 2) - coordinate(start, 2)
);
}
function normalizeLayerRange(value, layerCount) {
const maxLayer = Math.max(0, Math.trunc(toFiniteNumber(layerCount, 0)) - 1);
if (!Array.isArray(value) || value.length < 2) {
return [0, maxLayer];
}
const start = clamp(Math.trunc(toFiniteNumber(value[0], 0)), 0, maxLayer);
const end = clamp(Math.trunc(toFiniteNumber(value[1], maxLayer)), 0, maxLayer);
return start <= end ? [start, end] : [end, start];
}
export function normalizeGcodePreviewOptions(options = {}, layerCount = 0) {
const source = options && typeof options === "object" ? options : {};
const detailMode = normalizeDetailMode(source.detailMode);
const detailLevel = normalizePreviewDetailLevel(source.detailLevel);
const defaultSegmentBudget = segmentBudgetForPreviewDetailLevel(detailLevel);
const explicitRange = Array.isArray(source.layerRange)
? source.layerRange
: Number.isFinite(Number(source.maxLayer))
? [0, Number(source.maxLayer)]
: null;
return {
showTravel: normalizeBoolean(source.showTravel, false),
layerRange: normalizeLayerRange(explicitRange, layerCount),
detailMode,
detailLevel,
maxVisibleSegments: detailMode === "full"
? 0
: Math.max(
Math.trunc(toFiniteNumber(source.maxVisibleSegments, defaultSegmentBudget)),
MIN_GCODE_PREVIEW_SEGMENT_BUDGET
),
extrusionWidthMm: Math.max(toFiniteNumber(source.extrusionWidthMm, DEFAULT_EXTRUSION_WIDTH_MM), 0.01),
travelWidthMm: Math.max(toFiniteNumber(source.travelWidthMm, DEFAULT_TRAVEL_WIDTH_MM), 0.01),
extrusionHeightMm: Math.max(toFiniteNumber(source.extrusionHeightMm, DEFAULT_EXTRUSION_HEIGHT_MM), 0.01),
travelHeightMm: Math.max(toFiniteNumber(source.travelHeightMm, DEFAULT_TRAVEL_HEIGHT_MM), 0.01)
};
}
function layerColor(layerIndex) {
const index = Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0)));
return DEFAULT_LAYER_COLORS[index % DEFAULT_LAYER_COLORS.length];
}
function segmentGroupKey(segment) {
if (segment.kind === "travel") {
return "travel";
}
return `layer:${Math.max(0, Math.trunc(toFiniteNumber(segment.layerIndex, 0)))}`;
}
function groupLabel(key, layerIndex) {
if (key === "travel") {
return "Travel";
}
return `Layer ${Math.max(0, Math.trunc(toFiniteNumber(layerIndex, 0))) + 1}`;
}
function colorToRgb(color) {
const normalized = String(color || "").trim();
const expanded = /^#[0-9a-fA-F]{3}$/.test(normalized)
? `#${normalized[1]}${normalized[1]}${normalized[2]}${normalized[2]}${normalized[3]}${normalized[3]}`
: normalized;
if (!/^#[0-9a-fA-F]{6}$/.test(expanded)) {
return [1, 1, 1];
}
return [
parseInt(expanded.slice(1, 3), 16) / 255,
parseInt(expanded.slice(3, 5), 16) / 255,
parseInt(expanded.slice(5, 7), 16) / 255
];
}
function visibleSegmentMatches(segment, options) {
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
const [minLayer, maxLayer] = options.layerRange;
if (layerIndex < minLayer || layerIndex > maxLayer) {
return false;
}
if (segment?.kind === "travel") {
return options.showTravel;
}
return segment?.kind === "extrusion";
}
function analyzeVisibleSegments(gcodeData, options) {
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
const segmentsByLayer = new Map();
let visibleSegmentCount = 0;
for (const segment of segments) {
if (!visibleSegmentMatches(segment, options) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
continue;
}
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
segmentsByLayer.set(layerIndex, (segmentsByLayer.get(layerIndex) || 0) + 1);
visibleSegmentCount += 1;
}
return {
visibleSegmentCount,
segmentsByLayer
};
}
function includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride) {
if (layerStride <= 1) {
return true;
}
return (
layerIndex === minLayer ||
layerIndex === maxLayer ||
(layerIndex - minLayer) % layerStride === 0
);
}
function countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) {
let count = 0;
for (const [layerIndex, segmentCount] of segmentsByLayer.entries()) {
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
count += segmentCount;
}
}
return count;
}
function layerStrideForBudget(segmentsByLayer, visibleSegmentCount, options) {
const budget = Math.trunc(toFiniteNumber(options?.maxVisibleSegments, 0));
if (options?.detailMode === "full" || budget <= 0 || visibleSegmentCount <= budget) {
return 1;
}
const [minLayer, maxLayer] = options.layerRange;
const visibleLayerCount = Math.max(1, maxLayer - minLayer + 1);
for (let layerStride = 2; layerStride <= visibleLayerCount; layerStride += 1) {
if (countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride) <= budget) {
return layerStride;
}
}
return visibleLayerCount;
}
function countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride) {
let count = 0;
for (const layerIndex of segmentsByLayer.keys()) {
if (layerIndex >= minLayer && layerIndex <= maxLayer && includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
count += 1;
}
}
return count;
}
function updateBoundsFromCoordinates(bounds, x, y, z) {
bounds.min[0] = Math.min(bounds.min[0], x);
bounds.min[1] = Math.min(bounds.min[1], y);
bounds.min[2] = Math.min(bounds.min[2], z);
bounds.max[0] = Math.max(bounds.max[0], x);
bounds.max[1] = Math.max(bounds.max[1], y);
bounds.max[2] = Math.max(bounds.max[2], z);
}
function updateSegmentBounds(bounds, segment) {
updateBoundsFromCoordinates(
bounds,
coordinate(segment?.start, 0),
coordinate(segment?.start, 1),
coordinate(segment?.start, 2)
);
updateBoundsFromCoordinates(
bounds,
coordinate(segment?.end, 0),
coordinate(segment?.end, 1),
coordinate(segment?.end, 2)
);
}
function segmentColor(segment, key, layerIndex) {
if (key === "travel") {
return TRAVEL_COLOR;
}
if (segment?.featureCategory === "support") {
return SUPPORT_COLOR;
}
return layerColor(layerIndex);
}
function segmentZOffset(segment) {
if (segment?.kind === "travel") {
return GCODE_PREVIEW_Z_LIFT_MM + TRAVEL_Z_BIAS_MM;
}
const category = String(segment?.featureCategory || "other").trim().toLowerCase();
return GCODE_PREVIEW_Z_LIFT_MM + (FEATURE_CATEGORY_Z_BIAS_MM[category] ?? FEATURE_CATEGORY_Z_BIAS_MM.other);
}
function writeRibbonVertex({ vertices, colors, normals, bounds, offset, x, y, z, zOffset = 0, color }) {
vertices[offset] = x;
vertices[offset + 1] = y;
vertices[offset + 2] = z + zOffset;
colors[offset] = color[0];
colors[offset + 1] = color[1];
colors[offset + 2] = color[2];
normals[offset] = RIBBON_NORMAL[0];
normals[offset + 1] = RIBBON_NORMAL[1];
normals[offset + 2] = RIBBON_NORMAL[2];
updateBoundsFromCoordinates(bounds, x, y, z + zOffset);
}
function writeSegmentRibbon({ vertices, indices, colors, normals, bounds, segment, segmentIndex, width, color, zOffset }) {
const start = segment?.start;
const end = segment?.end;
const startX = coordinate(start, 0);
const startY = coordinate(start, 1);
const startZ = coordinate(start, 2);
const endX = coordinate(end, 0);
const endY = coordinate(end, 1);
const endZ = coordinate(end, 2);
const dx = endX - startX;
const dy = endY - startY;
const dz = endZ - startZ;
const length = Math.hypot(dx, dy, dz);
if (!(length > MIN_SEGMENT_LENGTH_MM)) {
return null;
}
let nx = -dy;
let ny = dx;
let normalLength = Math.hypot(nx, ny);
if (!(normalLength > MIN_SEGMENT_LENGTH_MM)) {
nx = 1;
ny = 0;
normalLength = 1;
}
const halfWidth = width / 2;
const offsetX = (nx / normalLength) * halfWidth;
const offsetY = (ny / normalLength) * halfWidth;
const vertexBase = segmentIndex * RIBBON_VERTICES_PER_SEGMENT;
const vertexOffset = vertexBase * 3;
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset,
x: startX + offsetX,
y: startY + offsetY,
z: startZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 3,
x: startX - offsetX,
y: startY - offsetY,
z: startZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 6,
x: endX - offsetX,
y: endY - offsetY,
z: endZ,
zOffset,
color
});
writeRibbonVertex({
vertices,
colors,
normals,
bounds,
offset: vertexOffset + 9,
x: endX + offsetX,
y: endY + offsetY,
z: endZ,
zOffset,
color
});
const indexOffset = segmentIndex * RIBBON_INDICES_PER_SEGMENT;
indices[indexOffset] = vertexBase;
indices[indexOffset + 1] = vertexBase + 1;
indices[indexOffset + 2] = vertexBase + 2;
indices[indexOffset + 3] = vertexBase;
indices[indexOffset + 4] = vertexBase + 2;
indices[indexOffset + 5] = vertexBase + 3;
return true;
}
export function buildGcodePreviewMeshData(gcodeData, options = {}) {
const layers = Array.isArray(gcodeData?.layers) ? gcodeData.layers : [];
const normalized = normalizeGcodePreviewOptions(options, layers.length);
const segments = Array.isArray(gcodeData?.segments) ? gcodeData.segments : [];
const { visibleSegmentCount, segmentsByLayer } = analyzeVisibleSegments(gcodeData, normalized);
const layerStride = layerStrideForBudget(segmentsByLayer, visibleSegmentCount, normalized);
const [minLayer, maxLayer] = normalized.layerRange;
const renderedSegmentCapacity = countSegmentsForLayerStride(segmentsByLayer, minLayer, maxLayer, layerStride);
const renderedLayerCount = countRenderedLayers(segmentsByLayer, minLayer, maxLayer, layerStride);
const vertices = new Float32Array(renderedSegmentCapacity * RIBBON_VERTICES_PER_SEGMENT * 3);
const indices = new Uint32Array(renderedSegmentCapacity * RIBBON_INDICES_PER_SEGMENT);
const colors = new Float32Array(vertices.length);
const normals = new Float32Array(vertices.length);
const parts = [];
const geometryBounds = emptyBounds();
const rawBounds = emptyBounds();
let currentGroup = null;
let writtenSegments = 0;
for (const segment of segments) {
if (!visibleSegmentMatches(segment, normalized) || segmentLength(segment) <= MIN_SEGMENT_LENGTH_MM) {
continue;
}
const key = segmentGroupKey(segment);
const layerIndex = Math.max(0, Math.trunc(toFiniteNumber(segment?.layerIndex, 0)));
if (!includeLayerForStride(layerIndex, minLayer, maxLayer, layerStride)) {
continue;
}
updateSegmentBounds(rawBounds, segment);
if (!currentGroup || currentGroup.key !== key) {
currentGroup = {
key,
layerIndex,
vertexOffset: writtenSegments * RIBBON_VERTICES_PER_SEGMENT,
triangleOffset: writtenSegments * 2,
segmentCount: 0
};
parts.push(currentGroup);
}
const added = writeSegmentRibbon({
vertices,
indices,
colors,
normals,
bounds: geometryBounds,
segment,
segmentIndex: writtenSegments,
width: segment.kind === "travel" ? normalized.travelWidthMm : normalized.extrusionWidthMm,
color: colorToRgb(segmentColor(segment, key, layerIndex)),
zOffset: segmentZOffset(segment)
});
if (added) {
currentGroup.segmentCount += 1;
writtenSegments += 1;
}
}
const finalizedParts = parts
.map((part, index) => {
const nextVertexOffset = parts[index + 1]?.vertexOffset ?? writtenSegments * RIBBON_VERTICES_PER_SEGMENT;
const nextTriangleOffset = parts[index + 1]?.triangleOffset ?? writtenSegments * 2;
return {
id: part.key === "travel" ? `gcode:travel:${index}` : `gcode:layer:${part.layerIndex}:${index}`,
label: groupLabel(part.key, part.layerIndex),
vertexOffset: part.vertexOffset,
vertexCount: Math.max(0, nextVertexOffset - part.vertexOffset),
triangleOffset: part.triangleOffset,
triangleCount: Math.max(0, nextTriangleOffset - part.triangleOffset),
bounds: finalizeBounds(rawBounds),
segmentCount: part.segmentCount
};
})
.filter((part) => part.vertexCount > 0 && part.triangleCount > 0);
const finalBounds = finalizeBounds(rawBounds);
return {
vertices,
indices,
normals,
colors,
bounds: finalBounds,
parts: finalizedParts,
has_source_colors: true,
sourceFormat: "gcode",
sourceColor: "#2563eb",
metadata: {
kind: "gcode",
previewGeometry: "ribbon",
options: normalized,
visibleSegments: visibleSegmentCount,
renderedSegments: writtenSegments,
visibleLayers: Math.max(0, maxLayer - minLayer + 1),
renderedLayers: renderedLayerCount,
layerStride,
segmentStride: 1,
decimationMode: layerStride > 1 ? "layers" : "none",
decimated: layerStride > 1,
stats: gcodeData?.stats || {},
features: Array.isArray(gcodeData?.features) ? gcodeData.features : [],
warnings: Array.isArray(gcodeData?.warnings) ? gcodeData.warnings : []
}
};
}
@@ -0,0 +1,787 @@
const GCODE_RENDER_SCHEMA_VERSION = 1;
const POSITION_EPSILON = 1e-7;
const EXTRUSION_EPSILON = 1e-7;
const ARC_CHORD_MM = 0.8;
const MIN_ARC_SEGMENTS = 6;
const MAX_ARC_SEGMENTS = 160;
const UNCLASSIFIED_FEATURE = Object.freeze({
id: "unclassified",
label: "Unclassified",
category: "other",
rawLabel: ""
});
const FEATURE_CATEGORY_ORDER = Object.freeze({
support: 0,
wall: 1,
surface: 2,
infill: 3,
adhesion: 4,
calibration: 5,
other: 6
});
const FEATURE_DEFINITIONS = Object.freeze([
{
id: "support-interface",
label: "Support interface",
category: "support",
patterns: [
/\bsupport\b.*\b(interface|roof|floor)\b/,
/\binterface\b.*\bsupport\b/
]
},
{
id: "support",
label: "Support",
category: "support",
patterns: [/\bsupport(?:s| material)?\b/]
},
{
id: "overhang-wall",
label: "Overhang wall",
category: "wall",
patterns: [/\boverhang\b.*\b(wall|perimeter)\b/]
},
{
id: "outer-wall",
label: "Outer wall",
category: "wall",
patterns: [
/\bouter\b.*\b(wall|perimeter|shell)\b/,
/\bexternal\b.*\b(perimeter|wall|shell)\b/,
/\bwall outer\b/
]
},
{
id: "inner-wall",
label: "Inner wall",
category: "wall",
patterns: [
/\binner\b.*\b(wall|perimeter|shell)\b/,
/\binternal\b.*\bperimeter\b/,
/\bwall inner\b/
]
},
{
id: "wall",
label: "Wall",
category: "wall",
patterns: [/\b(wall|perimeter|shell)\b/]
},
{
id: "bridge",
label: "Bridge",
category: "surface",
patterns: [/\bbridge\b/]
},
{
id: "top-surface",
label: "Top surface",
category: "surface",
patterns: [/\btop\b.*\b(surface|skin|shell|solid infill|infill)\b/]
},
{
id: "bottom-surface",
label: "Bottom surface",
category: "surface",
patterns: [/\bbottom\b.*\b(surface|skin|shell|solid infill|infill)\b/]
},
{
id: "surface",
label: "Surface",
category: "surface",
patterns: [/\b(skin|surface)\b/]
},
{
id: "gap-fill",
label: "Gap fill",
category: "infill",
patterns: [/\bgap\b.*\bfill\b/]
},
{
id: "internal-solid-infill",
label: "Internal solid infill",
category: "infill",
patterns: [
/\binternal\b.*\bsolid\b.*\binfill\b/,
/\bsolid\b.*\binfill\b/
]
},
{
id: "sparse-infill",
label: "Sparse infill",
category: "infill",
patterns: [
/\bsparse\b.*\binfill\b/,
/\binternal\b.*\binfill\b/,
/\bfill\b/
]
},
{
id: "infill",
label: "Infill",
category: "infill",
patterns: [/\binfill\b/]
},
{
id: "skirt",
label: "Skirt",
category: "adhesion",
patterns: [/\bskirt\b/]
},
{
id: "brim",
label: "Brim",
category: "adhesion",
patterns: [/\bbrim\b/]
},
{
id: "raft",
label: "Raft",
category: "adhesion",
patterns: [/\braft\b/]
},
{
id: "prime-tower",
label: "Prime tower",
category: "calibration",
patterns: [/\b(prime|wipe)\b.*\btower\b/]
},
{
id: "ironing",
label: "Ironing",
category: "surface",
patterns: [/\bironing\b/]
},
{
id: "flush",
label: "Flush",
category: "calibration",
patterns: [/\bflush\b/]
},
{
id: "wipe",
label: "Wipe",
category: "calibration",
patterns: [/\bwipe\b/]
},
{
id: "custom",
label: "Custom",
category: "other",
patterns: [/\bcustom\b/]
}
]);
const SUPPORTED_COMMANDS = new Set([
"G0",
"G1",
"G2",
"G3",
"G20",
"G21",
"G28",
"G90",
"G91",
"G92",
"M82",
"M83",
"M84",
"M104",
"M106",
"M107",
"M109",
"M140",
"M190"
]);
function toFiniteNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function clonePoint(point) {
return [
toFiniteNumber(point?.[0]),
toFiniteNumber(point?.[1]),
toFiniteNumber(point?.[2])
];
}
function stripComment(line) {
return String(line || "").split(";", 1)[0].trim();
}
function parseCommand(line) {
const stripped = stripComment(line).toUpperCase();
if (!stripped) {
return "";
}
const first = stripped.split(/\s+/, 1)[0];
if (/^[GMT]\d+(?:\.\d+)?$/.test(first)) {
return first.split(".", 1)[0];
}
return first;
}
function parseTokens(line) {
const stripped = stripComment(line).toUpperCase();
const tokens = {};
for (const match of stripped.matchAll(/([A-Z])([-+]?(?:\d+(?:\.\d*)?|\.\d+))/g)) {
tokens[match[1]] = Number(match[2]);
}
return tokens;
}
function commentText(line) {
const source = String(line || "");
const commentIndex = source.indexOf(";");
return commentIndex >= 0 ? source.slice(commentIndex + 1).trim() : "";
}
function normalizeFeatureText(value) {
return String(value || "")
.replace(/^["']+|["']+$/g, "")
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function featureSlug(value) {
return normalizeFeatureText(value)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function titleCaseFeatureLabel(value) {
const normalized = normalizeFeatureText(value);
if (!normalized) {
return "Other";
}
return normalized
.toLowerCase()
.split(" ")
.map((word) => word ? `${word[0].toUpperCase()}${word.slice(1)}` : "")
.join(" ");
}
function featureFromLabel(rawLabel) {
const normalized = normalizeFeatureText(rawLabel);
const matchText = normalized.toLowerCase();
for (const definition of FEATURE_DEFINITIONS) {
if (definition.patterns.some((pattern) => pattern.test(matchText))) {
return {
id: definition.id,
label: definition.label,
category: definition.category,
rawLabel: normalized
};
}
}
const slug = featureSlug(normalized);
return {
id: slug ? `other:${slug}` : UNCLASSIFIED_FEATURE.id,
label: slug ? titleCaseFeatureLabel(normalized) : UNCLASSIFIED_FEATURE.label,
category: "other",
rawLabel: normalized
};
}
function parseFeatureMarker(line) {
const comment = commentText(line);
if (!comment) {
return null;
}
const match = comment.match(/^(?:FEATURE|TYPE|PRINT_FEATURE|TOOLPATH(?:_TYPE)?|PATH_TYPE)\s*[:=]\s*(.+)$/i);
if (!match?.[1]) {
return null;
}
return featureFromLabel(match[1]);
}
function distance3(a, b) {
const dx = toFiniteNumber(b?.[0]) - toFiniteNumber(a?.[0]);
const dy = toFiniteNumber(b?.[1]) - toFiniteNumber(a?.[1]);
const dz = toFiniteNumber(b?.[2]) - toFiniteNumber(a?.[2]);
return Math.hypot(dx, dy, dz);
}
function roundedZ(value) {
return Math.round(toFiniteNumber(value) * 1000000) / 1000000;
}
function updateBounds(bounds, point) {
const next = clonePoint(point);
for (let axis = 0; axis < 3; axis += 1) {
bounds.min[axis] = Math.min(bounds.min[axis], next[axis]);
bounds.max[axis] = Math.max(bounds.max[axis], next[axis]);
}
}
function layerKey(z) {
return roundedZ(z).toFixed(6);
}
function layerIndexForZ(layers, z) {
if (!layers.length) {
return 0;
}
const target = roundedZ(z);
let bestIndex = 0;
let bestDistance = Infinity;
for (let index = 0; index < layers.length; index += 1) {
const distance = Math.abs(toFiniteNumber(layers[index]?.z) - target);
if (distance < bestDistance) {
bestIndex = index;
bestDistance = distance;
}
}
return bestIndex;
}
function endpointForMove(position, tokens, absolutePositioning) {
return [
Object.hasOwn(tokens, "X")
? (absolutePositioning ? tokens.X : position.x + tokens.X)
: position.x,
Object.hasOwn(tokens, "Y")
? (absolutePositioning ? tokens.Y : position.y + tokens.Y)
: position.y,
Object.hasOwn(tokens, "Z")
? (absolutePositioning ? tokens.Z : position.z + tokens.Z)
: position.z
];
}
function nextExtruder(position, tokens, absoluteExtrusion) {
if (!Object.hasOwn(tokens, "E")) {
return position.e;
}
return absoluteExtrusion ? tokens.E : position.e + tokens.E;
}
function addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta = 0,
feedRate = null,
arc = false,
feature = null
}) {
const lengthMm = distance3(start, end);
if (lengthMm <= POSITION_EPSILON) {
if (extrusionDelta < -EXTRUSION_EPSILON) {
stats.retractMoves += 1;
} else if (extrusionDelta > EXTRUSION_EPSILON) {
stats.primeMoves += 1;
}
return;
}
const kind = extrusionDelta > EXTRUSION_EPSILON ? "extrusion" : "travel";
if (kind === "extrusion") {
stats.extrusionMoves += 1;
stats.extrusionMm += extrusionDelta;
} else {
stats.travelMoves += 1;
}
stats.movementCommands += 1;
stats.pathMm += lengthMm;
updateBounds(bounds, start);
updateBounds(bounds, end);
segments.push({
lineNumber,
command,
kind,
start: clonePoint(start),
end: clonePoint(end),
z: roundedZ(end[2]),
extrusionDelta,
feedRate: Number.isFinite(feedRate) ? feedRate : null,
lengthMm,
arc: Boolean(arc),
featureId: feature?.id || UNCLASSIFIED_FEATURE.id,
featureLabel: feature?.label || UNCLASSIFIED_FEATURE.label,
featureCategory: feature?.category || UNCLASSIFIED_FEATURE.category,
featureRaw: feature?.rawLabel || ""
});
}
function arcSegments({ start, end, tokens, clockwise }) {
if (!Object.hasOwn(tokens, "I") || !Object.hasOwn(tokens, "J")) {
return null;
}
const centerX = start[0] + tokens.I;
const centerY = start[1] + tokens.J;
const radius = Math.hypot(start[0] - centerX, start[1] - centerY);
if (!(radius > POSITION_EPSILON)) {
return null;
}
const startAngle = Math.atan2(start[1] - centerY, start[0] - centerX);
const endAngle = Math.atan2(end[1] - centerY, end[0] - centerX);
let sweep = endAngle - startAngle;
if (clockwise && sweep >= 0) {
sweep -= Math.PI * 2;
} else if (!clockwise && sweep <= 0) {
sweep += Math.PI * 2;
}
const chordSegments = Math.ceil(Math.abs(sweep * radius) / ARC_CHORD_MM);
const segmentCount = Math.max(MIN_ARC_SEGMENTS, Math.min(MAX_ARC_SEGMENTS, chordSegments));
const points = [clonePoint(start)];
for (let index = 1; index <= segmentCount; index += 1) {
const t = index / segmentCount;
const angle = startAngle + sweep * t;
points.push([
centerX + radius * Math.cos(angle),
centerY + radius * Math.sin(angle),
start[2] + (end[2] - start[2]) * t
]);
}
return points;
}
function compactBounds(bounds) {
if (!bounds.min.every(Number.isFinite) || !bounds.max.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [1, 1, 1]
};
}
return {
min: bounds.min.map((value) => roundedZ(value)),
max: bounds.max.map((value) => roundedZ(value))
};
}
function finalizeLayers(segments) {
const zValues = new Map();
for (const segment of segments) {
if (segment.kind !== "extrusion") {
continue;
}
const key = layerKey(segment.z);
if (!zValues.has(key)) {
zValues.set(key, roundedZ(segment.z));
}
}
if (!zValues.size) {
for (const segment of segments) {
const key = layerKey(segment.z);
if (!zValues.has(key)) {
zValues.set(key, roundedZ(segment.z));
}
}
}
const layers = [...zValues.values()]
.sort((left, right) => left - right)
.map((z, index) => ({
index,
z,
segmentCount: 0,
extrusionMoves: 0,
travelMoves: 0
}));
for (const segment of segments) {
const index = layerIndexForZ(layers, segment.z);
segment.layerIndex = index;
if (layers[index]) {
layers[index].segmentCount += 1;
if (segment.kind === "extrusion") {
layers[index].extrusionMoves += 1;
} else {
layers[index].travelMoves += 1;
}
}
}
return layers;
}
function ensureFeatureRecord(records, feature) {
const resolvedFeature = feature?.id ? feature : UNCLASSIFIED_FEATURE;
if (!records.has(resolvedFeature.id)) {
records.set(resolvedFeature.id, {
id: resolvedFeature.id,
label: resolvedFeature.label || UNCLASSIFIED_FEATURE.label,
category: resolvedFeature.category || UNCLASSIFIED_FEATURE.category,
rawLabels: new Set(),
markerCount: 0,
firstLine: null,
lastLine: null,
firstLayerIndex: null,
lastLayerIndex: null,
segmentCount: 0,
extrusionMoves: 0,
travelMoves: 0,
extrusionMm: 0,
pathMm: 0
});
}
const record = records.get(resolvedFeature.id);
if (resolvedFeature.rawLabel) {
record.rawLabels.add(resolvedFeature.rawLabel);
}
return record;
}
function layerRangeText(record) {
if (!Number.isInteger(record.firstLayerIndex) || !Number.isInteger(record.lastLayerIndex)) {
return "";
}
return record.firstLayerIndex === record.lastLayerIndex
? `Layer ${record.firstLayerIndex + 1}`
: `Layers ${record.firstLayerIndex + 1}-${record.lastLayerIndex + 1}`;
}
function finalizeFeatures(segments, markers) {
const records = new Map();
for (const marker of markers) {
const record = ensureFeatureRecord(records, marker.feature);
record.markerCount += 1;
record.firstLine = record.firstLine == null ? marker.lineNumber : Math.min(record.firstLine, marker.lineNumber);
record.lastLine = record.lastLine == null ? marker.lineNumber : Math.max(record.lastLine, marker.lineNumber);
}
for (const segment of segments) {
const record = ensureFeatureRecord(records, {
id: segment.featureId,
label: segment.featureLabel,
category: segment.featureCategory,
rawLabel: segment.featureRaw
});
record.segmentCount += 1;
record.pathMm += toFiniteNumber(segment.lengthMm);
if (Number.isInteger(segment.layerIndex)) {
record.firstLayerIndex = record.firstLayerIndex == null
? segment.layerIndex
: Math.min(record.firstLayerIndex, segment.layerIndex);
record.lastLayerIndex = record.lastLayerIndex == null
? segment.layerIndex
: Math.max(record.lastLayerIndex, segment.layerIndex);
}
if (segment.kind === "extrusion") {
record.extrusionMoves += 1;
record.extrusionMm += toFiniteNumber(segment.extrusionDelta);
} else if (segment.kind === "travel") {
record.travelMoves += 1;
}
}
return [...records.values()]
.map((record) => ({
...record,
rawLabels: [...record.rawLabels].sort((left, right) => left.localeCompare(right)),
layerRangeText: layerRangeText(record),
pathMm: roundedZ(record.pathMm),
extrusionMm: roundedZ(record.extrusionMm)
}))
.sort((left, right) => {
const categoryDelta = (FEATURE_CATEGORY_ORDER[left.category] ?? 99) - (FEATURE_CATEGORY_ORDER[right.category] ?? 99);
if (categoryDelta !== 0) {
return categoryDelta;
}
const extrusionDelta = right.extrusionMoves - left.extrusionMoves;
if (extrusionDelta !== 0) {
return extrusionDelta;
}
return left.label.localeCompare(right.label);
});
}
function warningList(warnings, unsupportedCommands) {
const result = [...warnings];
const unsupported = [...unsupportedCommands.entries()]
.sort((left, right) => left[0].localeCompare(right[0]))
.slice(0, 12)
.map(([command, line]) => `${command} line ${line}`);
if (unsupported.length) {
result.push(`Unsupported or ignored commands were left out of the preview: ${unsupported.join(", ")}.`);
}
return result;
}
export function parseGcode(text, { sourceUrl = "", fileRef = "" } = {}) {
const lines = String(text || "").split(/\r?\n/);
const segments = [];
const unsupportedCommands = new Map();
const warnings = [];
const bounds = {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity]
};
const stats = {
lines: lines.length,
nonCommentLines: 0,
movementCommands: 0,
extrusionMoves: 0,
travelMoves: 0,
retractMoves: 0,
primeMoves: 0,
temperatureCommands: 0,
extrusionMm: 0,
pathMm: 0
};
const position = {
x: 0,
y: 0,
z: 0,
e: 0
};
let absolutePositioning = true;
let absoluteExtrusion = true;
let units = "mm";
let relativeWarningAdded = false;
let inchWarningAdded = false;
let currentFeature = null;
const featureMarkers = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const lineNumber = lineIndex + 1;
const rawLine = lines[lineIndex];
const featureMarker = parseFeatureMarker(rawLine);
if (featureMarker) {
currentFeature = featureMarker;
featureMarkers.push({
lineNumber,
feature: featureMarker
});
}
const command = parseCommand(rawLine);
if (!command) {
continue;
}
stats.nonCommentLines += 1;
const tokens = parseTokens(rawLine);
if (!SUPPORTED_COMMANDS.has(command) && !/^T\d+$/.test(command)) {
unsupportedCommands.set(command, unsupportedCommands.get(command) || lineNumber);
}
if (command === "G20") {
units = "inch";
if (!inchWarningAdded) {
warnings.push("G20 inch units found; preview geometry assumes millimeters.");
inchWarningAdded = true;
}
continue;
}
if (command === "G21") {
units = "mm";
continue;
}
if (command === "G90") {
absolutePositioning = true;
continue;
}
if (command === "G91") {
absolutePositioning = false;
if (!relativeWarningAdded) {
warnings.push("Relative XYZ positioning found; preview follows relative moves after G91.");
relativeWarningAdded = true;
}
continue;
}
if (command === "M82") {
absoluteExtrusion = true;
continue;
}
if (command === "M83") {
absoluteExtrusion = false;
continue;
}
if (command === "G92") {
if (Object.hasOwn(tokens, "X")) {
position.x = tokens.X;
}
if (Object.hasOwn(tokens, "Y")) {
position.y = tokens.Y;
}
if (Object.hasOwn(tokens, "Z")) {
position.z = tokens.Z;
}
if (Object.hasOwn(tokens, "E")) {
position.e = tokens.E;
}
continue;
}
if (["M104", "M109", "M140", "M190"].includes(command)) {
stats.temperatureCommands += 1;
continue;
}
if (!["G0", "G1", "G2", "G3"].includes(command)) {
continue;
}
const start = [position.x, position.y, position.z];
const end = endpointForMove(position, tokens, absolutePositioning);
const nextE = nextExtruder(position, tokens, absoluteExtrusion);
const extrusionDelta = nextE - position.e;
const feedRate = Object.hasOwn(tokens, "F") ? tokens.F : null;
if (command === "G2" || command === "G3") {
const points = arcSegments({
start,
end,
tokens,
clockwise: command === "G2"
});
if (points) {
for (let index = 0; index < points.length - 1; index += 1) {
addSegment(segments, bounds, stats, {
lineNumber,
command,
start: points[index],
end: points[index + 1],
extrusionDelta: extrusionDelta / Math.max(points.length - 1, 1),
feedRate,
arc: true,
feature: currentFeature
});
}
} else {
warnings.push(`Line ${lineNumber}: ${command} arc is missing I/J center offsets; previewed as a straight move.`);
addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta,
feedRate,
arc: false,
feature: currentFeature
});
}
} else {
addSegment(segments, bounds, stats, {
lineNumber,
command,
start,
end,
extrusionDelta,
feedRate,
feature: currentFeature
});
}
position.x = end[0];
position.y = end[1];
position.z = end[2];
position.e = nextE;
}
const layers = finalizeLayers(segments);
const features = finalizeFeatures(segments, featureMarkers);
return {
schemaVersion: GCODE_RENDER_SCHEMA_VERSION,
sourceUrl,
fileRef,
units,
bounds: compactBounds(bounds),
layers,
features,
segments,
stats,
warnings: warningList(warnings, unsupportedCommands)
};
}
@@ -0,0 +1,201 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildGcodePreviewMeshData,
GCODE_PREVIEW_DETAIL_MAX
} from "./buildPreviewMesh.js";
import { parseGcode } from "./parseGcode.js";
test("parseGcode extracts extrusion, travel, layers, and basic print stats", () => {
const data = parseGcode(`
; perimeter preview
G21
G90
M82
G92 E0
G0 X0 Y0 Z0.2 F6000
G1 X10 Y0 E0.4 F1200
G1 X10 Y10 E0.8
G0 X0 Y10 F6000
G92 E0
G1 X0 Y0 E0.4
G1 Z0.4 F600
G1 X5 Y0 E0.8
M104 S210
`, { sourceUrl: "/models/test.gcode", fileRef: "test.gcode" });
assert.equal(data.schemaVersion, 1);
assert.equal(data.sourceUrl, "/models/test.gcode");
assert.equal(data.fileRef, "test.gcode");
assert.equal(data.units, "mm");
assert.deepEqual(data.bounds, {
min: [0, 0, 0],
max: [10, 10, 0.4]
});
assert.equal(data.layers.length, 2);
assert.deepEqual(data.layers.map((layer) => layer.z), [0.2, 0.4]);
assert.equal(data.stats.movementCommands, 7);
assert.equal(data.stats.extrusionMoves, 4);
assert.equal(data.stats.travelMoves, 3);
assert.equal(data.stats.temperatureCommands, 1);
assert.equal(Math.round(data.stats.extrusionMm * 1000) / 1000, 1.6);
assert.equal(data.segments.filter((segment) => segment.kind === "extrusion").length, 4);
assert.equal(data.segments[1].layerIndex, 0);
assert.equal(data.segments.at(-1).layerIndex, 1);
});
test("parseGcode follows relative moves, samples IJ arcs, and reports ignored commands", () => {
const data = parseGcode(`
G91
M83
G1 X2 E0.05
G2 X2 Y0 E0.05 I1 J0
G4 P200
`);
assert.ok(data.stats.movementCommands > 2);
assert.equal(data.stats.extrusionMoves, data.stats.movementCommands);
assert.ok(data.segments.some((segment) => segment.arc));
assert.ok(data.warnings.some((warning) => warning.includes("Relative XYZ positioning")));
assert.ok(data.warnings.some((warning) => /G4 line \d+/.test(warning)));
});
test("parseGcode summarizes slicer feature annotations including supports", () => {
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
;TYPE:WALL-OUTER
G1 X10 Y0 E0.4
; FEATURE: Support interface
G1 X10 Y10 E0.8
;TYPE:SUPPORT
G1 X0 Y10 E1.2
; FEATURE: Sparse infill
G1 X0 Y0 E1.6
`);
const featuresById = new Map(data.features.map((feature) => [feature.id, feature]));
assert.equal(featuresById.get("outer-wall")?.label, "Outer wall");
assert.equal(featuresById.get("support-interface")?.category, "support");
assert.equal(featuresById.get("support-interface")?.extrusionMoves, 1);
assert.deepEqual(featuresById.get("support-interface")?.rawLabels, ["Support interface"]);
assert.equal(featuresById.get("support")?.extrusionMoves, 1);
assert.equal(featuresById.get("sparse-infill")?.category, "infill");
assert.deepEqual(
data.segments.filter((segment) => segment.kind === "extrusion").map((segment) => segment.featureId),
["outer-wall", "support-interface", "support", "sparse-infill"]
);
});
test("buildGcodePreviewMeshData turns visible G-code paths into layer-colored mesh data", () => {
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
;TYPE:WALL-OUTER
G1 X4 Y0 E0.2
G0 X4 Y4
G1 X0 Y4 E0.4
G1 Z0.4
G1 X0 Y0 E0.6
`);
const extrusionOnly = buildGcodePreviewMeshData(data, {
showTravel: false,
maxLayer: 0
});
assert.equal(extrusionOnly.sourceFormat, "gcode");
assert.deepEqual(extrusionOnly.metadata.options.layerRange, [0, 0]);
assert.equal(extrusionOnly.metadata.options.showTravel, false);
assert.equal(extrusionOnly.metadata.previewGeometry, "ribbon");
assert.equal(extrusionOnly.metadata.features.some((feature) => feature.id === "outer-wall"), true);
assert.equal(extrusionOnly.has_source_colors, true);
assert.ok(extrusionOnly.vertices.length > 0);
assert.ok(extrusionOnly.indices.length > 0);
assert.equal(extrusionOnly.colors.length, extrusionOnly.vertices.length);
assert.equal(extrusionOnly.normals.length, extrusionOnly.vertices.length);
assert.equal(extrusionOnly.bounds.min[2], 0.2);
assert.ok(extrusionOnly.vertices[2] > extrusionOnly.bounds.min[2]);
assert.deepEqual(extrusionOnly.parts.map((part) => part.label), ["Layer 1"]);
const withTravel = buildGcodePreviewMeshData(data, {
showTravel: true,
maxLayer: 1
});
assert.ok(withTravel.parts.some((part) => part.label === "Travel"));
assert.ok(withTravel.parts.some((part) => part.label === "Layer 2"));
assert.equal(withTravel.metadata.stats.extrusionMoves, 3);
});
test("buildGcodePreviewMeshData renders support paths with a neutral color and z bias", () => {
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
;TYPE:WALL-OUTER
G1 X4 Y0 E0.2
G1 Z0.4
G1 X4 Y4 E0.4
G1 Z0.6
;TYPE:SUPPORT
G1 X0 Y4 E0.6
`);
const preview = buildGcodePreviewMeshData(data, { detailMode: "full" });
const supportVertexOffset = 2 * 4 * 3;
const supportRgb = Array.from(preview.colors.slice(supportVertexOffset, supportVertexOffset + 3));
assert.ok(supportRgb[0] < 0.75);
assert.ok(supportRgb[1] > 0.45);
assert.ok(supportRgb[2] > 0.55);
assert.ok(preview.vertices[supportVertexOffset + 2] > 0.6);
assert.equal(preview.bounds.max[2], 0.6);
});
test("buildGcodePreviewMeshData adaptively samples whole layers unless full detail is requested", () => {
const moves = Array.from({ length: 8 }, (_, layerIndex) => `
G1 Z${((layerIndex + 1) * 0.2).toFixed(1)}
G1 X${layerIndex * 2 + 1} Y0 E${(layerIndex * 2 + 1) / 10}
G1 X${layerIndex * 2 + 2} Y0 E${(layerIndex * 2 + 2) / 10}
`).join("\n");
const data = parseGcode(`
G21
G90
M82
G0 X0 Y0 Z0.2
${moves}
`);
const adaptive = buildGcodePreviewMeshData(data, {
maxVisibleSegments: 6
});
assert.equal(adaptive.metadata.visibleSegments, 16);
assert.equal(adaptive.metadata.segmentStride, 1);
assert.equal(adaptive.metadata.layerStride, 4);
assert.equal(adaptive.metadata.renderedLayers, 3);
assert.equal(adaptive.metadata.renderedSegments, 6);
assert.equal(adaptive.metadata.decimationMode, "layers");
assert.equal(adaptive.metadata.decimated, true);
assert.deepEqual(adaptive.parts.map((part) => part.label), ["Layer 1", "Layer 5", "Layer 8"]);
const full = buildGcodePreviewMeshData(data, {
detailMode: "full",
maxVisibleSegments: 6
});
assert.equal(full.metadata.visibleSegments, 16);
assert.equal(full.metadata.segmentStride, 1);
assert.equal(full.metadata.layerStride, 1);
assert.equal(full.metadata.renderedSegments, 16);
assert.equal(full.metadata.decimated, false);
const maxAdaptive = buildGcodePreviewMeshData(data, {
detailLevel: 99
});
assert.equal(maxAdaptive.metadata.options.detailLevel, GCODE_PREVIEW_DETAIL_MAX);
});
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
function readJson(relativePath) {
return JSON.parse(fs.readFileSync(path.join(packageRoot, relativePath), "utf8"));
}
function collectSourceFiles(directory, files = []) {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectSourceFiles(entryPath, files);
} else if (/\.[cm]?js$/u.test(entry.name)) {
files.push(entryPath);
}
}
return files;
}
test("cadjs does not depend on implicitjs", () => {
const manifest = readJson("package.json");
const lockfile = readJson("package-lock.json");
assert.equal(manifest.dependencies?.implicitjs, undefined);
assert.equal(lockfile.packages?.[""]?.dependencies?.implicitjs, undefined);
assert.equal(lockfile.packages?.["node_modules/implicitjs"], undefined);
const sourceFiles = collectSourceFiles(path.join(packageRoot, "src"));
for (const filePath of sourceFiles) {
const source = fs.readFileSync(filePath, "utf8");
assert.equal(
/\bfrom\s+["']implicitjs(?:\/[^"']*)?["']|export\s+\*\s+from\s+["']implicitjs(?:\/[^"']*)?["']/u.test(source),
false,
`${path.relative(packageRoot, filePath)} imports implicitjs`
);
}
});
test("viewer-only server workflow modules stay out of cadjs", () => {
for (const relativePath of [
"src/lib/cadDirectoryScanner.mjs",
"src/lib/generationStatus.mjs",
"src/lib/step/stepArtifactCompiler.mjs",
"src/lib/cadManifestStore.js",
"src/lib/cadViewerDirectorySession.mjs",
"src/lib/viewerConfig.mjs",
"src/lib/viewerServerInfo.mjs",
"src/lib/viewerServerRegistry.mjs",
]) {
assert.equal(fs.existsSync(path.join(packageRoot, relativePath)), false, relativePath);
}
});
test("viewer workspace resolution stays in viewer", () => {
const pathUtilsSource = fs.readFileSync(path.join(packageRoot, "src/lib/pathUtils.mjs"), "utf8");
assert.equal(/\bresolveWorkspaceRoot\b/u.test(pathUtilsSource), false);
});
@@ -0,0 +1,30 @@
import path from "node:path";
export function toPosixPath(value) {
return String(value || "").split(path.sep).join("/");
}
export function encodePathParam(value) {
return toPosixPath(value)
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
}
function relativePathStaysInsideRoot(relativePath) {
return relativePath === "" || (
relativePath !== ".." &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
export function pathIsInside(childPath, parentPath) {
const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath));
return Boolean(relativePath) && relativePathStaysInsideRoot(relativePath);
}
export function pathIsInsideOrEqual(childPath, parentPath) {
const relativePath = path.relative(path.resolve(parentPath), path.resolve(childPath));
return relativePathStaysInsideRoot(relativePath);
}
@@ -0,0 +1,163 @@
import {
cloneCameraVector,
normalizeCameraZoom
} from "../common/camera.js";
function normalizePerspectiveMetadataValue(value) {
return String(value || "").trim();
}
export const CAMERA_PROJECTION = Object.freeze({
PERSPECTIVE: "perspective",
ORTHOGRAPHIC: "orthographic"
});
export function normalizeCameraProjection(value, fallback = CAMERA_PROJECTION.PERSPECTIVE) {
const normalizedValue = String(value || "").trim().toLowerCase();
if (normalizedValue === CAMERA_PROJECTION.ORTHOGRAPHIC) {
return CAMERA_PROJECTION.ORTHOGRAPHIC;
}
if (normalizedValue === CAMERA_PROJECTION.PERSPECTIVE) {
return CAMERA_PROJECTION.PERSPECTIVE;
}
return fallback === CAMERA_PROJECTION.ORTHOGRAPHIC
? CAMERA_PROJECTION.ORTHOGRAPHIC
: CAMERA_PROJECTION.PERSPECTIVE;
}
export function clonePerspectiveVector(vector) {
return cloneCameraVector(vector);
}
export function clonePerspectiveSnapshot(snapshot) {
if (!snapshot || typeof snapshot !== "object") {
return null;
}
const position = clonePerspectiveVector(snapshot.position);
const target = clonePerspectiveVector(snapshot.target);
const up = clonePerspectiveVector(snapshot.up);
if (!position || !target || !up) {
return null;
}
const clonedSnapshot = {
position,
target,
up
};
if (Object.prototype.hasOwnProperty.call(snapshot, "zoom")) {
clonedSnapshot.zoom = normalizeCameraZoom(snapshot.zoom, 1);
}
if (Object.prototype.hasOwnProperty.call(snapshot, "projection")) {
clonedSnapshot.projection = normalizeCameraProjection(snapshot.projection);
}
const modelKey = normalizePerspectiveMetadataValue(snapshot.modelKey);
const sceneScaleMode = normalizePerspectiveMetadataValue(snapshot.sceneScaleMode);
const coordinateSystem = normalizePerspectiveMetadataValue(snapshot.coordinateSystem);
if (modelKey) {
clonedSnapshot.modelKey = modelKey;
}
if (sceneScaleMode) {
clonedSnapshot.sceneScaleMode = sceneScaleMode;
}
if (coordinateSystem) {
clonedSnapshot.coordinateSystem = coordinateSystem;
}
return clonedSnapshot;
}
export function annotatePerspectiveSnapshot(snapshot, { modelKey = "", sceneScaleMode = "", coordinateSystem = "" } = {}) {
const annotatedSnapshot = clonePerspectiveSnapshot(snapshot);
if (!annotatedSnapshot) {
return null;
}
const normalizedModelKey = normalizePerspectiveMetadataValue(modelKey);
const normalizedSceneScaleMode = normalizePerspectiveMetadataValue(sceneScaleMode);
const normalizedCoordinateSystem = normalizePerspectiveMetadataValue(coordinateSystem);
if (normalizedModelKey) {
annotatedSnapshot.modelKey = normalizedModelKey;
}
if (normalizedSceneScaleMode) {
annotatedSnapshot.sceneScaleMode = normalizedSceneScaleMode;
}
if (normalizedCoordinateSystem) {
annotatedSnapshot.coordinateSystem = normalizedCoordinateSystem;
}
return annotatedSnapshot;
}
export function resolvePerspectiveSnapshot(primary, fallback) {
if (typeof primary !== "undefined") {
return clonePerspectiveSnapshot(primary);
}
return clonePerspectiveSnapshot(fallback);
}
function perspectiveVectorEqual(a, b, epsilon = 1e-4) {
if (a === b) {
return true;
}
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
return false;
}
for (let index = 0; index < a.length; index += 1) {
if (Math.abs((Number(a[index]) || 0) - (Number(b[index]) || 0)) > epsilon) {
return false;
}
}
return true;
}
export function perspectiveSnapshotEqual(a, b, epsilon = 1e-4) {
if (a === b) {
return true;
}
if (!a || !b) {
return !a && !b;
}
return (
perspectiveVectorEqual(a.position, b.position, epsilon) &&
perspectiveVectorEqual(a.target, b.target, epsilon) &&
perspectiveVectorEqual(a.up, b.up, epsilon) &&
Math.abs(normalizeCameraZoom(a.zoom, 1) - normalizeCameraZoom(b.zoom, 1)) <= epsilon &&
normalizeCameraProjection(a.projection) === normalizeCameraProjection(b.projection) &&
normalizePerspectiveMetadataValue(a.modelKey) === normalizePerspectiveMetadataValue(b.modelKey) &&
normalizePerspectiveMetadataValue(a.sceneScaleMode) === normalizePerspectiveMetadataValue(b.sceneScaleMode) &&
normalizePerspectiveMetadataValue(a.coordinateSystem) === normalizePerspectiveMetadataValue(b.coordinateSystem)
);
}
export function perspectiveSnapshotMatchesScene(snapshot, {
modelKey = "",
sceneScaleMode = "",
coordinateSystem = "",
requireModelKey = false,
requireSceneScaleMode = false,
requireCoordinateSystem = false
} = {}) {
const normalizedSnapshot = clonePerspectiveSnapshot(snapshot);
if (!normalizedSnapshot) {
return false;
}
const normalizedModelKey = normalizePerspectiveMetadataValue(modelKey);
if (requireModelKey && normalizedModelKey && normalizedSnapshot.modelKey !== normalizedModelKey) {
return false;
}
if (normalizedModelKey && normalizedSnapshot.modelKey && normalizedSnapshot.modelKey !== normalizedModelKey) {
return false;
}
const normalizedSceneScaleMode = normalizePerspectiveMetadataValue(sceneScaleMode);
if (requireSceneScaleMode && normalizedSceneScaleMode && normalizedSnapshot.sceneScaleMode !== normalizedSceneScaleMode) {
return false;
}
if (normalizedSceneScaleMode && normalizedSnapshot.sceneScaleMode && normalizedSnapshot.sceneScaleMode !== normalizedSceneScaleMode) {
return false;
}
const normalizedCoordinateSystem = normalizePerspectiveMetadataValue(coordinateSystem);
if (requireCoordinateSystem && normalizedCoordinateSystem && normalizedSnapshot.coordinateSystem !== normalizedCoordinateSystem) {
return false;
}
if (normalizedCoordinateSystem && normalizedSnapshot.coordinateSystem && normalizedSnapshot.coordinateSystem !== normalizedCoordinateSystem) {
return false;
}
return true;
}
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
annotatePerspectiveSnapshot,
CAMERA_PROJECTION,
clonePerspectiveSnapshot,
normalizeCameraProjection,
perspectiveSnapshotEqual,
perspectiveSnapshotMatchesScene,
resolvePerspectiveSnapshot
} from "./perspective.js";
const PERSPECTIVE_A = Object.freeze({
position: [10, 20, 30],
target: [1, 2, 3],
up: [0, 1, 0]
});
const PERSPECTIVE_B = Object.freeze({
position: [40, 50, 60],
target: [4, 5, 6],
up: [0, 0, 1],
zoom: 2.5
});
test("resolvePerspectiveSnapshot uses the fallback only when the primary value is undefined", () => {
assert.deepEqual(resolvePerspectiveSnapshot(undefined, PERSPECTIVE_A), PERSPECTIVE_A);
assert.equal(resolvePerspectiveSnapshot(null, PERSPECTIVE_A), null);
assert.deepEqual(resolvePerspectiveSnapshot(PERSPECTIVE_B, PERSPECTIVE_A), PERSPECTIVE_B);
});
test("perspective snapshots match scene metadata when present", () => {
const taggedSnapshot = annotatePerspectiveSnapshot({
...PERSPECTIVE_A,
zoom: 1.75
}, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1"
});
assert.deepEqual(taggedSnapshot, {
...PERSPECTIVE_A,
zoom: 1.75,
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1"
});
assert.equal(
perspectiveSnapshotMatchesScene(taggedSnapshot, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1"
}),
true
);
assert.equal(
perspectiveSnapshotMatchesScene(taggedSnapshot, {
modelKey: "sample_robot",
sceneScaleMode: "cad"
}),
false
);
assert.equal(
perspectiveSnapshotMatchesScene(taggedSnapshot, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-y-up-v1"
}),
false
);
assert.equal(
perspectiveSnapshotMatchesScene(PERSPECTIVE_A, {
modelKey: "sample_robot.urdf",
coordinateSystem: "cad-z-up-v1"
}),
true
);
assert.equal(
perspectiveSnapshotMatchesScene(PERSPECTIVE_A, {
modelKey: "sample_robot.urdf",
sceneScaleMode: "urdf",
coordinateSystem: "cad-z-up-v1",
requireModelKey: true,
requireSceneScaleMode: true,
requireCoordinateSystem: true
}),
false
);
});
test("perspective snapshots preserve and compare camera projection", () => {
assert.equal(normalizeCameraProjection("ORTHOGRAPHIC"), CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.equal(normalizeCameraProjection("unknown"), CAMERA_PROJECTION.PERSPECTIVE);
assert.equal(normalizeCameraProjection("unknown", CAMERA_PROJECTION.ORTHOGRAPHIC), CAMERA_PROJECTION.ORTHOGRAPHIC);
assert.deepEqual(
clonePerspectiveSnapshot({
...PERSPECTIVE_A,
projection: "orthographic"
}),
{
...PERSPECTIVE_A,
projection: CAMERA_PROJECTION.ORTHOGRAPHIC
}
);
assert.equal(
perspectiveSnapshotEqual(PERSPECTIVE_A, {
...PERSPECTIVE_A,
projection: CAMERA_PROJECTION.PERSPECTIVE
}),
true
);
assert.equal(
perspectiveSnapshotEqual(PERSPECTIVE_A, {
...PERSPECTIVE_A,
projection: CAMERA_PROJECTION.ORTHOGRAPHIC
}),
false
);
});
@@ -0,0 +1,492 @@
const GLB_CAD_UNIT_SCALE = 1000;
const CAD_EDGE_BARYCENTRIC_ATTRIBUTE_NAMES = Object.freeze([
"_cad_edge_barycentric",
"_CAD_EDGE_BARYCENTRIC"
]);
const CAD_EDGE_CLASS_ATTRIBUTE_NAMES = Object.freeze([
"_cad_edge_class",
"_CAD_EDGE_CLASS"
]);
const GENERATED_STEP_DEFAULT_BASE_COLOR = Object.freeze([0.72, 0.72, 0.72, 1]);
const BASE_COLOR_EPSILON = 1e-6;
function createBoundsAccumulator() {
return {
min: [Infinity, Infinity, Infinity],
max: [-Infinity, -Infinity, -Infinity],
};
}
function includeBoundsPoint(bounds, x, y, z) {
if (![x, y, z].every(Number.isFinite)) {
return;
}
bounds.min[0] = Math.min(bounds.min[0], x);
bounds.min[1] = Math.min(bounds.min[1], y);
bounds.min[2] = Math.min(bounds.min[2], z);
bounds.max[0] = Math.max(bounds.max[0], x);
bounds.max[1] = Math.max(bounds.max[1], y);
bounds.max[2] = Math.max(bounds.max[2], z);
}
function boundsFromAccumulator(bounds) {
if (!bounds?.min?.every(Number.isFinite) || !bounds?.max?.every(Number.isFinite)) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return {
min: [...bounds.min],
max: [...bounds.max],
};
}
function rawBaseColorFactor(rawMaterial) {
const value = rawMaterial?.pbrMetallicRoughness?.baseColorFactor;
return Array.isArray(value) ? value : null;
}
function colorFactorsEqual(left, right) {
if (!Array.isArray(left) || !Array.isArray(right)) {
return false;
}
return right.every((expected, index) => (
Math.abs(Number(left[index] ?? (index === 3 ? 1 : 0)) - expected) <= BASE_COLOR_EPSILON
));
}
function rawMaterialSourceColorHint(rawMaterial) {
const value = rawMaterial?.extras?.cadSourceColor;
return value === true || value === false ? value : null;
}
function materialSourceColorHint(material) {
const value = material?.userData?.cadSourceColor;
return value === true || value === false ? value : null;
}
function isGeneratedStepDefaultMaterial(rawMaterial, material) {
return (
colorFactorsEqual(rawBaseColorFactor(rawMaterial), GENERATED_STEP_DEFAULT_BASE_COLOR) &&
material?.color?.getHexString?.() === "dddddd"
);
}
function colorFromMaterial(material, useSourceColors, {
rawMaterial = null,
stepTopology = false
} = {}) {
if (!useSourceColors || !material?.color) {
return null;
}
const sourceColorHint = materialSourceColorHint(material) ?? rawMaterialSourceColorHint(rawMaterial);
if (sourceColorHint === false) {
return null;
}
if (stepTopology && sourceColorHint !== true && isGeneratedStepDefaultMaterial(rawMaterial, material)) {
return null;
}
return {
rgb: [material.color.r, material.color.g, material.color.b],
hex: `#${material.color.getHexString()}`,
opacity: materialOpacity(material, rawMaterial),
};
}
function materialOpacity(material, rawMaterial = null) {
const materialValue = Number(material?.opacity);
if (Number.isFinite(materialValue)) {
return Math.min(Math.max(materialValue, 0), 1);
}
const rawAlpha = rawBaseColorFactor(rawMaterial)?.[3];
const rawValue = Number(rawAlpha);
return Number.isFinite(rawValue) ? Math.min(Math.max(rawValue, 0), 1) : 1;
}
function materialForGroup(material, group) {
if (Array.isArray(material)) {
const materialIndex = Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
return material[materialIndex] || material[0] || null;
}
return material || null;
}
function materialIndexForGroup(group) {
return Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
}
function rawMaterialForGroup(rawMaterials, group) {
if (!Array.isArray(rawMaterials) || rawMaterials.length <= 0) {
return null;
}
return rawMaterials[materialIndexForGroup(group)] || rawMaterials[0] || null;
}
function isBuild123dAxisCorrectionMatrix(matrix) {
const elements = matrix?.elements;
if (!Array.isArray(elements) && !(elements instanceof Float32Array)) {
return false;
}
const expected = [
1, 0, 0, 0,
0, 0, -1, 0,
0, 1, 0, 0,
0, 0, 0, 1,
];
return expected.every((value, index) => Math.abs(Number(elements[index]) - value) < 1e-6);
}
function buildGlbCadRootCorrection(THREE, scene) {
scene?.updateWorldMatrix?.(true, true);
const children = Array.isArray(scene?.children) ? scene.children : [];
if (children.length !== 1 || !isBuild123dAxisCorrectionMatrix(children[0]?.matrixWorld)) {
return null;
}
return new THREE.Matrix4().copy(children[0].matrixWorld).invert();
}
function cadOccurrenceIdForObject(object) {
let current = object || null;
while (current) {
const rawOccurrenceId = String(current.userData?.cadOccurrenceId || "").trim();
if (rawOccurrenceId) {
return rawOccurrenceId;
}
current = current.parent || null;
}
const objectName = String(object?.name || "").trim();
return /^o\d+(?:\.\d+)*$/.test(objectName) ? objectName : "";
}
function sceneHasCadOccurrenceIds(scene) {
let found = false;
scene?.traverse?.((object) => {
if (found) {
return;
}
found = Boolean(String(object?.userData?.cadOccurrenceId || "").trim());
});
return found;
}
function cadVectorFromGlbVector(vector, convertYUpToCad) {
const x = vector.x * GLB_CAD_UNIT_SCALE;
const y = vector.y * GLB_CAD_UNIT_SCALE;
const z = vector.z * GLB_CAD_UNIT_SCALE;
return convertYUpToCad
? { x, y: -z, z: y }
: { x, y, z };
}
function sourceIndexForSlot(indexAttribute, slot) {
return indexAttribute ? indexAttribute.getX(slot) : slot;
}
function geometryAttributeByName(geometry, names) {
for (const name of names) {
const attribute = geometry?.getAttribute?.(name);
if (attribute) {
return attribute;
}
}
return null;
}
function isValidTriangleSource(positions, indexAttribute, sourceStart) {
for (let offset = 0; offset < 3; offset += 1) {
const sourceIndex = sourceIndexForSlot(indexAttribute, sourceStart + offset);
if (sourceIndex < 0 || sourceIndex >= positions.count) {
return false;
}
}
return true;
}
function countPrimitiveOutputVertices(positions, indexAttribute, sourceStart, triangleVertexCount) {
let vertexCount = 0;
for (let localIndex = 0; localIndex < triangleVertexCount; localIndex += 3) {
if (isValidTriangleSource(positions, indexAttribute, sourceStart + localIndex)) {
vertexCount += 3;
}
}
return vertexCount;
}
function inspectGlbPrimitive(
THREE,
mesh,
group,
material,
useSourceColors,
rootCorrection,
convertYUpToCad,
primitiveIndex = 0,
partIndex = 0,
{ rawMaterial = null, stepTopology = false } = {}
) {
const geometry = mesh?.geometry;
const positions = geometry?.getAttribute?.("position");
if (!positions || positions.itemSize !== 3 || positions.count <= 0) {
return null;
}
mesh.updateWorldMatrix?.(true, false);
const matrixWorld = mesh.matrixWorld
? (
rootCorrection
? new THREE.Matrix4().multiplyMatrices(rootCorrection, mesh.matrixWorld)
: new THREE.Matrix4().copy(mesh.matrixWorld)
)
: null;
const normalMatrix = matrixWorld ? new THREE.Matrix3().getNormalMatrix(matrixWorld) : null;
const normals = geometry.getAttribute("normal");
const surfaceEdgeBarycentric = geometryAttributeByName(geometry, CAD_EDGE_BARYCENTRIC_ATTRIBUTE_NAMES);
const surfaceEdgeClass = geometryAttributeByName(geometry, CAD_EDGE_CLASS_ATTRIBUTE_NAMES);
const indexAttribute = geometry.getIndex?.();
const sourceStart = Math.max(0, Math.floor(Number(group?.start || 0)));
const availableCount = indexAttribute?.count || positions.count;
const rawCount = Math.floor(Number(group?.count || (availableCount - sourceStart)));
const sourceCount = Math.max(0, Math.min(rawCount, availableCount - sourceStart));
const triangleVertexCount = sourceCount - (sourceCount % 3);
if (triangleVertexCount <= 0) {
return null;
}
const vertexCount = countPrimitiveOutputVertices(positions, indexAttribute, sourceStart, triangleVertexCount);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return null;
}
const color = colorFromMaterial(material, useSourceColors, {
rawMaterial,
stepTopology
});
const cadOccurrenceId = cadOccurrenceIdForObject(mesh);
const label = String(cadOccurrenceId || mesh?.name || mesh?.parent?.name || `glb:${partIndex}`).trim();
const id = cadOccurrenceId || `glb:${partIndex}`;
return {
mesh,
positions,
normals,
surfaceEdgeBarycentric,
surfaceEdgeClass,
indexAttribute,
sourceStart,
triangleVertexCount,
matrixWorld,
normalMatrix,
convertYUpToCad,
id,
occurrenceId: id,
primitiveIndex: Math.max(0, Math.floor(Number(primitiveIndex) || 0)),
name: label || id,
label: label || id,
color: color?.hex || "",
opacity: color ? color.opacity : 1,
hasSourceColors: Boolean(color),
vertexCount,
triangleCount,
};
}
function writeGlbPrimitive(THREE, descriptor, output, offsets) {
const partBounds = createBoundsAccumulator();
const positionVector = new THREE.Vector3();
const normalVector = new THREE.Vector3();
const vertexOffset = offsets.vertexOffset;
const triangleOffset = Math.floor(offsets.indexOffset / 3);
let localVertexCount = 0;
for (let localIndex = 0; localIndex < descriptor.triangleVertexCount; localIndex += 3) {
if (!isValidTriangleSource(
descriptor.positions,
descriptor.indexAttribute,
descriptor.sourceStart + localIndex
)) {
continue;
}
for (let triangleOffsetIndex = 0; triangleOffsetIndex < 3; triangleOffsetIndex += 1) {
const sourceSlot = descriptor.sourceStart + localIndex + triangleOffsetIndex;
const sourceIndex = sourceIndexForSlot(descriptor.indexAttribute, sourceSlot);
const outputVertexIndex = vertexOffset + localVertexCount;
const outputComponentIndex = outputVertexIndex * 3;
positionVector.set(
descriptor.positions.getX(sourceIndex),
descriptor.positions.getY(sourceIndex),
descriptor.positions.getZ(sourceIndex)
);
if (descriptor.matrixWorld) {
positionVector.applyMatrix4(descriptor.matrixWorld);
}
const cadPosition = cadVectorFromGlbVector(positionVector, descriptor.convertYUpToCad);
const x = cadPosition.x;
const y = cadPosition.y;
const z = cadPosition.z;
output.vertices[outputComponentIndex] = x;
output.vertices[outputComponentIndex + 1] = y;
output.vertices[outputComponentIndex + 2] = z;
includeBoundsPoint(partBounds, x, y, z);
includeBoundsPoint(output.bounds, x, y, z);
if (descriptor.normals?.itemSize === 3 && sourceIndex < descriptor.normals.count) {
normalVector.set(
descriptor.normals.getX(sourceIndex),
descriptor.normals.getY(sourceIndex),
descriptor.normals.getZ(sourceIndex)
);
if (descriptor.normalMatrix) {
normalVector.applyMatrix3(descriptor.normalMatrix).normalize();
}
const cadNormal = descriptor.convertYUpToCad
? { x: normalVector.x, y: -normalVector.z, z: normalVector.y }
: normalVector;
output.normals[outputComponentIndex] = cadNormal.x;
output.normals[outputComponentIndex + 1] = cadNormal.y;
output.normals[outputComponentIndex + 2] = cadNormal.z;
}
if (output.surfaceEdgeBarycentric && descriptor.surfaceEdgeBarycentric?.itemSize === 3 && sourceIndex < descriptor.surfaceEdgeBarycentric.count) {
output.surfaceEdgeBarycentric[outputComponentIndex] = descriptor.surfaceEdgeBarycentric.getX(sourceIndex);
output.surfaceEdgeBarycentric[outputComponentIndex + 1] = descriptor.surfaceEdgeBarycentric.getY(sourceIndex);
output.surfaceEdgeBarycentric[outputComponentIndex + 2] = descriptor.surfaceEdgeBarycentric.getZ(sourceIndex);
}
if (output.surfaceEdgeClass && descriptor.surfaceEdgeClass?.itemSize === 3 && sourceIndex < descriptor.surfaceEdgeClass.count) {
output.surfaceEdgeClass[outputComponentIndex] = descriptor.surfaceEdgeClass.getX(sourceIndex);
output.surfaceEdgeClass[outputComponentIndex + 1] = descriptor.surfaceEdgeClass.getY(sourceIndex);
output.surfaceEdgeClass[outputComponentIndex + 2] = descriptor.surfaceEdgeClass.getZ(sourceIndex);
}
output.indices[offsets.indexOffset + localVertexCount] = outputVertexIndex;
localVertexCount += 1;
}
}
const triangleCount = Math.floor(localVertexCount / 3);
const part = {
id: descriptor.id,
occurrenceId: descriptor.occurrenceId,
primitiveIndex: descriptor.primitiveIndex,
name: descriptor.name,
label: descriptor.label,
nodeType: "part",
color: descriptor.color,
opacity: descriptor.opacity,
hasSourceColors: descriptor.hasSourceColors,
bounds: boundsFromAccumulator(partBounds),
vertexOffset,
vertexCount: localVertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
};
offsets.vertexOffset += localVertexCount;
offsets.indexOffset += triangleCount * 3;
return part;
}
function buildMeshDataFromGltf(THREE, gltf) {
const declaredMaterials = Array.isArray(gltf?.parser?.json?.materials) && gltf.parser.json.materials.length > 0;
const rawMaterials = Array.isArray(gltf?.parser?.json?.materials) ? gltf.parser.json.materials : [];
const rootCorrection = buildGlbCadRootCorrection(THREE, gltf?.scene);
const hasStepTopology = !!gltf?.parser?.json?.extensions?.STEP_topology;
const convertYUpToCad = !hasStepTopology && !sceneHasCadOccurrenceIds(gltf?.scene) && !rootCorrection;
const descriptors = [];
const colorSet = new Set();
let totalVertexCount = 0;
let totalIndexCount = 0;
let hasSurfaceEdgeAttributes = false;
const nextPrimitiveIndexByOccurrence = new Map();
gltf?.scene?.traverse?.((object) => {
if (!object?.isMesh || !object.geometry) {
return;
}
const occurrenceId = cadOccurrenceIdForObject(object) || String(object?.name || `glb:${descriptors.length}`).trim();
const primitiveIndexBase = nextPrimitiveIndexByOccurrence.get(occurrenceId) || 0;
const groups = Array.isArray(object.geometry.groups) && object.geometry.groups.length
? object.geometry.groups
: [null];
groups.forEach((group, primitiveIndex) => {
const descriptor = inspectGlbPrimitive(
THREE,
object,
group,
materialForGroup(object.material, group),
declaredMaterials,
rootCorrection,
convertYUpToCad,
primitiveIndexBase + primitiveIndex,
descriptors.length,
{
rawMaterial: rawMaterialForGroup(rawMaterials, group),
stepTopology: hasStepTopology
}
);
if (!descriptor) {
return;
}
descriptors.push(descriptor);
totalVertexCount += descriptor.vertexCount;
totalIndexCount += descriptor.triangleCount * 3;
hasSurfaceEdgeAttributes ||= Boolean(descriptor.surfaceEdgeBarycentric && descriptor.surfaceEdgeClass);
if (descriptor.color) {
colorSet.add(descriptor.color.toLowerCase());
}
});
nextPrimitiveIndexByOccurrence.set(occurrenceId, primitiveIndexBase + groups.length);
});
const vertices = new Float32Array(totalVertexCount * 3);
const indices = new Uint32Array(totalIndexCount);
const normals = new Float32Array(totalVertexCount * 3);
const surfaceEdgeBarycentric = hasSurfaceEdgeAttributes
? new Float32Array(totalVertexCount * 3)
: null;
const surfaceEdgeClass = hasSurfaceEdgeAttributes
? new Uint8Array(totalVertexCount * 3)
: null;
const parts = [];
const output = {
vertices,
indices,
normals,
surfaceEdgeBarycentric,
surfaceEdgeClass,
bounds: createBoundsAccumulator(),
};
const offsets = {
vertexOffset: 0,
indexOffset: 0,
};
for (const descriptor of descriptors) {
parts.push(writeGlbPrimitive(THREE, descriptor, output, offsets));
}
const colors = new Float32Array(0);
return {
vertices,
indices,
normals,
surfaceEdgeBarycentric: surfaceEdgeBarycentric || new Float32Array(0),
surfaceEdgeClass: surfaceEdgeClass || new Uint8Array(0),
colors,
edge_indices: new Uint32Array(0),
bounds: boundsFromAccumulator(output.bounds),
parts,
has_source_colors: colorSet.size > 0,
sourceColor: colorSet.size === 1 ? [...colorSet][0] : "",
};
}
function parseGlb(GLTFLoader, buffer) {
const loader = new GLTFLoader();
return new Promise((resolve, reject) => {
loader.parse(buffer, "", resolve, reject);
});
}
export async function buildMeshDataFromGlbBuffer(buffer) {
const [THREE, { GLTFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/GLTFLoader.js"),
]);
const gltf = await parseGlb(GLTFLoader, buffer);
return buildMeshDataFromGltf(THREE, gltf);
}
@@ -0,0 +1,546 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildMeshDataFromGlbBuffer } from "./glbMeshData.js";
function pad4(buffer, padByte = 0) {
const padding = (4 - (buffer.length % 4)) % 4;
return padding ? Buffer.concat([buffer, Buffer.alloc(padding, padByte)]) : buffer;
}
function floatBuffer(values) {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeFloatLE(value, index * 4));
return buffer;
}
function uint32Buffer(values) {
const buffer = Buffer.alloc(values.length * 4);
values.forEach((value, index) => buffer.writeUInt32LE(value, index * 4));
return buffer;
}
function makeOccurrenceGlb({
cadExtras = true,
stepTopology = false,
materialBaseColor = null,
materialExtras = null
} = {}) {
const positions = floatBuffer([
0, 0, 0,
0.001, 0, 0,
0, 0.001, 0
]);
const indices = uint32Buffer([0, 1, 2]);
const binary = pad4(Buffer.concat([positions, indices]));
const primitive = {
attributes: {
POSITION: 0
},
indices: 1,
mode: 4,
...(Array.isArray(materialBaseColor) ? { material: 0 } : {})
};
const gltf = {
asset: {
version: "2.0"
},
...(stepTopology
? {
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {}
}
}
: {}),
scenes: [
{
nodes: [0]
}
],
nodes: [
{
name: "leaf",
mesh: 0,
...(cadExtras ? { extras: { cadOccurrenceId: "o1.2" } } : {})
}
],
meshes: [
{
primitives: [primitive]
}
],
...(Array.isArray(materialBaseColor)
? {
materials: [
{
pbrMetallicRoughness: {
baseColorFactor: materialBaseColor
},
...(materialExtras ? { extras: materialExtras } : {})
}
]
}
: {}),
buffers: [
{
byteLength: binary.length
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positions.length,
target: 34962
},
{
buffer: 0,
byteOffset: positions.length,
byteLength: indices.length,
target: 34963
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 3,
type: "VEC3",
min: [0, 0, 0],
max: [0.001, 0.001, 0]
},
{
bufferView: 1,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [0],
max: [2]
}
]
};
const json = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + json.length + 8 + binary.length, 8);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binary.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
const glb = Buffer.concat([header, jsonHeader, json, binHeader, binary]);
return glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength);
}
function makeTwoPrimitiveOccurrenceGlb({
stepTopology = false,
materialBaseColors = [
[1, 0, 0, 1],
[0, 0, 1, 1]
],
materialExtras = []
} = {}) {
const positions = floatBuffer([
0, 0, 0,
0.001, 0, 0,
0, 0.001, 0,
0.002, 0, 0,
0.003, 0, 0,
0.002, 0.001, 0
]);
const firstIndices = uint32Buffer([0, 1, 2]);
const secondIndices = uint32Buffer([3, 4, 5]);
const binary = pad4(Buffer.concat([positions, firstIndices, secondIndices]));
const secondIndexOffset = positions.length + firstIndices.length;
const gltf = {
asset: {
version: "2.0"
},
...(stepTopology
? {
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {}
}
}
: {}),
scenes: [
{
nodes: [0]
}
],
nodes: [
{
name: "leaf",
mesh: 0,
extras: {
cadOccurrenceId: "o1.2"
}
}
],
meshes: [
{
primitives: [
{
attributes: {
POSITION: 0
},
indices: 1,
material: 0,
mode: 4
},
{
attributes: {
POSITION: 0
},
indices: 2,
material: 1,
mode: 4
}
]
}
],
materials: materialBaseColors.map((baseColorFactor, index) => ({
pbrMetallicRoughness: {
baseColorFactor
},
...(materialExtras[index] ? { extras: materialExtras[index] } : {})
})),
buffers: [
{
byteLength: binary.length
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positions.length,
target: 34962
},
{
buffer: 0,
byteOffset: positions.length,
byteLength: firstIndices.length,
target: 34963
},
{
buffer: 0,
byteOffset: secondIndexOffset,
byteLength: secondIndices.length,
target: 34963
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 6,
type: "VEC3",
min: [0, 0, 0],
max: [0.003, 0.001, 0]
},
{
bufferView: 1,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [0],
max: [2]
},
{
bufferView: 2,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [3],
max: [5]
}
]
};
const json = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + json.length + 8 + binary.length, 8);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binary.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
const glb = Buffer.concat([header, jsonHeader, json, binHeader, binary]);
return glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength);
}
function makeIndexedMaterialOccurrenceGlb({
stepTopology = false,
primitiveMaterial = 1,
materialBaseColors = [
[0.72, 0.72, 0.72, 1],
[1, 0, 0, 1]
],
materialExtras = [
{ cadSourceColor: false },
{ cadSourceColor: true }
]
} = {}) {
const positions = floatBuffer([
0, 0, 0,
0.001, 0, 0,
0, 0.001, 0
]);
const indices = uint32Buffer([0, 1, 2]);
const binary = pad4(Buffer.concat([positions, indices]));
const gltf = {
asset: {
version: "2.0"
},
...(stepTopology
? {
extensionsUsed: ["STEP_topology"],
extensions: {
STEP_topology: {}
}
}
: {}),
scenes: [
{
nodes: [0]
}
],
nodes: [
{
name: "leaf",
mesh: 0,
extras: {
cadOccurrenceId: "o1.2"
}
}
],
meshes: [
{
primitives: [
{
attributes: {
POSITION: 0
},
indices: 1,
material: primitiveMaterial,
mode: 4
}
]
}
],
materials: materialBaseColors.map((baseColorFactor, index) => ({
pbrMetallicRoughness: {
baseColorFactor
},
...(materialExtras[index] ? { extras: materialExtras[index] } : {})
})),
buffers: [
{
byteLength: binary.length
}
],
bufferViews: [
{
buffer: 0,
byteOffset: 0,
byteLength: positions.length,
target: 34962
},
{
buffer: 0,
byteOffset: positions.length,
byteLength: indices.length,
target: 34963
}
],
accessors: [
{
bufferView: 0,
byteOffset: 0,
componentType: 5126,
count: 3,
type: "VEC3",
min: [0, 0, 0],
max: [0.001, 0.001, 0]
},
{
bufferView: 1,
byteOffset: 0,
componentType: 5125,
count: 3,
type: "SCALAR",
min: [0],
max: [2]
}
]
};
const json = pad4(Buffer.from(JSON.stringify(gltf), "utf8"), 0x20);
const header = Buffer.alloc(12);
header.writeUInt32LE(0x46546c67, 0);
header.writeUInt32LE(2, 4);
header.writeUInt32LE(12 + 8 + json.length + 8 + binary.length, 8);
const jsonHeader = Buffer.alloc(8);
jsonHeader.writeUInt32LE(json.length, 0);
jsonHeader.writeUInt32LE(0x4e4f534a, 4);
const binHeader = Buffer.alloc(8);
binHeader.writeUInt32LE(binary.length, 0);
binHeader.writeUInt32LE(0x004e4942, 4);
const glb = Buffer.concat([header, jsonHeader, json, binHeader, binary]);
return glb.buffer.slice(glb.byteOffset, glb.byteOffset + glb.byteLength);
}
test("GLB mesh data preserves cadOccurrenceId node extras", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb());
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].id, "o1.2");
assert.equal(meshData.parts[0].occurrenceId, "o1.2");
assert.equal(meshData.parts[0].primitiveIndex, 0);
assert.deepEqual(Array.from(meshData.vertices, (value) => Object.is(value, -0) ? 0 : value), [
0, 0, 0,
1, 0, 0,
0, 1, 0
]);
});
test("native GLB mesh data converts Y-up meters to CAD Z-up millimeters", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({ cadExtras: false }));
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].id, "glb:0");
assert.equal(meshData.parts[0].label, "leaf");
assert.deepEqual(Array.from(meshData.vertices, (value) => Object.is(value, -0) ? 0 : value), [
0, 0, 0,
1, 0, 0,
0, 0, 1
]);
});
test("STEP GLB mesh data ignores generated default material colors", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [0.72, 0.72, 0.72, 1]
}));
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceColor, "");
assert.deepEqual([...meshData.colors], []);
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[0].hasSourceColors, false);
});
test("STEP GLB mesh data preserves neutral source material colors", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeTwoPrimitiveOccurrenceGlb({
stepTopology: true,
materialBaseColors: [
[0, 0, 0, 1],
[1, 1, 1, 1]
]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "");
assert.deepEqual(meshData.parts.map((part) => part.color), ["#000000", "#ffffff"]);
assert.deepEqual(meshData.parts.map((part) => part.hasSourceColors), [true, true]);
});
test("STEP GLB mesh data honors explicit non-source material metadata", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [0.05, 0.05, 0.05, 1],
materialExtras: { cadSourceColor: false }
}));
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceColor, "");
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[0].hasSourceColors, false);
});
test("STEP GLB mesh data honors explicit source material metadata", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [0.72, 0.72, 0.72, 1],
materialExtras: { cadSourceColor: true }
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#dddddd");
assert.equal(meshData.parts[0].color, "#dddddd");
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves loaded material metadata on single-primitive meshes", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeIndexedMaterialOccurrenceGlb({
stepTopology: true
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#ff0000");
assert.equal(meshData.parts[0].color, "#ff0000");
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves non-gray material colors", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [1, 0, 0, 1]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#ff0000");
assert.equal(meshData.colors.length, 0);
assert.equal(meshData.parts[0].color, "#ff0000");
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves source material opacity", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeOccurrenceGlb({
stepTopology: true,
materialBaseColor: [1, 0, 0, 0.2]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.parts[0].color, "#ff0000");
assert.equal(meshData.parts[0].opacity, 0.2);
assert.equal(meshData.parts[0].hasSourceColors, true);
});
test("STEP GLB mesh data preserves colored primitives alongside generated default materials", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeTwoPrimitiveOccurrenceGlb({
stepTopology: true,
materialBaseColors: [
[0.72, 0.72, 0.72, 1],
[0.05, 0.33, 0.86, 1]
]
}));
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#3f9bef");
assert.equal(meshData.colors.length, 0);
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[0].hasSourceColors, false);
assert.equal(meshData.parts[1].color, "#3f9bef");
assert.equal(meshData.parts[1].hasSourceColors, true);
});
test("GLB mesh data assigns stable primitive indexes per occurrence", async () => {
const meshData = await buildMeshDataFromGlbBuffer(makeTwoPrimitiveOccurrenceGlb());
assert.equal(meshData.parts.length, 2);
assert.deepEqual(meshData.parts.map((part) => part.occurrenceId), ["o1.2", "o1.2"]);
assert.deepEqual(meshData.parts.map((part) => part.primitiveIndex), [0, 1]);
assert.deepEqual(meshData.parts.map((part) => part.triangleCount), [1, 1]);
});
@@ -0,0 +1,61 @@
import { buildMeshDataFromGlbBuffer } from "./glbMeshData.js";
import { meshDataTransferList } from "./meshTransfer.js";
const activeControllers = new Map();
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
async function loadArrayBuffer(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
self.addEventListener("message", async (event) => {
const message = event.data || {};
const id = message.id;
if (!id) {
return;
}
if (message.type === "cancel") {
activeControllers.get(id)?.abort();
activeControllers.delete(id);
return;
}
if (message.type !== "loadGlb") {
return;
}
const controller = new AbortController();
activeControllers.set(id, controller);
try {
const buffer = await loadArrayBuffer(message.url, controller.signal);
const meshData = await buildMeshDataFromGlbBuffer(buffer);
if (controller.signal.aborted) {
return;
}
self.postMessage(
{ id, ok: true, meshData },
meshDataTransferList(meshData)
);
} catch (error) {
if (!controller.signal.aborted) {
self.postMessage({
id,
ok: false,
error: {
name: error?.name || "Error",
message: error instanceof Error ? error.message : String(error)
}
});
}
} finally {
activeControllers.delete(id);
}
});
@@ -0,0 +1,94 @@
let glbWorker = null;
let nextRequestId = 1;
const pendingRequests = new Map();
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function glbWorkerSupported() {
return typeof Worker === "function" && typeof URL === "function";
}
function rejectPendingRequests(error) {
for (const request of pendingRequests.values()) {
request.cleanup();
request.reject(error);
}
pendingRequests.clear();
}
function ensureGlbWorker() {
if (!glbWorkerSupported()) {
return null;
}
if (glbWorker) {
return glbWorker;
}
try {
glbWorker = new Worker(new URL("./glbMeshWorker.js", import.meta.url), { type: "module" });
} catch {
glbWorker = null;
return null;
}
glbWorker.addEventListener("message", (event) => {
const message = event.data || {};
const request = pendingRequests.get(message.id);
if (!request) {
return;
}
pendingRequests.delete(message.id);
request.cleanup();
if (message.ok) {
request.resolve(message.meshData);
return;
}
const error = new Error(message.error?.message || "Failed to load GLB mesh in worker.");
error.name = message.error?.name || "Error";
request.reject(error);
});
glbWorker.addEventListener("error", (event) => {
const error = new Error(event?.message || "GLB mesh worker failed.");
rejectPendingRequests(error);
glbWorker?.terminate?.();
glbWorker = null;
});
return glbWorker;
}
export function loadGlbMeshDataInWorker(url, { signal } = {}) {
const worker = ensureGlbWorker();
if (!worker) {
return null;
}
if (signal?.aborted) {
return Promise.reject(makeAbortError());
}
const id = nextRequestId;
nextRequestId += 1;
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener?.("abort", abort);
};
const abort = () => {
pendingRequests.delete(id);
cleanup();
worker.postMessage({ type: "cancel", id });
reject(makeAbortError());
};
pendingRequests.set(id, {
resolve,
reject,
cleanup
});
signal?.addEventListener?.("abort", abort, { once: true });
worker.postMessage({ type: "loadGlb", id, url });
});
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import { loadGlbMeshDataInWorker } from "./glbMeshWorkerClient.js";
import { meshDataTransferList } from "./meshTransfer.js";
test("GLB mesh worker client falls back when Worker is unavailable", () => {
assert.equal(loadGlbMeshDataInWorker("/mesh.glb"), null);
});
test("mesh transfer list includes unique non-empty typed-array buffers", () => {
const shared = new ArrayBuffer(16);
const transferList = meshDataTransferList({
vertices: new Float32Array(shared),
normals: new Float32Array(shared),
indices: new Uint32Array([0, 1, 2]),
colors: new Float32Array(0),
surfaceEdgeBarycentric: new Float32Array([1, 0, 0]),
surfaceEdgeClass: new Uint8Array([1, 0, 0])
});
assert.equal(transferList.length, 4);
assert.equal(transferList[0], shared);
});
@@ -0,0 +1,68 @@
import {
entryAssetBytes,
entryMeshAssetBytes
} from "../entryAssets.js";
import {
entrySourceFormat,
RENDER_FORMAT
} from "../fileFormats.js";
export const LARGE_STEP_GLB_BYTES = 32 * 1024 * 1024;
export const LARGE_MESH_TRIANGLE_COUNT = 1_000_000;
export const LARGE_MESH_TYPED_ARRAY_BYTES = 160 * 1024 * 1024;
function typedArrayBytes(value) {
return ArrayBuffer.isView(value) ? Number(value.byteLength) || 0 : 0;
}
export function entryStepGlbBytes(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.STEP
? entryAssetBytes(entry, "glb")
: 0;
}
export function hasStepGlbByteCost(entry) {
return entryStepGlbBytes(entry) > 0;
}
export function isLargeStepGlbEntry(entry) {
return entryStepGlbBytes(entry) >= LARGE_STEP_GLB_BYTES;
}
export function isLargeNativeGlbEntry(entry) {
return entrySourceFormat(entry) === RENDER_FORMAT.GLB &&
entryMeshAssetBytes(entry) >= LARGE_STEP_GLB_BYTES;
}
export function shouldUseGlbMeshWorkerForEntry(entry) {
return isLargeStepGlbEntry(entry) || isLargeNativeGlbEntry(entry);
}
export function estimateMeshRenderCost(meshData) {
const indicesTriangleCount = Math.floor((Number(meshData?.indices?.length) || 0) / 3);
const partTriangleCount = Array.isArray(meshData?.parts)
? meshData.parts.reduce((sum, part) => {
const triangleCount = Number(part?.triangleCount);
return sum + (Number.isFinite(triangleCount) && triangleCount > 0 ? triangleCount : 0);
}, 0)
: 0;
return {
triangleCount: Math.max(indicesTriangleCount, partTriangleCount),
typedArrayBytes: [
meshData?.vertices,
meshData?.indices,
meshData?.normals,
meshData?.colors,
meshData?.edge_indices,
meshData?.surfaceEdgeBarycentric,
meshData?.surfaceEdgeClass,
meshData?.guide_line_segments
].reduce((sum, value) => sum + typedArrayBytes(value), 0)
};
}
export function isLargeMeshData(meshData) {
const cost = estimateMeshRenderCost(meshData);
return cost.triangleCount >= LARGE_MESH_TRIANGLE_COUNT ||
cost.typedArrayBytes >= LARGE_MESH_TYPED_ARRAY_BYTES;
}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
estimateMeshRenderCost,
isLargeMeshData,
isLargeNativeGlbEntry,
isLargeStepGlbEntry,
LARGE_MESH_TRIANGLE_COUNT,
LARGE_MESH_TYPED_ARRAY_BYTES,
LARGE_STEP_GLB_BYTES,
shouldUseGlbMeshWorkerForEntry
} from "./meshCost.js";
test("large STEP GLBs are classified from catalog asset bytes", () => {
const smallStep = {
kind: "part",
url: "/parts/.small.step.glb",
hash: "small-hash",
bytes: LARGE_STEP_GLB_BYTES - 1
};
const largeStep = {
kind: "part",
url: "/parts/.large.step.glb",
hash: "large-hash",
bytes: LARGE_STEP_GLB_BYTES
};
const largeNativeGlb = {
kind: "glb",
url: "/meshes/large.glb",
hash: "large-glb-hash",
bytes: LARGE_STEP_GLB_BYTES
};
assert.equal(isLargeStepGlbEntry(smallStep), false);
assert.equal(isLargeStepGlbEntry(largeStep), true);
assert.equal(isLargeNativeGlbEntry(largeNativeGlb), true);
assert.equal(shouldUseGlbMeshWorkerForEntry(largeStep), true);
assert.equal(shouldUseGlbMeshWorkerForEntry(largeNativeGlb), true);
});
test("mesh render cost uses triangle count and typed-array bytes", () => {
const meshData = {
vertices: new Float32Array(9),
normals: new Float32Array(9),
colors: new Float32Array(0),
indices: new Uint32Array(3),
edge_indices: new Uint32Array(2),
surfaceEdgeBarycentric: new Float32Array(9),
surfaceEdgeClass: new Uint8Array(9),
parts: [{ triangleCount: LARGE_MESH_TRIANGLE_COUNT }]
};
assert.deepEqual(estimateMeshRenderCost(meshData), {
triangleCount: LARGE_MESH_TRIANGLE_COUNT,
typedArrayBytes: 137
});
assert.equal(isLargeMeshData(meshData), true);
assert.equal(isLargeMeshData({
vertices: new Uint8Array(LARGE_MESH_TYPED_ARRAY_BYTES),
indices: new Uint32Array(3)
}), true);
});
@@ -0,0 +1,78 @@
import {
renderFormatFromPath,
RENDER_FORMAT
} from "../fileFormats.js";
import {
loadRender3Mf,
loadRenderGlb,
loadRenderStl,
peekRender3Mf,
peekRenderGlb,
peekRenderStl
} from "../renderAssetClient.js";
function meshRenderFormat(format) {
return format === RENDER_FORMAT.STL || format === RENDER_FORMAT.THREE_MF || format === RENDER_FORMAT.GLB
? format
: "";
}
function renderFormatFromMeshAssetUrl(url) {
const directFormat = meshRenderFormat(renderFormatFromPath(url, {
baseUrl: typeof window !== "undefined" ? window.location.href : ""
}));
if (directFormat) {
return directFormat;
}
try {
const parsedUrl = new URL(url, typeof window !== "undefined" ? window.location.href : "http://localhost/");
for (const parameterName of ["file", "path"]) {
const parameterValue = parsedUrl.searchParams.get(parameterName);
const parameterFormat = meshRenderFormat(renderFormatFromPath(parameterValue || "", {
baseUrl: parsedUrl.href
}));
if (parameterFormat) {
return parameterFormat;
}
}
} catch {
// Fall through to the caller-provided fallback.
}
return "";
}
export function meshFormatFromUrl(url) {
return renderFormatFromMeshAssetUrl(url) || RENDER_FORMAT.GLB;
}
function normalizeMeshFallback(fallback) {
return fallback === RENDER_FORMAT.STL || fallback === RENDER_FORMAT.THREE_MF || fallback === RENDER_FORMAT.GLB
? fallback
: RENDER_FORMAT.GLB;
}
export function resolveMeshFormatFromUrl(url, { fallback = RENDER_FORMAT.GLB } = {}) {
return renderFormatFromMeshAssetUrl(url) || normalizeMeshFallback(fallback);
}
export function peekRenderMeshByUrl(url, options = {}) {
const format = resolveMeshFormatFromUrl(url, options);
if (format === RENDER_FORMAT.STL) {
return peekRenderStl(url);
}
if (format === RENDER_FORMAT.THREE_MF) {
return peekRender3Mf(url);
}
return peekRenderGlb(url);
}
export async function loadRenderMeshByUrl(url, options = {}) {
const format = resolveMeshFormatFromUrl(url, options);
if (format === RENDER_FORMAT.STL) {
return loadRenderStl(url, options);
}
if (format === RENDER_FORMAT.THREE_MF) {
return loadRender3Mf(url, options);
}
return loadRenderGlb(url, options);
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { RENDER_FORMAT } from "../fileFormats.js";
import {
meshFormatFromUrl,
resolveMeshFormatFromUrl
} from "./meshLoaders.js";
test("meshFormatFromUrl routes native mesh URLs to the correct loader family", () => {
assert.equal(meshFormatFromUrl("/assets/part.stl"), RENDER_FORMAT.STL);
assert.equal(meshFormatFromUrl("/assets/part.3mf?download=1"), RENDER_FORMAT.THREE_MF);
assert.equal(meshFormatFromUrl("/assets/part.glb"), RENDER_FORMAT.GLB);
assert.equal(meshFormatFromUrl("/assets/part.gltf#scene"), RENDER_FORMAT.GLB);
});
test("meshFormatFromUrl routes CAD asset URLs using the file query extension", () => {
assert.equal(
meshFormatFromUrl("/__cad/asset?file=%2Fworkspace%2Frobots%2Ftom%2F3MF%2Fsts3250.3mf&v=abc123"),
RENDER_FORMAT.THREE_MF
);
assert.equal(
meshFormatFromUrl("/__cad/asset?file=%2Fworkspace%2Frobots%2Ftom%2Fmeshes%2Ftool.glb"),
RENDER_FORMAT.GLB
);
});
test("meshFormatFromUrl keeps the headless GLB fallback by default", () => {
assert.equal(meshFormatFromUrl("/assets/link-mesh.obj"), RENDER_FORMAT.GLB);
assert.equal(meshFormatFromUrl("/assets/link-mesh"), RENDER_FORMAT.GLB);
});
test("resolveMeshFormatFromUrl allows viewer consumers to preserve STL fallback paths", () => {
assert.equal(
resolveMeshFormatFromUrl("/assets/link-mesh.obj", { fallback: RENDER_FORMAT.STL }),
RENDER_FORMAT.STL
);
assert.equal(
resolveMeshFormatFromUrl("/__cad/asset?file=%2Fworkspace%2Frobots%2Ftom%2F3MF%2Fsts3250.3mf", { fallback: RENDER_FORMAT.STL }),
RENDER_FORMAT.THREE_MF
);
});
@@ -0,0 +1,13 @@
export function meshDataTransferList(meshData) {
const buffers = [
meshData?.vertices?.buffer,
meshData?.indices?.buffer,
meshData?.normals?.buffer,
meshData?.colors?.buffer,
meshData?.edge_indices?.buffer,
meshData?.surfaceEdgeBarycentric?.buffer,
meshData?.surfaceEdgeClass?.buffer,
meshData?.guide_line_segments?.buffer
].filter((buffer) => buffer instanceof ArrayBuffer && buffer.byteLength > 0);
return [...new Set(buffers)];
}
@@ -0,0 +1,85 @@
function buildBoundsFromGeometry(geometry) {
geometry.computeBoundingBox();
const min = geometry.boundingBox?.min;
const max = geometry.boundingBox?.max;
if (!min || !max) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
return {
min: [min.x, min.y, min.z],
max: [max.x, max.y, max.z],
};
}
const STL_NORMAL_CREASE_ANGLE = Math.PI / 4;
function buildDisplayGeometryFromStlGeometry(geometry, toCreasedNormals) {
const displayGeometry = geometry.clone();
displayGeometry.deleteAttribute?.("normal");
const normalGeometry = typeof toCreasedNormals === "function"
? toCreasedNormals(displayGeometry, STL_NORMAL_CREASE_ANGLE)
: displayGeometry;
if (normalGeometry !== displayGeometry) {
displayGeometry.dispose?.();
}
if (!normalGeometry.getAttribute("normal")) {
normalGeometry.computeVertexNormals?.();
}
return normalGeometry;
}
function buildGeometryIndices(geometry, vertexCount) {
const indexAttribute = geometry.getIndex?.();
if (indexAttribute?.count > 0) {
return new Uint32Array(indexAttribute.array);
}
const indices = new Uint32Array(vertexCount);
for (let index = 0; index < vertexCount; index += 1) {
indices[index] = index;
}
return indices;
}
export function buildMeshDataFromStlGeometry(geometry, { toCreasedNormals } = {}) {
const displayGeometry = buildDisplayGeometryFromStlGeometry(geometry, toCreasedNormals);
const positions = geometry.getAttribute("position");
const displayPositions = displayGeometry.getAttribute("position");
const displayNormals = displayGeometry.getAttribute("normal");
try {
if (!positions || positions.itemSize !== 3 || !displayPositions || displayPositions.itemSize !== 3) {
throw new Error("STL geometry is missing vertex positions");
}
const vertexCount = displayPositions.count;
return {
vertices: new Float32Array(displayPositions.array),
indices: buildGeometryIndices(displayGeometry, vertexCount),
normals: displayNormals?.itemSize === 3 ? new Float32Array(displayNormals.array) : new Float32Array(displayPositions.array.length),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromGeometry(geometry),
parts: [],
has_source_colors: false,
sourceColor: "",
sourceFormat: "stl",
};
} finally {
displayGeometry.dispose?.();
}
}
export async function buildMeshDataFromStlBuffer(buffer) {
const [{ STLLoader }, { toCreasedNormals }] = await Promise.all([
import("three/examples/jsm/loaders/STLLoader.js"),
import("three/examples/jsm/utils/BufferGeometryUtils.js"),
]);
const loader = new STLLoader();
const geometry = loader.parse(buffer);
try {
return buildMeshDataFromStlGeometry(geometry, { toCreasedNormals });
} finally {
geometry.dispose?.();
}
}
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";
import { toCreasedNormals } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { buildMeshDataFromStlGeometry } from "./stlMeshData.js";
test("buildMeshDataFromStlGeometry recomputes crease-aware STL display normals", () => {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute([
0, 0, 0,
1, 0, 0,
1, 1, 0,
0, 0, 0,
1, 1, 0,
0, 1, 0
], 3)
);
geometry.setAttribute(
"normal",
new THREE.Float32BufferAttribute([
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1
], 3)
);
const meshData = buildMeshDataFromStlGeometry(geometry, { toCreasedNormals });
assert.equal(meshData.vertices.length, 18);
assert.equal(meshData.indices.length, 6);
assert.deepEqual([...meshData.normals.slice(0, 3)], [0, 0, 1]);
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceFormat, "stl");
});
@@ -0,0 +1,61 @@
import { buildMeshDataFromStlBuffer } from "./stlMeshData.js";
import { meshDataTransferList } from "./meshTransfer.js";
const activeControllers = new Map();
function fetchError(url, response) {
return new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
async function loadArrayBuffer(url, signal) {
const response = await fetch(url, { signal });
if (!response.ok) {
throw fetchError(url, response);
}
return response.arrayBuffer();
}
self.addEventListener("message", async (event) => {
const message = event.data || {};
const id = message.id;
if (!id) {
return;
}
if (message.type === "cancel") {
activeControllers.get(id)?.abort();
activeControllers.delete(id);
return;
}
if (message.type !== "loadStl") {
return;
}
const controller = new AbortController();
activeControllers.set(id, controller);
try {
const buffer = await loadArrayBuffer(message.url, controller.signal);
const meshData = await buildMeshDataFromStlBuffer(buffer);
if (controller.signal.aborted) {
return;
}
self.postMessage(
{ id, ok: true, meshData },
meshDataTransferList(meshData)
);
} catch (error) {
if (!controller.signal.aborted) {
self.postMessage({
id,
ok: false,
error: {
name: error?.name || "Error",
message: error instanceof Error ? error.message : String(error)
}
});
}
} finally {
activeControllers.delete(id);
}
});
@@ -0,0 +1,94 @@
let stlWorker = null;
let nextRequestId = 1;
const pendingRequests = new Map();
function makeAbortError() {
if (typeof DOMException === "function") {
return new DOMException("The operation was aborted.", "AbortError");
}
const error = new Error("The operation was aborted.");
error.name = "AbortError";
return error;
}
function stlWorkerSupported() {
return typeof Worker === "function" && typeof URL === "function";
}
function rejectPendingRequests(error) {
for (const request of pendingRequests.values()) {
request.cleanup();
request.reject(error);
}
pendingRequests.clear();
}
function ensureStlWorker() {
if (!stlWorkerSupported()) {
return null;
}
if (stlWorker) {
return stlWorker;
}
try {
stlWorker = new Worker(new URL("./stlMeshWorker.js", import.meta.url), { type: "module" });
} catch {
stlWorker = null;
return null;
}
stlWorker.addEventListener("message", (event) => {
const message = event.data || {};
const request = pendingRequests.get(message.id);
if (!request) {
return;
}
pendingRequests.delete(message.id);
request.cleanup();
if (message.ok) {
request.resolve(message.meshData);
return;
}
const error = new Error(message.error?.message || "Failed to load STL mesh in worker.");
error.name = message.error?.name || "Error";
request.reject(error);
});
stlWorker.addEventListener("error", (event) => {
const error = new Error(event?.message || "STL mesh worker failed.");
rejectPendingRequests(error);
stlWorker?.terminate?.();
stlWorker = null;
});
return stlWorker;
}
export function loadStlMeshDataInWorker(url, { signal } = {}) {
const worker = ensureStlWorker();
if (!worker) {
return null;
}
if (signal?.aborted) {
return Promise.reject(makeAbortError());
}
const id = nextRequestId;
nextRequestId += 1;
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener?.("abort", abort);
};
const abort = () => {
pendingRequests.delete(id);
cleanup();
worker.postMessage({ type: "cancel", id });
reject(makeAbortError());
};
pendingRequests.set(id, {
resolve,
reject,
cleanup
});
signal?.addEventListener?.("abort", abort, { once: true });
worker.postMessage({ type: "loadStl", id, url });
});
}
@@ -0,0 +1,597 @@
const GENERATED_DEFAULT_MATERIAL_NAMES = new Set(["default"]);
const GENERATED_DEFAULT_MATERIAL_COLORS = new Set(["#fafafb", "#b6c4ce"]);
const WORKBENCH_DEFAULT_MATERIAL_COLOR = Object.freeze({
rgb: [0.4677837961, 0.5520114015, 0.6172065624],
hex: "#b6c4ce",
});
function buildBoundsFromVertices(vertices) {
if (!vertices?.length) {
return {
min: [0, 0, 0],
max: [0, 0, 0],
};
}
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
for (let index = 0; index + 2 < vertices.length; index += 3) {
const x = Number(vertices[index]);
const y = Number(vertices[index + 1]);
const z = Number(vertices[index + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
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: [0, 0, 0],
};
}
return { min, max };
}
function normalize3MfPackagePath(value = "", basePath = "") {
const rawValue = String(value || "").trim().replace(/\\/g, "/");
if (!rawValue) {
return "";
}
if (rawValue.startsWith("/")) {
return rawValue.replace(/^\/+/, "");
}
const baseParts = String(basePath || "").split("/");
baseParts.pop();
const parts = [...baseParts, ...rawValue.split("/")];
const normalized = [];
for (const part of parts) {
if (!part || part === ".") {
continue;
}
if (part === "..") {
normalized.pop();
continue;
}
normalized.push(part);
}
return normalized.join("/");
}
function elementChildren(node, localName) {
return Array.from(node?.children || []).filter((child) => child.localName === localName);
}
function firstElementChild(node, localName) {
return elementChildren(node, localName)[0] || null;
}
function attributeByLocalName(node, localName) {
if (!node?.attributes) {
return "";
}
const direct = node.getAttribute?.(localName);
if (direct) {
return direct;
}
for (const attribute of Array.from(node.attributes)) {
if (attribute.localName === localName || String(attribute.name || "").split(":").pop() === localName) {
return attribute.value || "";
}
}
return "";
}
function parseNumber(value, fallback = 0) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : fallback;
}
function parseIndex(value) {
const numericValue = Number(value);
return Number.isInteger(numericValue) && numericValue >= 0 ? numericValue : -1;
}
function parse3MfXml(text, label) {
if (typeof DOMParser !== "function") {
throw new Error("3MF XML parsing is unavailable in this environment");
}
const document = new DOMParser().parseFromString(text, "application/xml");
const parseError = document.querySelector?.("parsererror");
if (parseError) {
throw new Error(`Invalid 3MF XML: ${label}`);
}
return document;
}
function parse3MfTransform(THREE, value) {
const transformValues = String(value || "")
.trim()
.split(/\s+/)
.map((item) => Number(item));
if (transformValues.length !== 12 || transformValues.some((item) => !Number.isFinite(item))) {
return null;
}
const matrix = new THREE.Matrix4();
matrix.set(
transformValues[0], transformValues[3], transformValues[6], transformValues[9],
transformValues[1], transformValues[4], transformValues[7], transformValues[10],
transformValues[2], transformValues[5], transformValues[8], transformValues[11],
0, 0, 0, 1
);
return matrix;
}
function parse3MfMeshNode(meshNode) {
const vertices = [];
const verticesNode = firstElementChild(meshNode, "vertices");
for (const vertexNode of elementChildren(verticesNode, "vertex")) {
vertices.push([
parseNumber(vertexNode.getAttribute("x")),
parseNumber(vertexNode.getAttribute("y")),
parseNumber(vertexNode.getAttribute("z")),
]);
}
const triangles = [];
const trianglesNode = firstElementChild(meshNode, "triangles");
for (const triangleNode of elementChildren(trianglesNode, "triangle")) {
const triangle = [
parseIndex(triangleNode.getAttribute("v1")),
parseIndex(triangleNode.getAttribute("v2")),
parseIndex(triangleNode.getAttribute("v3")),
];
if (triangle.every((index) => index >= 0)) {
triangles.push(triangle);
}
}
return { vertices, triangles };
}
function parse3MfModelPart(THREE, xmlText, modelPath) {
const document = parse3MfXml(xmlText, modelPath);
const modelNode = document.documentElement?.localName === "model"
? document.documentElement
: document.querySelector?.("model");
if (!modelNode) {
throw new Error(`3MF model part is missing a model node: ${modelPath}`);
}
const resourcesNode = firstElementChild(modelNode, "resources");
const objects = new Map();
for (const objectNode of elementChildren(resourcesNode, "object")) {
const id = String(objectNode.getAttribute("id") || "").trim();
if (!id) {
continue;
}
const name = String(objectNode.getAttribute("name") || objectNode.getAttribute("partnumber") || "").trim();
const meshNode = firstElementChild(objectNode, "mesh");
const componentsNode = firstElementChild(objectNode, "components");
const components = [];
for (const componentNode of elementChildren(componentsNode, "component")) {
const objectId = String(componentNode.getAttribute("objectid") || "").trim();
if (!objectId) {
continue;
}
components.push({
objectId,
path: normalize3MfPackagePath(attributeByLocalName(componentNode, "path"), modelPath),
transform: parse3MfTransform(THREE, componentNode.getAttribute("transform")),
});
}
objects.set(id, {
id,
name,
mesh: meshNode ? parse3MfMeshNode(meshNode) : null,
components,
});
}
const build = [];
const buildNode = firstElementChild(modelNode, "build");
for (const itemNode of elementChildren(buildNode, "item")) {
const objectId = String(itemNode.getAttribute("objectid") || "").trim();
if (!objectId) {
continue;
}
build.push({
objectId,
transform: parse3MfTransform(THREE, itemNode.getAttribute("transform")),
});
}
return {
path: modelPath,
objects,
build,
};
}
function parse3MfRelationships(xmlText, relsPath) {
const document = parse3MfXml(xmlText, relsPath);
return Array.from(document.querySelectorAll?.("Relationship") || [])
.map((node) => ({
target: normalize3MfPackagePath(node.getAttribute("Target")),
type: String(node.getAttribute("Type") || "").trim(),
}))
.filter((relationship) => relationship.target);
}
function colorFromMaterial(material) {
if (!material?.color) {
return null;
}
const hex = `#${material.color.getHexString()}`.toLowerCase();
const materialName = String(material.name || "").trim().toLowerCase();
const generatedDefault = GENERATED_DEFAULT_MATERIAL_NAMES.has(materialName) &&
GENERATED_DEFAULT_MATERIAL_COLORS.has(hex);
if (generatedDefault) {
return {
rgb: WORKBENCH_DEFAULT_MATERIAL_COLOR.rgb,
hex: WORKBENCH_DEFAULT_MATERIAL_COLOR.hex,
generatedDefault: true,
};
}
return {
rgb: [material.color.r, material.color.g, material.color.b],
hex,
generatedDefault: false,
};
}
function materialForGroup(material, group) {
if (Array.isArray(material)) {
const materialIndex = Number.isInteger(group?.materialIndex) ? group.materialIndex : 0;
return material[materialIndex] || material[0] || null;
}
return material || null;
}
function appendMeshPrimitive(THREE, accumulator, mesh, group, material) {
const geometry = mesh?.geometry;
const positions = geometry?.getAttribute?.("position");
if (!positions || positions.itemSize !== 3 || positions.count <= 0) {
return;
}
if (!geometry.getAttribute("normal")) {
geometry.computeVertexNormals?.();
}
mesh.updateWorldMatrix?.(true, false);
const matrixWorld = mesh.matrixWorld || null;
const normalMatrix = matrixWorld ? new THREE.Matrix3().getNormalMatrix(matrixWorld) : null;
const normals = geometry.getAttribute("normal");
const indexAttribute = geometry.getIndex?.();
const sourceStart = Math.max(0, Math.floor(Number(group?.start || 0)));
const availableCount = indexAttribute?.count || positions.count;
const rawCount = Math.floor(Number(group?.count || (availableCount - sourceStart)));
const sourceCount = Math.max(0, Math.min(rawCount, availableCount - sourceStart));
const triangleVertexCount = sourceCount - (sourceCount % 3);
if (triangleVertexCount <= 0) {
return;
}
const color = colorFromMaterial(material);
const positionVector = new THREE.Vector3();
const normalVector = new THREE.Vector3();
const vertexOffset = Math.floor(accumulator.vertices.length / 3);
const triangleOffset = Math.floor(accumulator.indices.length / 3);
const partVertices = [];
for (let localIndex = 0; localIndex < triangleVertexCount; localIndex += 1) {
const sourceSlot = sourceStart + localIndex;
const sourceIndex = indexAttribute ? indexAttribute.getX(sourceSlot) : sourceSlot;
if (sourceIndex < 0 || sourceIndex >= positions.count) {
continue;
}
const outputIndex = Math.floor(partVertices.length / 3);
positionVector.set(
positions.getX(sourceIndex),
positions.getY(sourceIndex),
positions.getZ(sourceIndex)
);
if (matrixWorld) {
positionVector.applyMatrix4(matrixWorld);
}
accumulator.vertices.push(positionVector.x, positionVector.y, positionVector.z);
partVertices.push(positionVector.x, positionVector.y, positionVector.z);
if (normals?.itemSize === 3 && sourceIndex < normals.count) {
normalVector.set(normals.getX(sourceIndex), normals.getY(sourceIndex), normals.getZ(sourceIndex));
if (normalMatrix) {
normalVector.applyMatrix3(normalMatrix).normalize();
}
accumulator.normals.push(normalVector.x, normalVector.y, normalVector.z);
} else {
accumulator.normals.push(0, 0, 0);
}
if (color) {
accumulator.colors.push(color.rgb[0], color.rgb[1], color.rgb[2]);
}
accumulator.indices.push(vertexOffset + outputIndex);
}
const vertexCount = Math.floor(partVertices.length / 3);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return;
}
const label = String(mesh?.name || mesh?.parent?.name || `3mf:${accumulator.parts.length}`).trim();
const id = `3mf:${accumulator.parts.length}`;
accumulator.parts.push({
id,
occurrenceId: id,
name: label || id,
label: label || id,
nodeType: "part",
color: color && !color.generatedDefault ? color.hex : "",
bounds: buildBoundsFromVertices(partVertices),
vertexOffset,
vertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
});
if (color?.hex && !color.generatedDefault) {
accumulator.colorSet.add(color.hex.toLowerCase());
}
}
export function buildMeshDataFromThreeMfGroup(THREE, group) {
const accumulator = {
vertices: [],
indices: [],
normals: [],
colors: [],
parts: [],
colorSet: new Set(),
};
group?.updateWorldMatrix?.(true, true);
group?.traverse?.((object) => {
if (object?.isMesh && object.geometry) {
const groups = Array.isArray(object.geometry.groups) && object.geometry.groups.length
? object.geometry.groups
: [null];
for (const geometryGroup of groups) {
appendMeshPrimitive(THREE, accumulator, object, geometryGroup, materialForGroup(object.material, geometryGroup));
}
}
});
const vertices = new Float32Array(accumulator.vertices);
const rawColors = accumulator.colors.length === accumulator.vertices.length
? new Float32Array(accumulator.colors)
: new Float32Array(0);
const hasSourceColors = rawColors.length === vertices.length && rawColors.length > 0 && accumulator.colorSet.size > 0;
return {
vertices,
indices: new Uint32Array(accumulator.indices),
normals: new Float32Array(accumulator.normals),
colors: hasSourceColors ? rawColors : new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromVertices(vertices),
parts: accumulator.parts,
has_source_colors: hasSourceColors,
sourceColor: accumulator.colorSet.size === 1 ? [...accumulator.colorSet][0] : "",
};
}
function appendParsedMeshPart(THREE, accumulator, meshData, matrix, label) {
if (!meshData?.vertices?.length || !meshData?.triangles?.length) {
return;
}
const vertexOffset = Math.floor(accumulator.vertices.length / 3);
const triangleOffset = Math.floor(accumulator.indices.length / 3);
const positionVector = new THREE.Vector3();
const transformedTriangle = [
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
];
const edgeA = new THREE.Vector3();
const edgeB = new THREE.Vector3();
const normal = new THREE.Vector3();
const partVertices = [];
for (const triangle of meshData.triangles) {
const sourceVertices = triangle.map((index) => meshData.vertices[index]);
if (sourceVertices.some((vertex) => !vertex)) {
continue;
}
for (let index = 0; index < 3; index += 1) {
const vertex = sourceVertices[index];
transformedTriangle[index].set(vertex[0], vertex[1], vertex[2]).applyMatrix4(matrix);
}
edgeA.subVectors(transformedTriangle[1], transformedTriangle[0]);
edgeB.subVectors(transformedTriangle[2], transformedTriangle[0]);
normal.crossVectors(edgeA, edgeB).normalize();
if (!Number.isFinite(normal.x) || !Number.isFinite(normal.y) || !Number.isFinite(normal.z)) {
normal.set(0, 0, 0);
}
for (const transformedVertex of transformedTriangle) {
positionVector.copy(transformedVertex);
const outputIndex = Math.floor(partVertices.length / 3);
accumulator.vertices.push(positionVector.x, positionVector.y, positionVector.z);
partVertices.push(positionVector.x, positionVector.y, positionVector.z);
accumulator.normals.push(normal.x, normal.y, normal.z);
accumulator.indices.push(vertexOffset + outputIndex);
}
}
const vertexCount = Math.floor(partVertices.length / 3);
const triangleCount = Math.floor(vertexCount / 3);
if (vertexCount <= 0 || triangleCount <= 0) {
return;
}
const id = `3mf:${accumulator.parts.length}`;
const partLabel = String(label || id).trim() || id;
accumulator.parts.push({
id,
occurrenceId: id,
name: partLabel,
label: partLabel,
nodeType: "part",
color: "",
bounds: buildBoundsFromVertices(partVertices),
vertexOffset,
vertexCount,
triangleOffset,
triangleCount,
edgeIndexOffset: 0,
edgeIndexCount: 0,
});
}
function findObjectInAnyModel(models, objectId) {
for (const [modelPath, model] of models) {
if (model.objects.has(objectId)) {
return { modelPath, objectData: model.objects.get(objectId) };
}
}
return null;
}
function appendParsed3MfObject(THREE, accumulator, models, modelPath, objectId, matrix, stack = []) {
const stackKey = `${modelPath}#${objectId}`;
if (stack.includes(stackKey)) {
throw new Error(`3MF component cycle detected at ${stackKey}`);
}
const model = models.get(modelPath);
let objectData = model?.objects?.get(objectId) || null;
let resolvedModelPath = modelPath;
if (!objectData) {
const fallback = findObjectInAnyModel(models, objectId);
objectData = fallback?.objectData || null;
resolvedModelPath = fallback?.modelPath || modelPath;
}
if (!objectData) {
throw new Error(`3MF object ${objectId} is missing from ${modelPath}`);
}
if (objectData.mesh) {
appendParsedMeshPart(
THREE,
accumulator,
objectData.mesh,
matrix,
objectData.name || `${resolvedModelPath}#${objectData.id}`
);
return;
}
for (const component of objectData.components || []) {
const componentPath = component.path || resolvedModelPath;
const componentMatrix = matrix.clone();
if (component.transform) {
componentMatrix.multiply(component.transform);
}
appendParsed3MfObject(
THREE,
accumulator,
models,
componentPath,
component.objectId,
componentMatrix,
[...stack, stackKey]
);
}
}
function finalizeParsed3MfMesh(accumulator) {
const vertices = new Float32Array(accumulator.vertices);
return {
vertices,
indices: new Uint32Array(accumulator.indices),
normals: new Float32Array(accumulator.normals),
colors: new Float32Array(0),
edge_indices: new Uint32Array(0),
bounds: buildBoundsFromVertices(vertices),
parts: accumulator.parts,
has_source_colors: false,
sourceColor: "",
};
}
async function buildMeshDataFrom3MfPackageFallback(THREE, buffer) {
const fflate = await import("three/examples/jsm/libs/fflate.module.js");
const textDecoder = new TextDecoder();
const zipEntries = fflate.unzipSync(new Uint8Array(buffer));
const textEntry = (entryPath) => {
const normalizedPath = normalize3MfPackagePath(entryPath);
const payload = zipEntries[normalizedPath];
return payload ? textDecoder.decode(payload) : "";
};
const modelPaths = Object.keys(zipEntries)
.filter((entryPath) => /^3D\/.*\.model$/i.test(entryPath))
.sort((left, right) => {
const leftRoot = /\/3dmodel\.model$/i.test(left) ? 1 : 0;
const rightRoot = /\/3dmodel\.model$/i.test(right) ? 1 : 0;
return leftRoot - rightRoot || left.localeCompare(right);
});
if (!modelPaths.length) {
throw new Error("3MF package does not contain any 3D model parts");
}
const models = new Map();
for (const modelPath of modelPaths) {
const xmlText = textEntry(modelPath);
if (xmlText) {
models.set(modelPath, parse3MfModelPart(THREE, xmlText, modelPath));
}
}
const relationshipsText = textEntry("_rels/.rels");
const rootModelPath = relationshipsText
? parse3MfRelationships(relationshipsText, "_rels/.rels")
.find((relationship) => /\.model$/i.test(relationship.target))?.target
: "";
const buildModelPath = models.has(rootModelPath) ? rootModelPath : modelPaths.find((modelPath) => models.get(modelPath)?.build?.length) || modelPaths[0];
const buildModel = models.get(buildModelPath);
if (!buildModel) {
throw new Error("3MF package does not contain a readable build model");
}
const identity = new THREE.Matrix4();
const accumulator = {
vertices: [],
indices: [],
normals: [],
parts: [],
};
const buildItems = buildModel.build.length
? buildModel.build
: Array.from(buildModel.objects.keys()).map((objectId) => ({ objectId, transform: null }));
for (const buildItem of buildItems) {
const matrix = buildItem.transform ? buildItem.transform.clone() : identity.clone();
appendParsed3MfObject(THREE, accumulator, models, buildModelPath, buildItem.objectId, matrix);
}
const meshData = finalizeParsed3MfMesh(accumulator);
if (!meshData.indices.length) {
throw new Error("3MF package did not produce any triangles");
}
return meshData;
}
export async function buildMeshDataFrom3MfBuffer(buffer) {
const [THREE, { ThreeMFLoader }] = await Promise.all([
import("three"),
import("three/examples/jsm/loaders/3MFLoader.js"),
]);
const loader = new ThreeMFLoader();
try {
return buildMeshDataFromThreeMfGroup(THREE, loader.parse(buffer));
} catch (error) {
try {
return await buildMeshDataFrom3MfPackageFallback(THREE, buffer);
} catch {
throw error;
}
}
}
@@ -0,0 +1,272 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMeshDataFromThreeMfGroup } from "./threeMfMeshData.js";
class FakeVector3 {
set(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
return this;
}
applyMatrix4() {
return this;
}
applyMatrix3() {
return this;
}
normalize() {
return this;
}
}
class FakeMatrix3 {
getNormalMatrix() {
return this;
}
}
function fakeAttribute(values) {
return {
itemSize: 3,
count: values.length / 3,
getX: (index) => values[index * 3],
getY: (index) => values[index * 3 + 1],
getZ: (index) => values[index * 3 + 2],
};
}
function assertArrayAlmostEqual(actual, expected, epsilon = 1e-6) {
assert.equal(actual.length, expected.length);
for (let index = 0; index < actual.length; index += 1) {
assert.ok(
Math.abs(Number(actual[index]) - Number(expected[index])) <= epsilon,
`index ${index}: expected ${expected[index]}, got ${actual[index]}`
);
}
}
test("buildMeshDataFromThreeMfGroup normalizes 3MF meshes to viewer mesh data", () => {
const mesh = {
isMesh: true,
name: "fixture",
material: null,
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0]);
}
if (name === "normal") {
return fakeAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assert.deepEqual([...meshData.vertices], [0, 0, 0, 1, 0, 0, 0, 1, 0]);
assert.deepEqual([...meshData.indices], [0, 1, 2]);
assert.deepEqual([...meshData.normals], [0, 0, 1, 0, 0, 1, 0, 0, 1]);
assert.deepEqual([...meshData.colors], []);
assert.deepEqual(meshData.bounds, { min: [0, 0, 0], max: [1, 1, 0] });
assert.equal(meshData.parts.length, 1);
assert.equal(meshData.parts[0].name, "fixture");
assert.equal(meshData.has_source_colors, false);
});
test("buildMeshDataFromThreeMfGroup preserves 3MF material colors", () => {
const mesh = {
isMesh: true,
name: "colored fixture",
material: {
color: {
r: 0.25,
g: 0.5,
b: 0.75,
getHexString: () => "4080bf",
},
},
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0]);
}
if (name === "normal") {
return fakeAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assert.deepEqual([...meshData.colors], [0.25, 0.5, 0.75, 0.25, 0.5, 0.75, 0.25, 0.5, 0.75]);
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#4080bf");
assert.equal(meshData.parts[0].color, "#4080bf");
});
test("buildMeshDataFromThreeMfGroup treats generated default material as uncolored", () => {
const mesh = {
isMesh: true,
name: "default fixture",
material: {
name: "Default",
color: {
r: 0.4677837961,
g: 0.5520114015,
b: 0.6172065624,
getHexString: () => "b6c4ce",
},
},
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0]);
}
if (name === "normal") {
return fakeAttribute([0, 0, 1, 0, 0, 1, 0, 0, 1]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assert.deepEqual([...meshData.colors], []);
assert.equal(meshData.has_source_colors, false);
assert.equal(meshData.sourceColor, "");
assert.equal(meshData.parts[0].color, "");
});
test("buildMeshDataFromThreeMfGroup keeps authored colors when mixed with generated defaults", () => {
const defaultRgb = [0.4677837961, 0.5520114015, 0.6172065624];
const mesh = {
isMesh: true,
name: "mixed fixture",
material: [
{
name: "Default",
color: {
r: defaultRgb[0],
g: defaultRgb[1],
b: defaultRgb[2],
getHexString: () => "b6c4ce",
},
},
{
name: "red",
color: {
r: 1,
g: 0,
b: 0,
getHexString: () => "ff0000",
},
},
],
matrixWorld: null,
updateWorldMatrix() {},
geometry: {
groups: [
{ start: 0, count: 3, materialIndex: 0 },
{ start: 3, count: 3, materialIndex: 1 },
],
getIndex: () => null,
getAttribute: (name) => {
if (name === "position") {
return fakeAttribute([
0, 0, 0,
1, 0, 0,
0, 1, 0,
1, 0, 0,
1, 1, 0,
0, 1, 0,
]);
}
if (name === "normal") {
return fakeAttribute([
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
0, 0, 1,
]);
}
return null;
},
computeVertexNormals() {},
},
};
const group = {
updateWorldMatrix() {},
traverse(callback) {
callback(mesh);
},
};
const meshData = buildMeshDataFromThreeMfGroup(
{ Matrix3: FakeMatrix3, Vector3: FakeVector3 },
group
);
assertArrayAlmostEqual([...meshData.colors], [
...defaultRgb,
...defaultRgb,
...defaultRgb,
1, 0, 0,
1, 0, 0,
1, 0, 0,
]);
assert.equal(meshData.has_source_colors, true);
assert.equal(meshData.sourceColor, "#ff0000");
assert.equal(meshData.parts[0].color, "");
assert.equal(meshData.parts[1].color, "#ff0000");
});

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