refactor: add DesignIR distillation pipeline
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
export type AiSelectionMode = "none" | "point" | "lasso";
|
||||
|
||||
export type CadEditTool = {
|
||||
id: string;
|
||||
label: string;
|
||||
pickKinds: Array<"surface_point" | "edge">;
|
||||
parameters: Record<string, string | number>;
|
||||
parameterFields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: "number" | "select" | "string";
|
||||
unit?: string;
|
||||
min?: number;
|
||||
step?: number;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const CAD_EDIT_TOOLS: CadEditTool[] = [
|
||||
{
|
||||
id: "add_hole",
|
||||
label: "打孔",
|
||||
pickKinds: ["surface_point"],
|
||||
parameters: { holeDiameter: 2, depth: "through", direction: "face_normal" },
|
||||
parameterFields: [
|
||||
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_counterbore",
|
||||
label: "沉孔",
|
||||
pickKinds: ["surface_point"],
|
||||
parameters: {
|
||||
holeDiameter: 3,
|
||||
counterboreDiameter: 6,
|
||||
counterboreDepth: 2,
|
||||
depth: "through",
|
||||
direction: "face_normal",
|
||||
},
|
||||
parameterFields: [
|
||||
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "counterboreDiameter", label: "沉孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "counterboreDepth", label: "沉孔深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_countersink",
|
||||
label: "锥沉孔",
|
||||
pickKinds: ["surface_point"],
|
||||
parameters: {
|
||||
holeDiameter: 3,
|
||||
countersinkDiameter: 6,
|
||||
countersinkAngleDeg: 90,
|
||||
depth: "through",
|
||||
direction: "face_normal",
|
||||
},
|
||||
parameterFields: [
|
||||
{ name: "holeDiameter", label: "底孔直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "countersinkDiameter", label: "锥口直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "countersinkAngleDeg", label: "锥角", type: "number", unit: "deg", min: 1, step: 1 },
|
||||
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_slot",
|
||||
label: "开槽",
|
||||
pickKinds: ["surface_point", "surface_point"],
|
||||
parameters: { slotWidth: 2, depth: "through", direction: "face_normal" },
|
||||
parameterFields: [
|
||||
{ name: "slotWidth", label: "槽宽", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_pocket",
|
||||
label: "铣型腔",
|
||||
pickKinds: ["surface_point"],
|
||||
parameters: { shape: "rectangle", width: 10, height: 6, depth: 1, direction: "face_normal", orientation: "surface_xy" },
|
||||
parameterFields: [
|
||||
{ name: "width", label: "宽度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "height", label: "高度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_circular_pocket",
|
||||
label: "圆形型腔",
|
||||
pickKinds: ["surface_point"],
|
||||
parameters: { diameter: 8, depth: 1, direction: "face_normal" },
|
||||
parameterFields: [
|
||||
{ name: "diameter", label: "直径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "depth", label: "深度", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_hole_pattern",
|
||||
label: "阵列孔",
|
||||
pickKinds: ["surface_point"],
|
||||
parameters: {
|
||||
holeDiameter: 2,
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
pitchX: 8,
|
||||
pitchY: 8,
|
||||
depth: "through",
|
||||
direction: "face_normal",
|
||||
orientation: "surface_xy",
|
||||
},
|
||||
parameterFields: [
|
||||
{ name: "holeDiameter", label: "孔径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "rows", label: "行数", type: "number", min: 1, step: 1 },
|
||||
{ name: "columns", label: "列数", type: "number", min: 1, step: 1 },
|
||||
{ name: "pitchX", label: "X 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "pitchY", label: "Y 间距", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
{ name: "depth", label: "深度", type: "select", options: ["through", "blind"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_chamfer",
|
||||
label: "倒角",
|
||||
pickKinds: ["edge"],
|
||||
parameters: { distance: 1 },
|
||||
parameterFields: [
|
||||
{ name: "distance", label: "倒角距离", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "add_fillet",
|
||||
label: "圆角",
|
||||
pickKinds: ["edge"],
|
||||
parameters: { radius: 1 },
|
||||
parameterFields: [
|
||||
{ name: "radius", label: "圆角半径", type: "number", unit: "mm", min: 0.01, step: 0.1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function cadEditToolForOperation(operation: string) {
|
||||
return CAD_EDIT_TOOLS.find((tool) => tool.id === operation) || CAD_EDIT_TOOLS[0];
|
||||
}
|
||||
|
||||
export function cadEditToolPickKind(operation: string) {
|
||||
return cadEditToolForOperation(operation).pickKinds[0] || "surface_point";
|
||||
}
|
||||
|
||||
export function cadEditToolNextPickKind(operation: string, pickCount: number) {
|
||||
const pickKinds = cadEditToolForOperation(operation).pickKinds;
|
||||
return pickKinds[Math.min(Math.max(Math.floor(pickCount), 0), pickKinds.length - 1)] || "surface_point";
|
||||
}
|
||||
|
||||
export function cadEditToolPickComplete(operation: string, picks: unknown[]) {
|
||||
return picks.length >= cadEditToolForOperation(operation).pickKinds.length;
|
||||
}
|
||||
|
||||
export function defaultCadEditParameters(operation: string) {
|
||||
return { ...cadEditToolForOperation(operation).parameters };
|
||||
}
|
||||
|
||||
export function buildEmbeddedCadEditIntent({
|
||||
operation,
|
||||
pick,
|
||||
parameters,
|
||||
artifactPath,
|
||||
taskId,
|
||||
}: {
|
||||
operation: string;
|
||||
pick: Record<string, unknown>;
|
||||
parameters?: Record<string, unknown>;
|
||||
artifactPath: string;
|
||||
taskId: string;
|
||||
}) {
|
||||
const tool = cadEditToolForOperation(operation);
|
||||
return {
|
||||
schema: "cad-edit-intent.v1",
|
||||
operation: tool.id,
|
||||
source: {
|
||||
entryFile: artifactPath,
|
||||
stepPath: artifactPath,
|
||||
sourcePath: "",
|
||||
backend: "agent-studio",
|
||||
units: "mm",
|
||||
taskId,
|
||||
},
|
||||
selection: pick,
|
||||
parameters: {
|
||||
...defaultCadEditParameters(tool.id),
|
||||
...(parameters || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEmbeddedGeometrySelectionIntent({
|
||||
selection,
|
||||
artifactPath,
|
||||
taskId,
|
||||
}: {
|
||||
selection: Record<string, unknown>;
|
||||
artifactPath: string;
|
||||
taskId: string;
|
||||
}) {
|
||||
return {
|
||||
schema: "cad-ai-geometry-selection.v1",
|
||||
intent: "modify_selected_geometry",
|
||||
source: {
|
||||
entryFile: artifactPath,
|
||||
stepPath: artifactPath,
|
||||
sourcePath: "",
|
||||
backend: "agent-studio",
|
||||
units: "mm",
|
||||
taskId,
|
||||
},
|
||||
selection,
|
||||
instruction: "用户通过 CAD Agent Studio 内置预览指定了要修改的几何区域。请结合 selection 中的屏幕坐标、近似模型坐标和当前视角理解位置,再修改可编辑源文件并重新生成 STEP 与预览资产。",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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")}"}`;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user