merge
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/Users/lk/Projects/cadSet/text-to-cad/skills/cad-router
|
||||||
+2
-5
@@ -13,15 +13,12 @@
|
|||||||
**/node_modules/
|
**/node_modules/
|
||||||
**/.next/
|
**/.next/
|
||||||
**/.cache/
|
**/.cache/
|
||||||
|
models/
|
||||||
|
|
||||||
# Undownloaded upstream Git LFS demo/catalog pointers
|
# Undownloaded upstream Git LFS demo/catalog pointers
|
||||||
text-to-cad/assets/
|
text-to-cad/assets/
|
||||||
text-to-cad/benchmarks/*.gif
|
text-to-cad/benchmarks/*.gif
|
||||||
text-to-cad/models/benchmarks/
|
text-to-cad/models/
|
||||||
text-to-cad/models/fun/
|
|
||||||
text-to-cad/models/mechanisms/
|
|
||||||
text-to-cad/models/robots/
|
|
||||||
text-to-cad/models/simple/
|
|
||||||
|
|
||||||
# Local editors and temporary files
|
# Local editors and temporary files
|
||||||
**/.idea/
|
**/.idea/
|
||||||
|
|||||||
+60
-3692
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ htmlcov/
|
|||||||
.agents/
|
.agents/
|
||||||
.vite
|
.vite
|
||||||
node_modules
|
node_modules
|
||||||
|
.next
|
||||||
dist
|
dist
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ coverage
|
|||||||
*.step.glb.lock
|
*.step.glb.lock
|
||||||
*.stp.glb.lock
|
*.stp.glb.lock
|
||||||
tmp/
|
tmp/
|
||||||
|
models/
|
||||||
|
models/agent-studio/
|
||||||
|
|
||||||
# Local printer credentials
|
# Local printer credentials
|
||||||
/bambu-printers.json
|
/bambu-printers.json
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# CAD Agent Studio
|
||||||
|
|
||||||
|
CAD Agent Studio is a two-pane web app for agent-driven CAD work:
|
||||||
|
|
||||||
|
- left pane: agent chat, uploads, provider/model selection, and Viewer context cards
|
||||||
|
- right pane: embedded 3D preview and CAD edit toolbar
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Configure providers in `../../config/llm.config.yaml`. Prefer putting secrets in
|
||||||
|
the environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OPENAI_API_KEY=...
|
||||||
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
DEEPSEEK_API_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
`OPENAI_BASE_URL` can point at an OpenAI-compatible third-party endpoint.
|
||||||
|
|
||||||
|
For local-only setups, providers may also use `apiKey: sk-...` directly in the
|
||||||
|
YAML. If `apiKeyEnv` accidentally contains a key value instead of an environment
|
||||||
|
variable name, the server treats it as a direct key for compatibility.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev -- -H 127.0.0.1 -p 53821
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:53821`.
|
||||||
|
|
||||||
|
## CAD Flow
|
||||||
|
|
||||||
|
Uploads are stored under `../../models/agent-studio/<taskId>`. STEP/STP uploads
|
||||||
|
write a `cad-task.json` manifest and are converted to an STL preview sidecar
|
||||||
|
for the embedded right-pane WebGL renderer.
|
||||||
|
|
||||||
|
CAD Agent Studio does not start the standalone CAD Viewer service. Generated
|
||||||
|
models keep STEP as the primary CAD artifact and export STL as a preview mesh.
|
||||||
|
The right-pane toolbar mirrors the CAD Viewer edit tool set and interaction
|
||||||
|
shape, but tool picks are attached to the next agent request as structured
|
||||||
|
`cadViewer.editIntent` / `cadViewer.selection` context instead of being copied
|
||||||
|
to the clipboard. The handoff is silent in the UI: no success modal, no copied
|
||||||
|
text panel.
|
||||||
|
|
||||||
|
Modification requests create revision folders with `edit-request.json`. The
|
||||||
|
generation adapter should apply that request to the editable source recorded in
|
||||||
|
`cad-task.json`; STEP-only uploads are marked as reconstructed edits.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# CAD Agent Studio Agent Rules
|
||||||
|
|
||||||
|
- 必须严格执行真实 agent 生成流程。任何 CAD 生成或修改都必须由模型调用服务端工具完成,并在工具成功写出真实 artifact 后才允许告诉用户“已生成”。
|
||||||
|
- 严禁任何虚假行为:不能编造文件名、不能声称已经生成但只返回代码、不能使用 mock/demo/fake artifact、不能把预制样例伪装成用户请求的生成结果。
|
||||||
|
- 严禁为具体零件写硬编码生成分支或关键词模板,例如根据“法兰”“连杆”等关键词直接走固定源码。零件几何必须由 agent 针对当前用户上下文实时生成源码并交给工具执行。
|
||||||
|
- 如果模型没有调用生成工具、工具执行失败、STEP/STL 文件为空或不存在,必须明确说明没有生成成功,不得触发右侧预览。
|
||||||
|
- 普通聊天、确认、说明、提问不得自动生成 CAD。只有明确的 CAD 生成/修改意图,或用户确认上一轮 agent 明确提出的 CAD 生成方案时,才可以调用生成工具。
|
||||||
|
- STEP 是主 CAD artifact;STL/GLB 只能作为页面预览或可视化 sidecar。后续修改应优先基于 `cad-task.json` 中记录的 editable source。
|
||||||
|
- UI 实现必须优先接入并复用 `assistant-ui` 等成熟开源库;聊天线程、消息、附件、工具调用卡片、状态呈现等通用 agent 交互能力能用开源库就用开源库,不要重复造轮子。只有项目特有的 CAD Viewer、CAD artifact、拓扑选择和生成/修改流程才自定义实现。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const appRoot = __dirname;
|
||||||
|
const repoRoot = path.resolve(appRoot, "../..");
|
||||||
|
const viewerRoot = path.join(repoRoot, "viewer");
|
||||||
|
const viewerNodeModules = path.join(viewerRoot, "node_modules");
|
||||||
|
const cadJsSrc = path.join(appRoot, "src", "viewer-cadjs");
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
allowedDevOrigins: ["127.0.0.1", "localhost"],
|
||||||
|
transpilePackages: ["cadjs", "three", "three-mesh-bvh"],
|
||||||
|
turbopack: {
|
||||||
|
resolveAlias: {
|
||||||
|
cadjs: "./src/viewer-cadjs",
|
||||||
|
three: "./node_modules/three",
|
||||||
|
"three/examples": "./node_modules/three/examples",
|
||||||
|
"three-mesh-bvh": "./node_modules/three-mesh-bvh",
|
||||||
|
gifenc: "./node_modules/gifenc/dist/gifenc.esm.js",
|
||||||
|
},
|
||||||
|
resolveExtensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".json"],
|
||||||
|
},
|
||||||
|
webpack(config) {
|
||||||
|
config.resolve = config.resolve || {};
|
||||||
|
config.resolve.alias = {
|
||||||
|
...(config.resolve.alias || {}),
|
||||||
|
cadjs: cadJsSrc,
|
||||||
|
three: path.join(viewerNodeModules, "three"),
|
||||||
|
"three/examples": path.join(viewerNodeModules, "three", "examples"),
|
||||||
|
"three-mesh-bvh": path.join(viewerNodeModules, "three-mesh-bvh"),
|
||||||
|
gifenc: path.join(viewerNodeModules, "gifenc", "dist", "gifenc.esm.js"),
|
||||||
|
};
|
||||||
|
config.resolve.extensions = [
|
||||||
|
...(config.resolve.extensions || []),
|
||||||
|
".js",
|
||||||
|
".mjs",
|
||||||
|
];
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
+4692
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "cad-agent-studio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --webpack",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"start": "next start",
|
||||||
|
"test": "tsx --test src/**/*.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai": "latest",
|
||||||
|
"@ai-sdk/openai-compatible": "latest",
|
||||||
|
"@ai-sdk/react": "latest",
|
||||||
|
"@assistant-ui/react": "latest",
|
||||||
|
"@assistant-ui/react-ai-sdk": "latest",
|
||||||
|
"ai": "latest",
|
||||||
|
"animejs": "^4.5.0",
|
||||||
|
"cadjs": "file:../../viewer/packages/cadjs",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"gifenc": "file:../../viewer/node_modules/gifenc",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"next": "16.2.6",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"three": "file:../../viewer/node_modules/three",
|
||||||
|
"three-mesh-bvh": "file:../../viewer/node_modules/three-mesh-bvh",
|
||||||
|
"tsx": "^4.20.6",
|
||||||
|
"yaml": "^2.8.1",
|
||||||
|
"zod": "^4.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
+25
@@ -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,563 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { PointerEvent } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { AiSelectionMode, cadEditToolPickKind } from "@/lib/cad-edit-tools";
|
||||||
|
|
||||||
|
type MeshData = {
|
||||||
|
positions: Float32Array;
|
||||||
|
normals: Float32Array;
|
||||||
|
triangles: number;
|
||||||
|
bounds: {
|
||||||
|
min: [number, number, number];
|
||||||
|
max: [number, number, number];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewStatus =
|
||||||
|
| { kind: "idle" | "loading" }
|
||||||
|
| { kind: "ready"; mesh: MeshData }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
type ScreenPoint = { x: number; y: number };
|
||||||
|
|
||||||
|
function isBinaryStl(buffer: ArrayBuffer) {
|
||||||
|
if (buffer.byteLength < 84) return false;
|
||||||
|
const triangles = new DataView(buffer).getUint32(80, true);
|
||||||
|
return 84 + triangles * 50 === buffer.byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faceNormal(a: number[], b: number[], c: number[]) {
|
||||||
|
const ux = b[0] - a[0];
|
||||||
|
const uy = b[1] - a[1];
|
||||||
|
const uz = b[2] - a[2];
|
||||||
|
const vx = c[0] - a[0];
|
||||||
|
const vy = c[1] - a[1];
|
||||||
|
const vz = c[2] - a[2];
|
||||||
|
const nx = uy * vz - uz * vy;
|
||||||
|
const ny = uz * vx - ux * vz;
|
||||||
|
const nz = ux * vy - uy * vx;
|
||||||
|
const length = Math.hypot(nx, ny, nz) || 1;
|
||||||
|
return [nx / length, ny / length, nz / length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishMesh(positionValues: number[], normalValues: number[]): MeshData {
|
||||||
|
if (!positionValues.length) {
|
||||||
|
throw new Error("STL 文件没有可渲染的三角面。");
|
||||||
|
}
|
||||||
|
const min: [number, number, number] = [Infinity, Infinity, Infinity];
|
||||||
|
const max: [number, number, number] = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let index = 0; index < positionValues.length; index += 3) {
|
||||||
|
min[0] = Math.min(min[0], positionValues[index]);
|
||||||
|
min[1] = Math.min(min[1], positionValues[index + 1]);
|
||||||
|
min[2] = Math.min(min[2], positionValues[index + 2]);
|
||||||
|
max[0] = Math.max(max[0], positionValues[index]);
|
||||||
|
max[1] = Math.max(max[1], positionValues[index + 1]);
|
||||||
|
max[2] = Math.max(max[2], positionValues[index + 2]);
|
||||||
|
}
|
||||||
|
const center = [
|
||||||
|
(min[0] + max[0]) / 2,
|
||||||
|
(min[1] + max[1]) / 2,
|
||||||
|
(min[2] + max[2]) / 2,
|
||||||
|
];
|
||||||
|
const radius = Math.max(
|
||||||
|
Math.hypot(max[0] - center[0], max[1] - center[1], max[2] - center[2]),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const scale = 1.55 / radius;
|
||||||
|
const normalized = positionValues.map((value, index) => (value - center[index % 3]) * scale);
|
||||||
|
return {
|
||||||
|
positions: new Float32Array(normalized),
|
||||||
|
normals: new Float32Array(normalValues),
|
||||||
|
triangles: positionValues.length / 9,
|
||||||
|
bounds: { min, max },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBinaryStl(buffer: ArrayBuffer) {
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
const triangles = view.getUint32(80, true);
|
||||||
|
const positions: number[] = [];
|
||||||
|
const normals: number[] = [];
|
||||||
|
let offset = 84;
|
||||||
|
for (let triangle = 0; triangle < triangles; triangle += 1) {
|
||||||
|
const normal = [
|
||||||
|
view.getFloat32(offset, true),
|
||||||
|
view.getFloat32(offset + 4, true),
|
||||||
|
view.getFloat32(offset + 8, true),
|
||||||
|
];
|
||||||
|
offset += 12;
|
||||||
|
const face: number[][] = [];
|
||||||
|
for (let vertex = 0; vertex < 3; vertex += 1) {
|
||||||
|
const point = [
|
||||||
|
view.getFloat32(offset, true),
|
||||||
|
view.getFloat32(offset + 4, true),
|
||||||
|
view.getFloat32(offset + 8, true),
|
||||||
|
];
|
||||||
|
face.push(point);
|
||||||
|
positions.push(point[0], point[1], point[2]);
|
||||||
|
offset += 12;
|
||||||
|
}
|
||||||
|
const fallbackNormal = Math.hypot(normal[0], normal[1], normal[2]) ? normal : faceNormal(face[0], face[1], face[2]);
|
||||||
|
for (let vertex = 0; vertex < 3; vertex += 1) {
|
||||||
|
normals.push(fallbackNormal[0], fallbackNormal[1], fallbackNormal[2]);
|
||||||
|
}
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
return finishMesh(positions, normals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAsciiStl(buffer: ArrayBuffer) {
|
||||||
|
const text = new TextDecoder().decode(buffer);
|
||||||
|
const vertices = [...text.matchAll(/vertex\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)\s+([-+0-9.eE]+)/g)]
|
||||||
|
.map((match) => [Number(match[1]), Number(match[2]), Number(match[3])]);
|
||||||
|
const positions: number[] = [];
|
||||||
|
const normals: number[] = [];
|
||||||
|
for (let index = 0; index + 2 < vertices.length; index += 3) {
|
||||||
|
const a = vertices[index];
|
||||||
|
const b = vertices[index + 1];
|
||||||
|
const c = vertices[index + 2];
|
||||||
|
const normal = faceNormal(a, b, c);
|
||||||
|
positions.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]);
|
||||||
|
normals.push(normal[0], normal[1], normal[2], normal[0], normal[1], normal[2], normal[0], normal[1], normal[2]);
|
||||||
|
}
|
||||||
|
return finishMesh(positions, normals);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStl(buffer: ArrayBuffer) {
|
||||||
|
return isBinaryStl(buffer) ? parseBinaryStl(buffer) : parseAsciiStl(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createShader(gl: WebGLRenderingContext, type: number, source: string) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
if (!shader) throw new Error("WebGL shader 创建失败。");
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
throw new Error(gl.getShaderInfoLog(shader) || "WebGL shader 编译失败。");
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl: WebGLRenderingContext) {
|
||||||
|
const vertex = createShader(gl, gl.VERTEX_SHADER, `
|
||||||
|
attribute vec3 aPosition;
|
||||||
|
attribute vec3 aNormal;
|
||||||
|
uniform mat4 uMvp;
|
||||||
|
uniform mat4 uModel;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vPosition;
|
||||||
|
void main() {
|
||||||
|
vec4 world = uModel * vec4(aPosition, 1.0);
|
||||||
|
vNormal = mat3(uModel) * aNormal;
|
||||||
|
vPosition = world.xyz;
|
||||||
|
gl_Position = uMvp * vec4(aPosition, 1.0);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const fragment = createShader(gl, gl.FRAGMENT_SHADER, `
|
||||||
|
precision mediump float;
|
||||||
|
varying vec3 vNormal;
|
||||||
|
varying vec3 vPosition;
|
||||||
|
void main() {
|
||||||
|
vec3 normal = normalize(vNormal);
|
||||||
|
vec3 lightA = normalize(vec3(0.35, 0.55, 0.75));
|
||||||
|
vec3 lightB = normalize(vec3(-0.4, -0.2, 0.5));
|
||||||
|
float diffuse = max(dot(normal, lightA), 0.0) * 0.78 + max(dot(normal, lightB), 0.0) * 0.28;
|
||||||
|
float rim = pow(1.0 - max(dot(normal, normalize(vec3(0.0, 0.0, 1.0))), 0.0), 2.0) * 0.22;
|
||||||
|
vec3 base = vec3(0.48, 0.78, 0.81);
|
||||||
|
vec3 color = base * (0.24 + diffuse) + vec3(0.95, 0.88, 0.62) * rim;
|
||||||
|
gl_FragColor = vec4(color, 1.0);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) throw new Error("WebGL program 创建失败。");
|
||||||
|
gl.attachShader(program, vertex);
|
||||||
|
gl.attachShader(program, fragment);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
throw new Error(gl.getProgramInfoLog(program) || "WebGL program 链接失败。");
|
||||||
|
}
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function identity() {
|
||||||
|
return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiply(a: Float32Array, b: Float32Array) {
|
||||||
|
const out = new Float32Array(16);
|
||||||
|
for (let column = 0; column < 4; column += 1) {
|
||||||
|
for (let row = 0; row < 4; row += 1) {
|
||||||
|
out[column * 4 + row] =
|
||||||
|
a[0 * 4 + row] * b[column * 4 + 0] +
|
||||||
|
a[1 * 4 + row] * b[column * 4 + 1] +
|
||||||
|
a[2 * 4 + row] * b[column * 4 + 2] +
|
||||||
|
a[3 * 4 + row] * b[column * 4 + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function perspective(aspect: number) {
|
||||||
|
const fovy = Math.PI / 4;
|
||||||
|
const near = 0.1;
|
||||||
|
const far = 100;
|
||||||
|
const f = 1 / Math.tan(fovy / 2);
|
||||||
|
const out = new Float32Array(16);
|
||||||
|
out[0] = f / aspect;
|
||||||
|
out[5] = f;
|
||||||
|
out[10] = (far + near) / (near - far);
|
||||||
|
out[11] = -1;
|
||||||
|
out[14] = (2 * far * near) / (near - far);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function translate(z: number) {
|
||||||
|
const out = identity();
|
||||||
|
out[14] = z;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateX(angle: number) {
|
||||||
|
const out = identity();
|
||||||
|
const c = Math.cos(angle);
|
||||||
|
const s = Math.sin(angle);
|
||||||
|
out[5] = c;
|
||||||
|
out[6] = s;
|
||||||
|
out[9] = -s;
|
||||||
|
out[10] = c;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateY(angle: number) {
|
||||||
|
const out = identity();
|
||||||
|
const c = Math.cos(angle);
|
||||||
|
const s = Math.sin(angle);
|
||||||
|
out[0] = c;
|
||||||
|
out[2] = -s;
|
||||||
|
out[8] = s;
|
||||||
|
out[10] = c;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleMatrix(value: number) {
|
||||||
|
const out = identity();
|
||||||
|
out[0] = value;
|
||||||
|
out[5] = value;
|
||||||
|
out[10] = value;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDimension(bounds: MeshData["bounds"]) {
|
||||||
|
const size = bounds.max.map((value, index) => value - bounds.min[index]);
|
||||||
|
return `${size[0].toFixed(1)} x ${size[1].toFixed(1)} x ${size[2].toFixed(1)} mm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPayloadFromPointer({
|
||||||
|
event,
|
||||||
|
canvas,
|
||||||
|
kind,
|
||||||
|
}: {
|
||||||
|
event: PointerEvent<HTMLCanvasElement>;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
kind: "surface_point" | "edge";
|
||||||
|
}) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const localX = event.clientX - rect.left;
|
||||||
|
const localY = event.clientY - rect.top;
|
||||||
|
const normalizedX = rect.width > 0 ? localX / rect.width : 0.5;
|
||||||
|
const normalizedY = rect.height > 0 ? localY / rect.height : 0.5;
|
||||||
|
const base = {
|
||||||
|
kind,
|
||||||
|
referenceId: `embedded_preview:${kind}`,
|
||||||
|
selector: `screen(${normalizedX.toFixed(4)},${normalizedY.toFixed(4)})`,
|
||||||
|
partId: "preview_mesh",
|
||||||
|
entityType: kind === "edge" ? "edge" : "face",
|
||||||
|
selectorType: "embedded_preview_screen_pick",
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
screenPoint: { x: event.clientX, y: event.clientY },
|
||||||
|
};
|
||||||
|
if (kind === "edge") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
curve: null,
|
||||||
|
adjacentFaces: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
faceIndex: null,
|
||||||
|
rowIndex: null,
|
||||||
|
pointModel: [(normalizedX - 0.5) * 2, (0.5 - normalizedY) * 2, 0],
|
||||||
|
pointWorld: [(normalizedX - 0.5) * 2, (0.5 - normalizedY) * 2, 0],
|
||||||
|
normal: [0, 0, 1],
|
||||||
|
surface: {
|
||||||
|
type: "APPROXIMATE_SCREEN_PLANE",
|
||||||
|
origin: [0, 0, 0],
|
||||||
|
normal: [0, 0, 1],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StlPreview({
|
||||||
|
src,
|
||||||
|
title,
|
||||||
|
activeEditToolId = "",
|
||||||
|
aiSelectionMode = "point",
|
||||||
|
resetViewToken = 0,
|
||||||
|
onEditPick,
|
||||||
|
onGeometrySelection,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
title: string;
|
||||||
|
activeEditToolId?: string;
|
||||||
|
aiSelectionMode?: AiSelectionMode;
|
||||||
|
resetViewToken?: number;
|
||||||
|
onEditPick?: (payload: Record<string, unknown>) => void;
|
||||||
|
onGeometrySelection?: (payload: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const dragRef = useRef<{ x: number; y: number; rotationX: number; rotationY: number } | null>(null);
|
||||||
|
const pickDownRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
|
const lassoRef = useRef<{ active: boolean; points: ScreenPoint[] }>({ active: false, points: [] });
|
||||||
|
const [status, setStatus] = useState<PreviewStatus>({ kind: "idle" });
|
||||||
|
const [rotation, setRotation] = useState({ x: -0.62, y: 0.68 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [lassoPoints, setLassoPoints] = useState<ScreenPoint[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setStatus({ kind: "loading" });
|
||||||
|
void fetch(src)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`预览网格读取失败: ${response.status}`);
|
||||||
|
return response.arrayBuffer();
|
||||||
|
})
|
||||||
|
.then((buffer) => {
|
||||||
|
if (!cancelled) setStatus({ kind: "ready", mesh: parseStl(buffer) });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) setStatus({ kind: "error", message: error instanceof Error ? error.message : "预览加载失败。" });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status.kind !== "ready") return undefined;
|
||||||
|
const mesh = status.mesh;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const gl = canvas?.getContext("webgl", { antialias: true });
|
||||||
|
if (!canvas || !gl) {
|
||||||
|
setStatus({ kind: "error", message: "浏览器不支持 WebGL。" });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const program = createProgram(gl);
|
||||||
|
const positionBuffer = gl.createBuffer();
|
||||||
|
const normalBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW);
|
||||||
|
const aPosition = gl.getAttribLocation(program, "aPosition");
|
||||||
|
const aNormal = gl.getAttribLocation(program, "aNormal");
|
||||||
|
const uMvp = gl.getUniformLocation(program, "uMvp");
|
||||||
|
const uModel = gl.getUniformLocation(program, "uModel");
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!canvas || !gl) return;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const width = Math.max(1, Math.floor(rect.width * window.devicePixelRatio));
|
||||||
|
const height = Math.max(1, Math.floor(rect.height * window.devicePixelRatio));
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
|
gl.viewport(0, 0, width, height);
|
||||||
|
gl.clearColor(0.05, 0.06, 0.07, 1);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||||
|
gl.enable(gl.DEPTH_TEST);
|
||||||
|
gl.enable(gl.CULL_FACE);
|
||||||
|
gl.useProgram(program);
|
||||||
|
const model = multiply(multiply(rotateY(rotation.y), rotateX(rotation.x)), scaleMatrix(zoom));
|
||||||
|
const view = translate(-4.2);
|
||||||
|
const mvp = multiply(multiply(perspective(width / height), view), model);
|
||||||
|
gl.uniformMatrix4fv(uMvp, false, mvp);
|
||||||
|
gl.uniformMatrix4fv(uModel, false, model);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||||
|
gl.enableVertexAttribArray(aPosition);
|
||||||
|
gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
|
||||||
|
gl.enableVertexAttribArray(aNormal);
|
||||||
|
gl.vertexAttribPointer(aNormal, 3, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, mesh.positions.length / 3);
|
||||||
|
frame = requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
gl.deleteBuffer(positionBuffer);
|
||||||
|
gl.deleteBuffer(normalBuffer);
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
};
|
||||||
|
}, [rotation, status, zoom]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRotation({ x: -0.62, y: 0.68 });
|
||||||
|
setZoom(1);
|
||||||
|
}, [resetViewToken]);
|
||||||
|
|
||||||
|
const interactionMode = activeEditToolId ? "edit" : aiSelectionMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full min-h-[42vh] overflow-hidden bg-[#0d0f11]">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="h-full w-full cursor-grab touch-none active:cursor-grabbing"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
if (interactionMode === "lasso") {
|
||||||
|
const point = { x: event.clientX, y: event.clientY };
|
||||||
|
lassoRef.current = { active: true, points: [point] };
|
||||||
|
setLassoPoints([point]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (interactionMode === "edit" || interactionMode === "point") {
|
||||||
|
pickDownRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
if (interactionMode === "point") {
|
||||||
|
dragRef.current = { x: event.clientX, y: event.clientY, rotationX: rotation.x, rotationY: rotation.y };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragRef.current = { x: event.clientX, y: event.clientY, rotationX: rotation.x, rotationY: rotation.y };
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (lassoRef.current.active) {
|
||||||
|
const nextPoints = [...lassoRef.current.points, { x: event.clientX, y: event.clientY }];
|
||||||
|
lassoRef.current = { active: true, points: nextPoints };
|
||||||
|
setLassoPoints(nextPoints);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const dx = event.clientX - dragRef.current.x;
|
||||||
|
const dy = event.clientY - dragRef.current.y;
|
||||||
|
setRotation({
|
||||||
|
x: dragRef.current.rotationX + dy * 0.01,
|
||||||
|
y: dragRef.current.rotationY + dx * 0.01,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
if (lassoRef.current.active) {
|
||||||
|
const points = lassoRef.current.points;
|
||||||
|
lassoRef.current = { active: false, points: [] };
|
||||||
|
setLassoPoints([]);
|
||||||
|
if (points.length >= 3) {
|
||||||
|
const xs = points.map((point) => point.x);
|
||||||
|
const ys = points.map((point) => point.y);
|
||||||
|
onGeometrySelection?.({
|
||||||
|
kind: "region_pick",
|
||||||
|
selectionMode: "lasso",
|
||||||
|
stroke2d: points,
|
||||||
|
bbox2d: {
|
||||||
|
x: Math.min(...xs),
|
||||||
|
y: Math.min(...ys),
|
||||||
|
width: Math.max(...xs) - Math.min(...xs),
|
||||||
|
height: Math.max(...ys) - Math.min(...ys),
|
||||||
|
},
|
||||||
|
entities: [],
|
||||||
|
entityCount: 0,
|
||||||
|
view: {
|
||||||
|
rotation,
|
||||||
|
zoom,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pickDownRef.current) {
|
||||||
|
const moved = Math.hypot(event.clientX - pickDownRef.current.x, event.clientY - pickDownRef.current.y);
|
||||||
|
pickDownRef.current = null;
|
||||||
|
dragRef.current = null;
|
||||||
|
if (moved < 6) {
|
||||||
|
if (interactionMode === "edit" && activeEditToolId) {
|
||||||
|
onEditPick?.(pickPayloadFromPointer({
|
||||||
|
event,
|
||||||
|
canvas: event.currentTarget,
|
||||||
|
kind: cadEditToolPickKind(activeEditToolId),
|
||||||
|
}));
|
||||||
|
} else if (interactionMode === "point") {
|
||||||
|
const pick = pickPayloadFromPointer({
|
||||||
|
event,
|
||||||
|
canvas: event.currentTarget,
|
||||||
|
kind: "surface_point",
|
||||||
|
});
|
||||||
|
onGeometrySelection?.({
|
||||||
|
kind: "point_pick",
|
||||||
|
selectionMode: "point",
|
||||||
|
multiSelect: Boolean(event.ctrlKey || event.metaKey),
|
||||||
|
pick,
|
||||||
|
screenPoint: { x: event.clientX, y: event.clientY },
|
||||||
|
referenceIds: [String(pick.referenceId || "").trim()].filter(Boolean),
|
||||||
|
entities: [],
|
||||||
|
view: {
|
||||||
|
rotation,
|
||||||
|
zoom,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragRef.current = null;
|
||||||
|
}}
|
||||||
|
onWheel={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setZoom((current) => Math.min(2.6, Math.max(0.45, current * (event.deltaY > 0 ? 0.92 : 1.08))));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{lassoPoints.length ? (
|
||||||
|
<div className="pointer-events-none fixed inset-0 z-[26]" aria-hidden="true">
|
||||||
|
<svg className="fixed inset-0 size-full overflow-visible">
|
||||||
|
<polyline
|
||||||
|
points={lassoPoints.map((point) => `${point.x},${point.y}`).join(" ")}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-[#7dc8cf]"
|
||||||
|
strokeWidth="2.25"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="absolute left-3 top-3 rounded border border-[#2d3339] bg-[#15181b]/95 px-3 py-2 text-[11px] text-[#c9cccf] shadow-xl">
|
||||||
|
<div className="font-semibold text-[#e8e8e3]">{title}</div>
|
||||||
|
{status.kind === "ready" ? (
|
||||||
|
<div className="mt-1 text-[#969b9f]">{status.mesh.triangles.toLocaleString()} triangles · {formatDimension(status.mesh.bounds)}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.kind === "loading" || status.kind === "idle" ? (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-[#0d0f11]">
|
||||||
|
<div className="flex items-center gap-2 text-[12px] text-[#c9cccf]">
|
||||||
|
<Loader2 className="size-4 animate-spin text-[#7dc8cf]" />
|
||||||
|
加载 3D 预览...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{status.kind === "error" ? (
|
||||||
|
<div className="absolute inset-0 grid 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]">
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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,244 @@
|
|||||||
|
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 } from "@/lib/preview-artifacts";
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CadSourceRepairContext = {
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
prompt: string;
|
||||||
|
summary: string;
|
||||||
|
sourceCode: string;
|
||||||
|
sourceFilename: string;
|
||||||
|
stepFilename: string;
|
||||||
|
stlFilename: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CadSourceRepairProgress = {
|
||||||
|
status: "running" | "success" | "error";
|
||||||
|
message: string;
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureRelativeFilename(filename: string, extension: string, fallback: string) {
|
||||||
|
const safeName = sanitizeFilename(filename || fallback);
|
||||||
|
return safeName.toLowerCase().endsWith(extension) ? safeName : `${safeName}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertFileExists(absolutePath: string, label: string) {
|
||||||
|
const stat = await fs.stat(absolutePath).catch(() => null);
|
||||||
|
if (!stat?.isFile() || stat.size <= 0) {
|
||||||
|
throw new Error(`${label} was not generated or is empty.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedSourceHasRequiredExports(sourceCode: string) {
|
||||||
|
return /\bexport_step\s*\(/.test(sourceCode) && /\bexport_stl\s*\(/.test(sourceCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function execErrorMessage(error: unknown) {
|
||||||
|
if (!error || typeof error !== "object") {
|
||||||
|
return error instanceof Error ? error.message : String(error || "Unknown execution error.");
|
||||||
|
}
|
||||||
|
const record = error as Record<string, unknown>;
|
||||||
|
const message = error instanceof Error ? error.message : "Command failed.";
|
||||||
|
const stderr = typeof record.stderr === "string" ? record.stderr.trim() : "";
|
||||||
|
const stdout = typeof record.stdout === "string" ? record.stdout.trim() : "";
|
||||||
|
return [
|
||||||
|
message,
|
||||||
|
stderr ? `stderr:\n${stderr}` : "",
|
||||||
|
stdout ? `stdout:\n${stdout}` : "",
|
||||||
|
].filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripMarkdownCodeFence(text: string) {
|
||||||
|
const trimmed = String(text || "").trim();
|
||||||
|
const fenced = trimmed.match(/```(?:python|py)?\s*([\s\S]*?)```/i);
|
||||||
|
return (fenced?.[1] || trimmed).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeGeneratedCadSource({
|
||||||
|
prompt,
|
||||||
|
summary,
|
||||||
|
sourceCode,
|
||||||
|
sourceFilename,
|
||||||
|
stepFilename,
|
||||||
|
stlFilename,
|
||||||
|
selectedTaskId,
|
||||||
|
assumptions = [],
|
||||||
|
maxRepairAttempts = 2,
|
||||||
|
repairSource,
|
||||||
|
onRepairProgress,
|
||||||
|
}: {
|
||||||
|
prompt: string;
|
||||||
|
summary: string;
|
||||||
|
sourceCode: string;
|
||||||
|
sourceFilename?: string;
|
||||||
|
stepFilename?: string;
|
||||||
|
stlFilename?: string;
|
||||||
|
selectedTaskId?: string;
|
||||||
|
assumptions?: string[];
|
||||||
|
maxRepairAttempts?: number;
|
||||||
|
repairSource?: (context: CadSourceRepairContext) => Promise<string>;
|
||||||
|
onRepairProgress?: (progress: CadSourceRepairProgress) => void;
|
||||||
|
}): Promise<CadGenerationResult> {
|
||||||
|
let normalizedSource = String(sourceCode || "").trim();
|
||||||
|
if (!normalizedSource) {
|
||||||
|
throw new Error("CAD sourceCode is empty.");
|
||||||
|
}
|
||||||
|
if (!generatedSourceHasRequiredExports(normalizedSource)) {
|
||||||
|
throw new Error("CAD source must call export_step(...) and export_stl(...).");
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await ensureTask(selectedTaskId || undefined);
|
||||||
|
const sourcePath = ensureRelativeFilename(sourceFilename || "model.py", ".py", "model.py");
|
||||||
|
const artifactPath = ensureRelativeFilename(stepFilename || "model.step", ".step", "model.step");
|
||||||
|
const previewPath = ensureRelativeFilename(stlFilename || "model.stl", ".stl", "model.stl");
|
||||||
|
const sourceAbsolutePath = path.join(task.dir, sourcePath);
|
||||||
|
const artifactAbsolutePath = path.join(task.dir, artifactPath);
|
||||||
|
const previewAbsolutePath = path.join(task.dir, previewPath);
|
||||||
|
|
||||||
|
let repairCount = 0;
|
||||||
|
let lastExecutionError = "";
|
||||||
|
const maxAttempts = Math.max(0, Math.trunc(maxRepairAttempts));
|
||||||
|
for (let attempt = 0; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
await fs.writeFile(sourceAbsolutePath, `${normalizedSource}\n`);
|
||||||
|
try {
|
||||||
|
await execFileAsync("python3", [sourcePath], {
|
||||||
|
cwd: task.dir,
|
||||||
|
timeout: 60_000,
|
||||||
|
maxBuffer: 1024 * 1024 * 4,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
lastExecutionError = execErrorMessage(error);
|
||||||
|
if (!repairSource || attempt >= maxAttempts) {
|
||||||
|
throw new Error(lastExecutionError);
|
||||||
|
}
|
||||||
|
const repairAttempt = attempt + 1;
|
||||||
|
onRepairProgress?.({
|
||||||
|
status: "running",
|
||||||
|
message: `第 ${repairAttempt}/${maxAttempts} 次分析 Python 错误并修复源码。`,
|
||||||
|
attempt: repairAttempt,
|
||||||
|
maxAttempts,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const repaired = stripMarkdownCodeFence(await repairSource({
|
||||||
|
attempt: repairAttempt,
|
||||||
|
maxAttempts,
|
||||||
|
prompt,
|
||||||
|
summary,
|
||||||
|
sourceCode: normalizedSource,
|
||||||
|
sourceFilename: sourcePath,
|
||||||
|
stepFilename: artifactPath,
|
||||||
|
stlFilename: previewPath,
|
||||||
|
error: lastExecutionError,
|
||||||
|
}));
|
||||||
|
if (!repaired || !generatedSourceHasRequiredExports(repaired)) {
|
||||||
|
throw new Error("Repaired CAD source is empty or missing export_step/export_stl calls.");
|
||||||
|
}
|
||||||
|
normalizedSource = repaired;
|
||||||
|
repairCount = repairAttempt;
|
||||||
|
onRepairProgress?.({
|
||||||
|
status: "success",
|
||||||
|
message: `源码已修复,正在重新执行 Python。`,
|
||||||
|
attempt: repairAttempt,
|
||||||
|
maxAttempts,
|
||||||
|
});
|
||||||
|
} catch (repairError) {
|
||||||
|
onRepairProgress?.({
|
||||||
|
status: "error",
|
||||||
|
message: repairError instanceof Error ? repairError.message : "CAD source repair failed.",
|
||||||
|
attempt: repairAttempt,
|
||||||
|
maxAttempts,
|
||||||
|
});
|
||||||
|
throw new Error([
|
||||||
|
lastExecutionError,
|
||||||
|
"Automatic source repair also failed:",
|
||||||
|
repairError instanceof Error ? repairError.message : String(repairError),
|
||||||
|
].join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await assertFileExists(artifactAbsolutePath, "STEP artifact");
|
||||||
|
await assertFileExists(previewAbsolutePath, "STL preview artifact");
|
||||||
|
const viewerAssetPath = await createStepGlbPreview({
|
||||||
|
taskId: task.taskId,
|
||||||
|
stepPath: artifactPath,
|
||||||
|
});
|
||||||
|
const artifactVersion = Date.now().toString(36);
|
||||||
|
|
||||||
|
await upsertManifest(task.taskId, {
|
||||||
|
request: prompt,
|
||||||
|
route: {
|
||||||
|
selected_backend: "build123d",
|
||||||
|
runner_skill: "cad-agent-studio.generate_cad",
|
||||||
|
project: "text-to-cad",
|
||||||
|
fallback_order: [],
|
||||||
|
workflow_profiles: ["agent_generated_source"],
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
path: sourcePath,
|
||||||
|
format: "python",
|
||||||
|
backend: "build123d",
|
||||||
|
source_editability: "editable_source",
|
||||||
|
generated_by: "llm_tool_call",
|
||||||
|
},
|
||||||
|
artifacts: [
|
||||||
|
{ path: artifactPath, role: "primary", kind: "step" },
|
||||||
|
{ path: previewPath, role: "preview", kind: "stl" },
|
||||||
|
{ path: viewerAssetPath, role: "viewer", kind: "glb" },
|
||||||
|
],
|
||||||
|
assumptions,
|
||||||
|
validation: [
|
||||||
|
"Agent provided build123d Python source through generate_cad tool.",
|
||||||
|
"Server executed the generated source successfully.",
|
||||||
|
repairCount > 0 ? `Server repaired the generated source automatically after ${repairCount} failed execution attempt(s).` : "",
|
||||||
|
"Verified non-empty STEP primary artifact.",
|
||||||
|
"Verified non-empty STL preview artifact.",
|
||||||
|
"Generated STEP GLB/topology artifact for CAD Viewer runtime picking.",
|
||||||
|
].filter(Boolean),
|
||||||
|
studio: {
|
||||||
|
latestArtifact: artifactPath,
|
||||||
|
latestPreview: previewPath,
|
||||||
|
latestViewerAsset: viewerAssetPath,
|
||||||
|
latestVersion: artifactVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
taskId: task.taskId,
|
||||||
|
artifactPath,
|
||||||
|
artifactUrl: artifactUrl(task.taskId, artifactPath, artifactVersion),
|
||||||
|
previewPath,
|
||||||
|
previewUrl: artifactUrl(task.taskId, previewPath, artifactVersion),
|
||||||
|
viewerAssetPath,
|
||||||
|
viewerAssetUrl: artifactUrl(task.taskId, viewerAssetPath, artifactVersion),
|
||||||
|
sourcePath,
|
||||||
|
summary: summary || "CAD model generated from agent-authored source.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cadResultDirective(result: CadGenerationResult) {
|
||||||
|
return `::cad-result{base64="${Buffer.from(JSON.stringify(result), "utf8").toString("base64")}"}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
|
import {
|
||||||
|
createUIMessageStream,
|
||||||
|
createUIMessageStreamResponse,
|
||||||
|
generateText,
|
||||||
|
isStepCount,
|
||||||
|
streamText,
|
||||||
|
tool,
|
||||||
|
type LanguageModel,
|
||||||
|
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 { executeGeneratedCadSource, 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, use its taskId and path as the reconstruction reference. Do not pretend to have viewed it visually; reconstruct by authoring real editable build123d source and calling generate_cad.",
|
||||||
|
].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 executes build123d Python source authored for this specific user request and verifies non-empty STEP/STL files.",
|
||||||
|
"When STEP files are attached, treat them as reconstruction reference artifacts handled by server tools, not text blobs and not direct preview output.",
|
||||||
|
"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 source recorded in cad-task.json before regenerating STEP/STL/viewer assets.",
|
||||||
|
"For CAD generation, author complete build123d Python source. It must import build123d, construct the requested model, call export_step(...), and call export_stl(...).",
|
||||||
|
"Use simple ASCII filenames ending in .py, .step, and .stl. The source must write outputs in its current working directory.",
|
||||||
|
"If CAD generation is requested and the generate_cad tool is available, call generate_cad. Do not answer with OpenSCAD/code-only instructions.",
|
||||||
|
"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");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairCadSourceWithModel({
|
||||||
|
languageModel,
|
||||||
|
context,
|
||||||
|
}: {
|
||||||
|
languageModel: LanguageModel;
|
||||||
|
context: {
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
prompt: string;
|
||||||
|
summary: string;
|
||||||
|
sourceCode: string;
|
||||||
|
sourceFilename: string;
|
||||||
|
stepFilename: string;
|
||||||
|
stlFilename: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const result = await generateText({
|
||||||
|
model: languageModel,
|
||||||
|
system: [
|
||||||
|
"You repair build123d Python CAD source after a failed server execution.",
|
||||||
|
"Return only the complete corrected Python source. Do not include markdown, explanations, diffs, or prose.",
|
||||||
|
"Preserve the user's CAD intent and preserve unrelated geometry.",
|
||||||
|
"Preserve the requested output filenames and ensure the code calls export_step(...) and export_stl(...).",
|
||||||
|
"Fix imports, math usage, API mistakes, variable names, and build123d construction errors as needed.",
|
||||||
|
"Do not replace the model with a trivial placeholder unless the original source cannot be made executable.",
|
||||||
|
].join("\n"),
|
||||||
|
prompt: [
|
||||||
|
`Repair attempt ${context.attempt} of ${context.maxAttempts}.`,
|
||||||
|
"",
|
||||||
|
"Original user request:",
|
||||||
|
context.prompt || "(empty)",
|
||||||
|
"",
|
||||||
|
"Model summary:",
|
||||||
|
context.summary || "(empty)",
|
||||||
|
"",
|
||||||
|
"Required files:",
|
||||||
|
`- source: ${context.sourceFilename}`,
|
||||||
|
`- STEP: ${context.stepFilename}`,
|
||||||
|
`- STL: ${context.stlFilename}`,
|
||||||
|
"",
|
||||||
|
"Execution error:",
|
||||||
|
"```text",
|
||||||
|
context.error,
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
"Current Python source:",
|
||||||
|
"```python",
|
||||||
|
context.sourceCode,
|
||||||
|
"```",
|
||||||
|
].join("\n"),
|
||||||
|
maxRetries: 1,
|
||||||
|
});
|
||||||
|
return result.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 build123d Python source.",
|
||||||
|
"Call this only when the user wants CAD generation or a model modification.",
|
||||||
|
"The source is executed on the server and must export both STEP and STL files.",
|
||||||
|
].join(" "),
|
||||||
|
inputSchema: z.object({
|
||||||
|
summary: z.string().describe("Concise user-facing summary of the generated CAD model."),
|
||||||
|
sourceFilename: z.string().describe("Python source filename, e.g. model.py."),
|
||||||
|
stepFilename: z.string().describe("STEP output filename, e.g. model.step."),
|
||||||
|
stlFilename: z.string().describe("STL preview output filename, e.g. model.stl."),
|
||||||
|
sourceCode: z.string().describe("Complete build123d Python source that exports the STEP and STL files."),
|
||||||
|
assumptions: z.array(z.string()).default([]).describe("Assumptions made because the user did not specify every dimension."),
|
||||||
|
}),
|
||||||
|
execute: async ({ summary, sourceFilename, stepFilename, stlFilename, sourceCode, assumptions }) => {
|
||||||
|
writeCadProgress({
|
||||||
|
step: "generate_source",
|
||||||
|
label: "生成 CAD 源码",
|
||||||
|
status: "success",
|
||||||
|
message: sourceFilename,
|
||||||
|
});
|
||||||
|
writeCadProgress({
|
||||||
|
step: "execute_cad",
|
||||||
|
label: "执行 Python 并导出 STEP/STL",
|
||||||
|
status: "running",
|
||||||
|
message: "正在运行 build123d 脚本并验证输出文件。",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
cadGeneration = await executeGeneratedCadSource({
|
||||||
|
prompt: lastUserText(chatMessages),
|
||||||
|
summary,
|
||||||
|
sourceFilename,
|
||||||
|
stepFilename,
|
||||||
|
stlFilename,
|
||||||
|
sourceCode,
|
||||||
|
assumptions,
|
||||||
|
selectedTaskId,
|
||||||
|
repairSource: (context) => repairCadSourceWithModel({
|
||||||
|
languageModel,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
onRepairProgress: (progress) => {
|
||||||
|
writeCadProgress({
|
||||||
|
step: "repair_cad_source",
|
||||||
|
label: "分析错误并修复源码",
|
||||||
|
status: progress.status,
|
||||||
|
message: progress.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "CAD generation failed.";
|
||||||
|
writeCadProgress({
|
||||||
|
step: "execute_cad",
|
||||||
|
label: "执行 Python 并导出 STEP/STL",
|
||||||
|
status: "error",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
failAgentStream(message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
writeCadProgress({
|
||||||
|
step: "execute_cad",
|
||||||
|
label: "执行 Python 并导出 STEP/STL",
|
||||||
|
status: "success",
|
||||||
|
message: `${cadGeneration.artifactPath} / ${cadGeneration.previewPath}`,
|
||||||
|
});
|
||||||
|
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,54 @@
|
|||||||
|
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("OpenAI baseURL is fixed to the configured compatible backend", () => {
|
||||||
|
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, "https://api.vip1129.cc/v1");
|
||||||
|
} 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 supports direct apiKey", () => {
|
||||||
|
const apiKey = resolveProviderApiKey({
|
||||||
|
type: "openai",
|
||||||
|
apiKey: "sk-test-direct-key-for-config-loader",
|
||||||
|
baseURL: "https://example.test/v1",
|
||||||
|
models: { default: "gpt-test" },
|
||||||
|
});
|
||||||
|
assert.equal(apiKey, "sk-test-direct-key-for-config-loader");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveProviderApiKey treats apiKeyEnv key-looking values as direct keys", () => {
|
||||||
|
const apiKey = resolveProviderApiKey({
|
||||||
|
type: "openai-compatible",
|
||||||
|
apiKeyEnv: "sk-test-misplaced-key-for-compatibility",
|
||||||
|
baseURL: "https://example.test/v1",
|
||||||
|
models: { default: "model-test" },
|
||||||
|
});
|
||||||
|
assert.equal(apiKey, "sk-test-misplaced-key-for-compatibility");
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { repoPath, repoRoot } from "@/lib/paths";
|
||||||
|
|
||||||
|
const modelMapSchema = z.record(z.string(), z.string());
|
||||||
|
|
||||||
|
const providerSchema = z.object({
|
||||||
|
type: z.enum(["openai", "openai-compatible"]),
|
||||||
|
apiKey: z.string().optional(),
|
||||||
|
apiKeyEnv: z.string().optional(),
|
||||||
|
baseURL: z.string().min(1),
|
||||||
|
models: modelMapSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 configPath = repoPath("config", "llm.config.yaml");
|
||||||
|
const source = fs.readFileSync(configPath, "utf8");
|
||||||
|
return llmConfigSchema.parse(expandConfig(YAML.parse(source)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTaskRoot(config = loadLlmConfig()) {
|
||||||
|
return path.resolve(repoRoot(), 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeApiKey(value: string | undefined) {
|
||||||
|
const normalized = String(value || "").trim();
|
||||||
|
return normalized.startsWith("sk-") || normalized.startsWith("sess-") || normalized.length > 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "";
|
||||||
|
}
|
||||||
|
const envValue = process.env[envName];
|
||||||
|
if (envValue) {
|
||||||
|
return envValue;
|
||||||
|
}
|
||||||
|
return looksLikeApiKey(envName) ? 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,13 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function repoRoot() {
|
||||||
|
return path.resolve(process.env.CAD_AGENT_STUDIO_REPO_ROOT || path.join(/* turbopackIgnore: true */ process.cwd(), "..", ".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repoPath(...segments: string[]) {
|
||||||
|
return path.join(repoRoot(), ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeToRepo(absolutePath: string) {
|
||||||
|
return path.relative(repoRoot(), absolutePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { repoPath } 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(".stl")) return "model/stl";
|
||||||
|
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";
|
||||||
|
if (lower.endsWith(".scad")) return "text/plain; charset=utf-8";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStepStlPreview({
|
||||||
|
taskId,
|
||||||
|
stepPath,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
stepPath: string;
|
||||||
|
}) {
|
||||||
|
const safeStepPath = safeArtifactPath(stepPath);
|
||||||
|
const parsed = path.parse(safeStepPath);
|
||||||
|
const previewPath = path.join(parsed.dir, `${parsed.name}.preview.stl`);
|
||||||
|
const root = taskDir(taskId);
|
||||||
|
const stepAbsolutePath = path.join(root, safeStepPath);
|
||||||
|
const previewAbsolutePath = path.join(root, previewPath);
|
||||||
|
await fs.mkdir(path.dirname(previewAbsolutePath), { recursive: true });
|
||||||
|
const script = [
|
||||||
|
"from build123d import import_step, export_stl",
|
||||||
|
"import sys",
|
||||||
|
"shape = import_step(sys.argv[1])",
|
||||||
|
"export_stl(shape, sys.argv[2])",
|
||||||
|
].join("\n");
|
||||||
|
await execFileAsync("python3", ["-c", script, stepAbsolutePath, previewAbsolutePath], {
|
||||||
|
cwd: root,
|
||||||
|
timeout: 30_000,
|
||||||
|
maxBuffer: 1024 * 1024,
|
||||||
|
});
|
||||||
|
return previewPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 './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 = repoPath();
|
||||||
|
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", "cadam"],
|
||||||
|
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(".scad") || 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,21 @@
|
|||||||
|
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 allows configured other files", () => {
|
||||||
|
const result = validateUpload("notes.txt", "text/plain", 128);
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.kind, "other");
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+31
@@ -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
+254
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
+185
@@ -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);
|
||||||
|
}
|
||||||
+205
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user