Compare commits

...

23 Commits

Author SHA1 Message Date
134
513df646de fix: reconcile HiCAD manufacturing geometry 2026-07-28 14:24:08 +08:00
134
1068072bd3 fix: preserve HiCAD hole annotations 2026-07-28 13:52:13 +08:00
134
ce1e101ffc fix: stabilize HiCAD manufacturing dimensions 2026-07-28 13:42:56 +08:00
134
d2123de778 fix: correct HiCAD BOM geometry 2026-07-28 10:41:15 +08:00
134
5c798c2214 feat: add HiCAD editor project shortcuts 2026-07-27 23:54:48 +08:00
134
a8de1d2d7c feat: simplify HiCAD project management 2026-07-27 22:57:40 +08:00
134
bde34dd5f7 feat: generate complete front and side part drawings 2026-07-27 22:36:27 +08:00
134
4b42b8831e fix: render HiCAD exploded fallback and reposition trigger 2026-07-27 22:31:43 +08:00
134
09502c18f7 fix: compact HiCAD engineering sheet header 2026-07-27 22:25:37 +08:00
134
d6fcd1233a test: validate HiCAD hardware production drawings 2026-07-27 22:20:27 +08:00
134
592df97568 feat: add HiCAD hole dimension production drawings 2026-07-27 22:03:57 +08:00
134
5fadb1ac61 fix: render HiCAD orthographic views and Chinese BOM names 2026-07-27 21:47:45 +08:00
134
81c3a64089 fix: correct HiCAD exploded previews and BOM templates 2026-07-27 21:36:46 +08:00
134
194762835f fix: make HiCAD engineering analysis resilient 2026-07-27 21:06:08 +08:00
134
c028d2ad00 fix: normalize HiCAD engine responses 2026-07-27 20:54:33 +08:00
134
d93410b01b feat: restore isolated HiCAD engineering tools 2026-07-27 20:45:16 +08:00
134
1f5aae3b86 fix: exclude HiCAD templates from app typecheck 2026-07-27 20:33:57 +08:00
134
3d0473bf65 fix: isolate latest HiCAD startup 2026-07-27 20:22:30 +08:00
134
36bcb3f5a5 fix: isolate HiCAD manufacturing extension 2026-07-27 20:09:12 +08:00
134
1038adae46 feat: brand HiCAD production sheets 2026-07-27 19:57:44 +08:00
134
891db63b70 feat: add HiCAD manufacturing drawings 2026-07-27 19:55:14 +08:00
134
c2bc6147c8 fix: use site favicon in HiCAD 2026-07-27 19:41:51 +08:00
134
957192cc5c feat: upgrade embedded HiCAD to latest official source 2026-07-27 19:33:41 +08:00
13 changed files with 5014 additions and 5 deletions

View File

@ -4,7 +4,8 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
const ZOO_MODELING_APP_SRC = "/vendor/zoo-modeling-app/index.html"; const ZOO_MODELING_APP_SRC =
"/vendor/zoo-modeling-app/index.html?v=f2b345db-eng1";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {

View File

@ -15,6 +15,7 @@ export default defineConfig({
/routes\/model-classification\.test\.ts/, /routes\/model-classification\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/, /routes\/clawd-request-lifecycle\.test\.ts/,
/routes\/hicad-embedded-performance\.test\.ts/, /routes\/hicad-embedded-performance\.test\.ts/,
/routes\/hicad-manufacturing-extension\.test\.ts/,
], ],
fullyParallel: true, fullyParallel: true,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,

View File

@ -18,7 +18,7 @@ import { cacheBustZooModelingAppAssets } from "./cache-bust-zoo-modeling-app-ass
const repoUrl = "https://github.com/KittyCAD/modeling-app.git"; const repoUrl = "https://github.com/KittyCAD/modeling-app.git";
const repoRef = const repoRef =
process.env.ZOO_MODELING_APP_REF || process.env.ZOO_MODELING_APP_REF ||
"6f87f4e66c07c67f09d8f7db284496a3ae3332e7"; "f2b345dbb56a851e10d8a742732ab73ee139f025";
const repoTarballUrls = [ const repoTarballUrls = [
`https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`, `https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
`https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`, `https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
@ -30,6 +30,9 @@ const sourceRefFile = path.join(sourceDir, ".hicad-source-ref");
const outputDir = path.join(root, "public/vendor/zoo-modeling-app"); const outputDir = path.join(root, "public/vendor/zoo-modeling-app");
const customizationDir = path.join(root, "vendor/zoo-modeling-app"); const customizationDir = path.join(root, "vendor/zoo-modeling-app");
const proxyToken = "hicad-session"; const proxyToken = "hicad-session";
const brandedFaviconUrl = "/api/favicon?surface=hicad";
const enableManufacturingExtension =
process.env.HICAD_MANUFACTURING_EXTENSION === "1";
function run(command, args, options = {}) { function run(command, args, options = {}) {
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
@ -107,16 +110,54 @@ function replaceSource(relativePath, before, after) {
if (!after) { if (!after) {
return; return;
} }
throw new Error(`Official source changed; patch failed for ${relativePath}`); throw new Error(
`Official source changed; patch failed for ${relativePath}; expected ${JSON.stringify(
before.slice(0, 160)
)}`
);
} }
writeFileSync(filePath, source.replace(before, after)); writeFileSync(filePath, source.replace(before, after));
} }
function patchOfficialManifest() {
const manifestPath = path.join(sourceDir, "public/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.short_name = "HiCAD";
manifest.name = "HiCAD";
manifest.icons = [
{
src: brandedFaviconUrl,
sizes: "any",
},
];
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
function patchOfficialSource() { function patchOfficialSource() {
copyFileSync( copyFileSync(
path.join(customizationDir, "Logo.tsx"), path.join(customizationDir, "Logo.tsx"),
path.join(sourceDir, "src/components/Logo.tsx") path.join(sourceDir, "src/components/Logo.tsx")
); );
if (enableManufacturingExtension) {
copyFileSync(
path.join(customizationDir, "manufacturing.ts"),
path.join(sourceDir, "src/components/manufacturing.ts")
);
copyFileSync(
path.join(customizationDir, "HicadManufacturingPanel.tsx.template"),
path.join(sourceDir, "src/components/HicadManufacturingPanel.tsx")
);
copyFileSync(
path.join(customizationDir, "HicadManufacturingLauncher.tsx.template"),
path.join(sourceDir, "src/components/HicadManufacturingLauncher.tsx")
);
copyFileSync(
path.join(customizationDir, "HicadManufacturingSlot.tsx.template"),
path.join(sourceDir, "src/components/HicadManufacturingSlot.tsx")
);
}
copyFileSync( copyFileSync(
path.join(customizationDir, "localization.ts"), path.join(customizationDir, "localization.ts"),
path.join(sourceDir, "src/lib/hicadLocalization.ts") path.join(sourceDir, "src/lib/hicadLocalization.ts")
@ -125,11 +166,26 @@ function patchOfficialSource() {
path.join(customizationDir, "FitAllButton.tsx.template"), path.join(customizationDir, "FitAllButton.tsx.template"),
path.join(sourceDir, "src/components/FitAllButton.tsx") path.join(sourceDir, "src/components/FitAllButton.tsx")
); );
copyFileSync(
path.join(customizationDir, "HicadProjectExperience.tsx.template"),
path.join(sourceDir, "src/components/HicadProjectExperience.tsx")
);
replaceSource( replaceSource(
"index.html", "index.html",
' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />', ' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />',
' <meta name="description" content="HiCAD browser-based CAD workspace." />' ' <meta name="description" content="HiCAD browser-based CAD workspace." />'
); );
replaceSource(
"index.html",
' <link rel="icon" href="/favicon.ico" />',
` <link rel="icon" href="${brandedFaviconUrl}" />`
);
replaceSource(
"index.html",
' <link rel="apple-touch-icon" href="/logo192.png" />',
` <link rel="apple-touch-icon" href="${brandedFaviconUrl}" />`
);
patchOfficialManifest();
replaceSource( replaceSource(
"index.html", "index.html",
' <script defer data-domain="app.zoo.dev" src="https://plausible.corp.zoo.dev/js/script.tagged-events.js"></script>\n', ' <script defer data-domain="app.zoo.dev" src="https://plausible.corp.zoo.dev/js/script.tagged-events.js"></script>\n',
@ -161,13 +217,56 @@ function patchOfficialSource() {
"import { isDesktop } from '@src/lib/isDesktop'\n", "import { isDesktop } from '@src/lib/isDesktop'\n",
"" ""
); );
replaceSource(
"src/components/OpenedProject.tsx",
"import { HicadManufacturingPanel } from '@src/components/HicadManufacturingPanel'\n",
""
);
replaceSource(
"src/components/OpenedProject.tsx",
" <HicadManufacturingPanel projectName={projectName ?? 'HiCAD 模型'} />\n",
""
);
replaceSource(
"src/components/OpenedProject.tsx",
"import { HicadManufacturingLauncher } from '@src/components/HicadManufacturingLauncher'\n",
""
);
replaceSource(
"src/components/OpenedProject.tsx",
" <HicadManufacturingLauncher projectName={projectName ?? 'HiCAD 模型'} />\n",
""
);
if (enableManufacturingExtension) {
replaceSource(
"src/components/OpenedProject.tsx",
"import { AppHeader } from '@src/components/AppHeader'\n",
"import { AppHeader } from '@src/components/AppHeader'\nimport { HicadManufacturingSlot } from '@src/components/HicadManufacturingSlot'\n"
);
replaceSource(
"src/components/OpenedProject.tsx",
" <StatusBar\n",
" <HicadManufacturingSlot projectName={projectName ?? 'HiCAD 模型'} />\n <StatusBar\n"
);
} else {
replaceSource(
"src/components/OpenedProject.tsx",
"import { HicadManufacturingSlot } from '@src/components/HicadManufacturingSlot'\n",
""
);
replaceSource(
"src/components/OpenedProject.tsx",
" <HicadManufacturingSlot projectName={projectName ?? 'HiCAD 模型'} />\n",
""
);
}
replaceSource( replaceSource(
"src/lib/socket.ts", "src/lib/socket.ts",
` ws.addEventListener('open', () => {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`, ` ws.addEventListener('open', () => {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`,
` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })` ` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })`
); );
replaceSource( replaceSource(
"src/network/websocketConnection.ts", "src/lib/engineConnection/websocketConnection.ts",
" if (token) {", " if (token) {",
` if (token && token !== '${proxyToken}') {` ` if (token && token !== '${proxyToken}') {`
); );
@ -216,6 +315,379 @@ function patchOfficialSource() {
" {displayedName !== user.email && (", " {displayedName !== user.email && (",
" {!isHiCadEmbedded && displayedName !== user.email && (" " {!isHiCadEmbedded && displayedName !== user.email && ("
); );
replaceSource(
"src/components/AppHeader.tsx",
"import { HicadAutosaveStatus } from '@src/components/HicadProjectExperience'\n",
""
);
replaceSource(
"src/components/AppHeader.tsx",
"import { HicadAutosaveStatus, HicadEditorProjectActions } from '@src/components/HicadProjectExperience'\n",
""
);
replaceSource(
"src/components/AppHeader.tsx",
"import ProjectSidebarMenu from '@src/components/ProjectSidebarMenu'\n",
"import { HicadAutosaveStatus, HicadEditorProjectActions } from '@src/components/HicadProjectExperience'\nimport ProjectSidebarMenu from '@src/components/ProjectSidebarMenu'\n"
);
replaceSource(
"src/components/AppHeader.tsx",
" {project && <HicadEditorProjectActions />}\n",
""
);
replaceSource(
"src/components/AppHeader.tsx",
` {project && (
<HicadEditorProjectActions
onOpenProjectList={() => {
lsp.onProjectClose(file || null, project.path || null, false)
kclManager.switchedFiles = true
void navigate(PATHS.HOME)
}}
onCreateBlankProject={() => {
lsp.onProjectClose(file || null, project.path || null, false)
kclManager.switchedFiles = true
void navigate(\`\${PATHS.HOME}?create=blank\`)
}}
/>
)}
`,
""
);
replaceSource(
"src/components/AppHeader.tsx",
` {project && (
<HicadEditorProjectActions
onOpenProjectList={() => {
lsp.onProjectClose(file || null, project.path || null, false)
kclManager.switchedFiles = true
}}
onCreateBlankProject={() => {
lsp.onProjectClose(file || null, project.path || null, false)
kclManager.switchedFiles = true
}}
/>
)}
`,
""
);
replaceSource(
"src/components/AppHeader.tsx",
` </ProjectSidebarMenu>
<div className="flex items-center gap-2 py-1.5 ml-auto">`,
` </ProjectSidebarMenu>
{project && (
<HicadEditorProjectActions
onOpenProjectList={() => {
lsp.onProjectClose(file || null, project.path || null, false)
kclManager.switchedFiles = true
}}
onCreateBlankProject={() => {
lsp.onProjectClose(file || null, project.path || null, false)
kclManager.switchedFiles = true
}}
/>
)}
<div className="flex items-center gap-2 py-1.5 ml-auto">`
);
replaceSource(
"src/lib/routeLoaders.ts",
" if (await webHomeRouteEnabled(app)) {\n",
" if (true) {\n"
);
replaceSource(
"src/lib/routeLoaders.ts",
" if (!window.electron && !(await webHomeRouteEnabled(app))) {\n",
" if (false) {\n"
);
replaceSource(
"src/lib/app.ts",
` const enableProjectDirectoryCommands =
typeof window !== 'undefined' &&
(Boolean(window.electron) ||
userFeaturesContextHas(
this.userFeatures.actor.getSnapshot().context,
OPFS_CLOUD_FEATURE_FLAG,
false
))
`,
` const enableProjectDirectoryCommands =
typeof window !== 'undefined'
`
);
replaceSource(
"src/components/AppHeader.tsx",
` <div className="flex items-center gap-2 py-1.5 ml-auto">
{children ||`,
` <div className="flex items-center gap-2 py-1.5 ml-auto">
{project && <HicadAutosaveStatus />}
{children ||`
);
replaceSource(
"src/routes/Home.tsx",
"import AppProjectCard from '@src/components/AppProjectCard/AppProjectCard'\n",
"import AppProjectCard from '@src/components/AppProjectCard/AppProjectCard'\nimport { HicadProjectDashboardHeading } from '@src/components/HicadProjectExperience'\n"
);
replaceSource(
"src/routes/Home.tsx",
` projectSearchKeybinding={projectSearchKeybinding}
className="col-start-2 -col-end-1"`,
` projectSearchKeybinding={projectSearchKeybinding}
projectCount={scopedHomeProjectEntries.length}
className="col-start-2 -col-end-1"`
);
replaceSource(
"src/routes/Home.tsx",
` projectSearchKeybinding?: string
}`,
` projectSearchKeybinding?: string
projectCount: number
}`
);
replaceSource(
"src/routes/Home.tsx",
` projectSearchKeybinding,
...rest`,
` projectSearchKeybinding,
projectCount,
...rest`
);
replaceSource(
"src/routes/Home.tsx",
` <div className="flex gap-8 items-center">
<div className="flex flex-col gap-1">
{library && showLibraryBackLink && (
<Link
to={PATHS.HOME}
className="text-sm text-chalkboard-70 underline underline-offset-2 dark:text-chalkboard-30"
>
All libraries
</Link>
)}
<h1 className="text-3xl font-bold">{title}</h1>
</div>
</div>`,
` <div className="flex min-w-0 flex-1 gap-8 items-center">
{library && showLibraryBackLink && (
<Link
to={PATHS.HOME}
className="text-sm text-chalkboard-70 underline underline-offset-2 dark:text-chalkboard-30"
>
返回全部项目
</Link>
)}
<HicadProjectDashboardHeading projectCount={projectCount} />
</div>`
);
replaceSource(
"src/routes/Home.tsx",
`<div className="flex flex-col md:flex-row gap-4 justify-between md:items-center select-none">`,
`<div className="flex flex-col gap-5 justify-between select-none">`
);
replaceSource(
"src/routes/Home.tsx",
`<div className="flex flex-col sm:flex-row gap-2 sm:items-center">`,
`<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
"import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'\n",
"import { ContextMenu, ContextMenuItem } from '@src/components/ContextMenu'\nimport { CustomIcon } from '@src/components/CustomIcon'\n"
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` {
local: 'Local',
'cloud-only': 'Cloud-only',
syncing: 'Syncing',
synced: 'Synced',
conflicted: 'Conflicted',
}`,
` {
local: '已保存',
'cloud-only': '云端项目',
syncing: '保存中',
synced: '已保存',
conflicted: '需要处理',
}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
`function getDisplayedTime(dateTimeMs: number) {
const date = new Date(dateTimeMs)
const startOfToday = new Date()
startOfToday.setHours(0, 0, 0, 0)
return date.getTime() < startOfToday.getTime()
? date.toLocaleDateString()
: date.toLocaleTimeString()
}`,
`function getDisplayedTime(dateTimeMs: number) {
const date = new Date(dateTimeMs)
const now = new Date()
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isToday) {
return \`今天 \${date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})}\`
}
return date.toLocaleDateString('zh-CN', {
year: date.getFullYear() === now.getFullYear() ? undefined : 'numeric',
month: 'short',
day: 'numeric',
})
}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` {project.kclFileCount !== undefined && (
<span className="px-2 text-chalkboard-60 text-xs">
<span data-testid="project-file-count">{project.kclFileCount}</span>{' '}
file{project.kclFileCount === 1 ? '' : 's'}{' '}
{(project.directoryCount ?? 0) > 0 && (
<>
{'/ '}
<span data-testid="project-folder-count">
{project.directoryCount}
</span>{' '}
folder{project.directoryCount === 1 ? '' : 's'}
</>
)}
</span>
)}
<span className="px-2 text-chalkboard-60 text-xs">
Edited{' '}
<span data-testid="project-edit-date">
{displayedProject.modified
? getDisplayedTime(displayedProject.modified)
: 'never'}
</span>
</span>`,
` <div className="flex items-center justify-between gap-2 px-2 text-xs text-chalkboard-60">
<span>
{project.kclFileCount !== undefined ? (
<>
<span data-testid="project-file-count">{project.kclFileCount}</span>{' '}
个模型文件
</>
) : (
'CAD 项目'
)}
</span>
<span className="truncate">
最近编辑{' '}
<span data-testid="project-edit-date">
{displayedProject.modified
? getDisplayedTime(displayedProject.modified)
: '暂无记录'}
</span>
</span>
</div>`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` title="Delete Project"`,
` title="删除项目"`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` <p className="my-4 text-wrap break-words">
This will permanently delete "{projectName || 'this file'}
".
</p>
<p className="my-4 text-wrap break-words">
Are you sure you want to delete "{projectName || 'this file'}
"? This action cannot be undone.
</p>`,
` <p className="my-4 text-wrap break-words">
确定删除“{projectName || '此项目'}”吗?
</p>
<p className="my-4 text-wrap break-words text-sm text-chalkboard-70 dark:text-chalkboard-30">
项目及其全部模型文件将被永久删除,此操作无法撤销。
</p>`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` title={projectName}
titleText={projectName}`,
` title={
<span className="flex min-w-0 items-center justify-between gap-2">
<span className="truncate font-semibold">{projectName}</span>
{canOpen && (
<span className="flex-none text-xs font-medium text-primary opacity-0 transition-opacity group-hover:opacity-100">
继续设计
</span>
)}
</span>
}
titleText={projectName}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` thumbnailUrl={imageUrl}
badges={badges}`,
` thumbnailUrl={imageUrl}
thumbnailAlt={projectName ? \`\${projectName} 的模型预览\` : 'CAD 项目预览'}
thumbnailFallback={
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-chalkboard-50 dark:text-chalkboard-50">
<CustomIcon name="model" className="h-8 w-8" />
<span className="text-xs">暂无模型预览</span>
</div>
}
badges={badges}`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` Rename project`,
` 重命名`
);
replaceSource(
"src/components/AppProjectCard/AppProjectCard.tsx",
` Delete project`,
` 删除项目`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` displayName: \`Create project\`,
description: 'Create a project',`,
` displayName: \`新建 CAD 项目\`,
description: '输入项目名称即可开始,设计内容会自动保存。',`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` displayName: 'Title',
required: true,`,
` displayName: '项目名称',
description: '使用容易识别的名称,例如“五金连接安装板”。',
required: true,`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` displayName: 'Library',
required: () => createProjectLibraryOptions().length > 1,`,
` displayName: '保存位置',
required: () => createProjectLibraryOptions().length > 1,`
);
replaceSource(
"src/lib/commandBarConfigs/projectsCommandConfig.ts",
` defaultValue: defaultProjectFolderNameSnapshot,`,
` defaultValue: () => '',`
);
replaceSource(
"src/components/CommandBar/CommandBarBasicInput.tsx",
` placeholder="Enter a value"`,
` placeholder={
commandBarState.context.selectedCommand?.name === 'Create project'
? '例如:五金连接安装板'
: '请输入'
}`
);
replaceSource( replaceSource(
"src/components/environment/Environment.tsx", "src/components/environment/Environment.tsx",
" new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? ''),", " new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? ''),",

View File

@ -3,8 +3,39 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const latestReviewedOfficialCommit =
"f2b345dbb56a851e10d8a742732ab73ee139f025";
test.describe("HiCAD embedded startup", () => { test.describe("HiCAD embedded startup", () => {
test("pins the latest reviewed official Zoo Design Studio source", () => {
const vendorScript = fs.readFileSync(
path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
"utf8"
);
expect(vendorScript).toContain(latestReviewedOfficialCommit);
});
test("uses the website favicon across embedded browser surfaces", () => {
const vendorScript = fs.readFileSync(
path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
"utf8"
);
expect(vendorScript).toContain(
'const brandedFaviconUrl = "/api/favicon?surface=hicad"'
);
expect(vendorScript).toContain(
'` <link rel="icon" href="${brandedFaviconUrl}" />`'
);
expect(vendorScript).toContain(
'` <link rel="apple-touch-icon" href="${brandedFaviconUrl}" />`'
);
expect(vendorScript).toMatch(
/manifest\.icons = \[\s*\{\s*src: brandedFaviconUrl,\s*sizes: "any",/
);
});
test("suppresses the upstream onboarding chooser in embedded mode", () => { test("suppresses the upstream onboarding chooser in embedded mode", () => {
const vendorScript = fs.readFileSync( const vendorScript = fs.readFileSync(
path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"), path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),

View File

@ -0,0 +1,778 @@
import { expect, test } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'
import {
buildExplodeOffsets,
createBomCsv,
createEngineeringSheetHtml,
createSnapshotDataUrl,
detectPanelHoles,
filterManufacturingBodies,
getOverallBounds,
groupBodiesIntoBom,
localizePartName,
parseManufacturingLengthInMillimeters,
reconcileManufacturingDimensions,
type BodyMeasurement,
type CircularEdgeSample,
type ManufacturingLengthParameter,
} from '../../vendor/zoo-modeling-app/manufacturing'
const bodies: BodyMeasurement[] = [
{
id: 'left-leg',
name: 'leg001',
center: { x: -50, y: 0, z: 20 },
dimensions: { x: 10, y: 10, z: 80 },
volume: 8_000,
surfaceArea: 3_400,
},
{
id: 'right-leg',
name: 'leg002',
center: { x: 50, y: 0, z: 20 },
dimensions: { x: 10, y: 10, z: 80 },
volume: 8_000,
surfaceArea: 3_400,
},
{
id: 'top',
name: 'tableTop',
center: { x: 0, y: 0, z: 65 },
dimensions: { x: 120, y: 60, z: 12 },
volume: 86_400,
surfaceArea: 18_720,
},
]
function circularEdge(
centerX: number,
centerY: number,
z: number,
radius: number
): CircularEdgeSample {
return {
controlPoints: [
{ x: centerX + radius, y: centerY, z },
{ x: centerX, y: centerY + radius, z },
{ x: centerX - radius, y: centerY, z },
{ x: centerX, y: centerY - radius, z },
],
length: Math.PI * radius * 2,
}
}
function hardwareAssemblyFixture() {
const holeCenters = [
[-160, -100],
[160, -100],
[-160, 100],
[160, 100],
] as const
const panel: BodyMeasurement = {
id: 'mounting-panel',
name: 'mountingPanel',
center: { x: 0, y: 0, z: 9 },
dimensions: { x: 400, y: 300, z: 18 },
holes: detectPanelHoles(
{
id: 'mounting-panel',
name: 'mountingPanel',
center: { x: 0, y: 0, z: 9 },
dimensions: { x: 400, y: 300, z: 18 },
},
holeCenters.flatMap(([x, y]) => [
circularEdge(x, y, 0, 4),
circularEdge(x, y, 18, 4),
])
),
}
const plate: BodyMeasurement = {
id: 'connector-plate',
name: 'connectorPlate',
center: { x: 0, y: 0, z: 19.5 },
dimensions: { x: 360, y: 260, z: 3 },
holes: detectPanelHoles(
{
id: 'connector-plate',
name: 'connectorPlate',
center: { x: 0, y: 0, z: 19.5 },
dimensions: { x: 360, y: 260, z: 3 },
},
holeCenters.flatMap(([x, y]) => [
circularEdge(x, y, 18, 4),
circularEdge(x, y, 21, 4),
])
),
}
const bolts = holeCenters.map(
([x, y], index): BodyMeasurement => ({
id: `connector-bolt-${index + 1}`,
name: `connectorBolt${index + 1}`,
center: { x, y, z: 11 },
dimensions: { x: 8, y: 8, z: 30 },
})
)
const washers = holeCenters.map(
([x, y], index): BodyMeasurement => ({
id: `washer-${index + 1}`,
name: `washer${index + 1}`,
center: { x, y, z: 22 },
dimensions: { x: 18, y: 18, z: 2 },
holes: [
{
center: { x, y, z: 22 },
diameter: 8,
axis: 'z',
type: 'through-hole',
depth: 2,
},
],
})
)
return [panel, plate, ...bolts, ...washers]
}
test.describe('HiCAD manufacturing extension', () => {
test('keeps the extension isolated behind one official registration point', () => {
const projectRoot = process.cwd()
const vendorScript = fs.readFileSync(
path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
'utf8'
)
expect(vendorScript).toContain(
'process.env.HICAD_MANUFACTURING_EXTENSION === "1"'
)
expect(vendorScript).toContain(
'vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx'
.split('/')
.at(-1)
)
expect(vendorScript).toContain(
"import { HicadManufacturingSlot } from '@src/components/HicadManufacturingSlot'"
)
expect(vendorScript).toContain(
"<HicadManufacturingSlot projectName={projectName ?? 'HiCAD 模型'} />"
)
const slot = fs.readFileSync(
path.join(
projectRoot,
'vendor/zoo-modeling-app/HicadManufacturingSlot.tsx.template'
),
'utf8'
)
expect(slot).toContain('lazy(() =>')
expect(slot).toContain('ManufacturingSlotBoundary')
const launcher = fs.readFileSync(
path.join(
projectRoot,
'vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template'
),
'utf8'
)
expect(launcher).toContain("document.querySelectorAll('canvas')")
expect(launcher).toContain('left: Math.round(canvas.rect.left + 16)')
expect(launcher).toContain('className="fixed z-20')
expect(launcher).toContain("lazy(() =>")
expect(launcher).toContain('ManufacturingBoundary')
const panel = fs.readFileSync(
path.join(
projectRoot,
'vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template'
),
'utf8'
)
expect(panel).toContain("type: 'scene_get_entity_ids'")
expect(panel).toContain("filter: ['solid3d']")
expect(panel).toContain("readBoundingBox([])")
expect(panel).toContain('engineFailureMessage(response)')
})
test('creates non-zero reversible offsets for every independent body', () => {
const offsets = buildExplodeOffsets(bodies)
expect(offsets).toHaveLength(3)
expect(new Set(offsets.map((offset) => offset.id)).size).toBe(3)
expect(
offsets.every(
({ offset }) => Math.hypot(offset.x, offset.y, offset.z) > 0
)
).toBe(true)
expect(buildExplodeOffsets([bodies[0]])).toEqual([])
})
test('converts evaluated KCL length units to millimeters', () => {
expect(parseManufacturingLengthInMillimeters('120 cm')).toBe(1200)
expect(parseManufacturingLengthInMillimeters('1.2m')).toBe(1200)
expect(parseManufacturingLengthInMillimeters('47.24409449 in')).toBeCloseTo(
1200,
5
)
expect(parseManufacturingLengthInMillimeters('1200')).toBeUndefined()
expect(parseManufacturingLengthInMillimeters('90 deg')).toBeUndefined()
})
test('groups matching parts and exports a UTF-8 BOM', () => {
const rows = groupBodiesIntoBom(bodies)
const legRow = rows.find((row) => row.name === '支腿')
expect(rows).toHaveLength(2)
expect(legRow?.quantity).toBe(2)
expect(legRow?.bodyIds).toEqual(['left-leg', 'right-leg'])
const csv = createBomCsv('工作台', rows)
expect(csv.startsWith('\uFEFF')).toBe(true)
expect(csv).toContain('"工作台"')
expect(csv).toContain('"生产工艺"')
})
test('filters helper solids and groups directional leg names', () => {
const stoolBodies: BodyMeasurement[] = [
{
id: 'template',
name: 'unitBox',
center: { x: 0, y: 0, z: 0 },
dimensions: { x: 1, y: 1, z: 1 },
},
{
id: 'seat',
name: 'seatBody',
center: { x: 0, y: 0, z: 310 },
dimensions: { x: 300, y: 220, z: 20 },
},
...['frontLeftLeg', 'frontRightLeg', 'backLeftLeg', 'backRightLeg'].map(
(name, index): BodyMeasurement => ({
id: `leg-${index}`,
name,
center: { x: index % 2 ? 100 : -100, y: index < 2 ? -70 : 70, z: 150 },
dimensions: { x: 30, y: 30, z: 300 },
})
),
]
const filtered = filterManufacturingBodies(stoolBodies)
const rows = groupBodiesIntoBom(filtered)
expect(filtered).toHaveLength(5)
expect(rows).toHaveLength(2)
expect(rows.find((row) => row.name === '支腿')?.quantity).toBe(4)
expect(rows.find((row) => row.name === '座板')?.quantity).toBe(1)
expect(rows.some((row) => row.name === 'unitBox')).toBe(false)
})
test('reconciles transformed Safari measurements and groups source instances', async ({
page,
}, testInfo) => {
const transformedBodies: BodyMeasurement[] = [
...[
['rearRightStudSource', 202.52],
['rearLeftStudSource', 188.56],
['frontRightStudSource', 165.4],
['frontLeftStud', 178.53],
].map(
([name, volume], index): BodyMeasurement => ({
id: `stud-${index}`,
name: String(name),
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
dimensions: { x: 6, y: 6, z: 10 },
volume: Number(volume),
})
),
...[
'rearRightLegSource',
'rearLeftLegSource',
'frontRightLegSource',
'frontLeftLeg',
].map(
(name, index): BodyMeasurement => ({
id: `leg-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
dimensions: { x: 60, y: 60, z: 750 },
volume: 1_463_300 + index * 12,
})
),
{
id: 'tabletop',
name: 'tableTop',
center: { x: 0, y: 0, z: 762.5 },
dimensions: { x: 1440, y: 720, z: 25.1 },
volume: 17_976_779.67,
},
]
const reconciled = reconcileManufacturingDimensions(transformedBodies)
const rows = groupBodiesIntoBom(reconciled)
expect(rows).toHaveLength(3)
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
quantity: 4,
dimensions: { x: 5, y: 5, z: 10 },
})
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
quantity: 4,
dimensions: { x: 50, y: 50, z: 750 },
})
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
quantity: 1,
dimensions: { x: 1200, y: 600, z: 25 },
})
const html = createEngineeringSheetHtml({
projectName: '圆腿桌',
generatedAt: '2026-07-28 10:00',
overallDimensions: { x: 1200, y: 600, z: 775 },
bodies: reconciled,
bomRows: rows,
})
expect(html).toContain('data-cross-section="circular"')
expect(html).toContain('class="circular-part-profile"')
expect(html).toContain('data-profile="circular"')
expect(html).toContain('直径 Ø50 mm')
expect(html).toContain('Ø50 mm')
await page.setViewportSize({ width: 1440, height: 1000 })
await page.setContent(html)
await expect(page.locator('.circular-part-profile')).toHaveCount(2)
const legProfile = page
.locator('.part-detail[data-cross-section="circular"]')
.filter({ hasText: '支腿' })
.locator('.circular-part-profile')
const profileBounds = await legProfile.boundingBox()
expect(profileBounds).not.toBeNull()
expect(Math.abs((profileBounds?.width ?? 0) - (profileBounds?.height ?? 0))).toBeLessThan(
1
)
await page.screenshot({
fullPage: true,
path: testInfo.outputPath('safari-round-leg-engineering-sheet.png'),
})
})
test('reconciles mixed assembly bounds without requiring one shared scale', () => {
const mixedBrowserBodies: BodyMeasurement[] = [
...[
'rearRightStudSource',
'rearLeftStudSource',
'frontRightStudSource',
'frontLeftStudSource',
].map(
(name, index): BodyMeasurement => ({
id: `mixed-stud-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
dimensions: { x: 5, y: 5, z: 10 },
volume: 196.35,
})
),
...[
'rearRightLegSource',
'rearLeftLegSource',
'frontRightLegSource',
'frontLeftLegSource',
].map(
(name, index): BodyMeasurement => ({
id: `mixed-leg-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
dimensions: { x: 49, y: 49, z: 750 },
volume: 1_463_167.94,
})
),
{
id: 'mixed-tabletop',
name: 'tableTopAssembly',
center: { x: 0, y: 0, z: 762.5 },
dimensions: { x: 1440, y: 720, z: 25.2 },
volume: 17_976_787.12,
},
]
const reconciled = reconcileManufacturingDimensions(mixedBrowserBodies)
const rows = groupBodiesIntoBom(reconciled)
expect(rows).toHaveLength(3)
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
quantity: 4,
dimensions: { x: 5, y: 5, z: 10 },
})
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
quantity: 4,
dimensions: { x: 50, y: 50, z: 750 },
})
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
quantity: 1,
dimensions: { x: 1200, y: 600, z: 25 },
})
expect(
reconciled.find((body) => body.id === 'mixed-tabletop')?.holes
).toMatchObject([
{ type: 'blind-hole', diameter: 5, depth: 10 },
{ type: 'blind-hole', diameter: 5, depth: 10 },
{ type: 'blind-hole', diameter: 5, depth: 10 },
{ type: 'blind-hole', diameter: 5, depth: 10 },
])
expect(rows.some((row) => row.name === 'tableTopAssembly')).toBe(false)
})
test('does not reinterpret a solid square leg as a round rod', () => {
const [leg] = reconcileManufacturingDimensions([
{
id: 'square-leg',
name: 'leg',
center: { x: 0, y: 0, z: 375 },
dimensions: { x: 50, y: 50, z: 750 },
volume: 1_875_000,
},
])
expect(leg.dimensions).toEqual({ x: 50, y: 50, z: 750 })
expect(leg.dimensionSource).toBe('engine')
})
test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => {
const coldBrowserBodies: BodyMeasurement[] = [
...[
'rearRightStudSource',
'rearLeftStudSource',
'frontRightStudSource',
'frontLeftStud',
].map(
(name, index): BodyMeasurement => ({
id: `cold-stud-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
dimensions: { x: 6, y: 6, z: 10 },
})
),
...[
'rearRightLegSource',
'rearLeftLegSource',
'frontRightLegSource',
'frontLeftLeg',
].map(
(name, index): BodyMeasurement => ({
id: `cold-leg-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
dimensions: { x: 60, y: 60, z: 750 },
})
),
{
id: 'cold-tabletop',
name: 'tableTop',
center: { x: 0, y: 0, z: 762.5 },
dimensions: { x: 1440, y: 720, z: 25.1 },
},
]
const parameters: ManufacturingLengthParameter[] = [
{ name: 'tableLength', valueMm: 1200 },
{ name: 'tableWidth', valueMm: 600 },
{ name: 'tableTopThickness', valueMm: 25 },
{ name: 'legDiameter', valueMm: 50 },
{ name: 'legHeight', valueMm: 750 },
{ name: 'studDiameter', valueMm: 5 },
{ name: 'studHeight', valueMm: 10 },
{ name: 'holeDiameter', valueMm: 5 },
{ name: 'holeDepth', valueMm: 10 },
{ name: 'holeInset', valueMm: 50 },
{ name: 'unrelatedCabinetWidth', valueMm: 900 },
]
const reconciled = reconcileManufacturingDimensions(
coldBrowserBodies,
parameters
)
const rows = groupBodiesIntoBom(reconciled)
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
quantity: 4,
dimensions: { x: 5, y: 5, z: 10 },
})
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
quantity: 4,
dimensions: { x: 50, y: 50, z: 750 },
})
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
quantity: 1,
dimensions: { x: 1200, y: 600, z: 25 },
})
const tabletop = reconciled.find((body) => body.id === 'cold-tabletop')
expect(tabletop?.holes).toHaveLength(4)
expect(
tabletop?.holes?.every(
(hole) =>
hole.type === 'blind-hole' &&
hole.diameter === 5 &&
hole.depth === 10
)
).toBe(true)
expect(
tabletop?.holes
?.map((hole) => [
Math.abs(hole.center.x - (tabletop?.center.x ?? 0)),
Math.abs(hole.center.y - (tabletop?.center.y ?? 0)),
])
.every(([fromCenterX, fromCenterY]) => fromCenterX === 550 && fromCenterY === 250)
).toBe(true)
expect(
reconciled.every(
(body) =>
body.dimensionSource === 'kcl-parameters' &&
body.engineDimensions !== undefined &&
body.dimensionReferences &&
body.dimensionReferences.length >= 2
)
).toBe(true)
const html = createEngineeringSheetHtml({
projectName: '冷启动孔位桌',
generatedAt: '2026-07-28 12:00',
overallDimensions: { x: 1200, y: 600, z: 775 },
bodies: reconciled,
bomRows: rows,
})
expect(html).toContain('4 × Ø5')
expect(html).toContain('Ø5 盲孔,深 10 mm')
})
test('keeps a square leg end view rectangular', () => {
const squareLeg: BodyMeasurement = {
id: 'square-leg',
name: 'leg',
center: { x: 0, y: 0, z: 375 },
dimensions: { x: 50, y: 50, z: 750 },
volume: 1_875_000,
}
const html = createEngineeringSheetHtml({
projectName: '方腿桌',
generatedAt: '2026-07-28 10:00',
overallDimensions: { x: 50, y: 50, z: 750 },
bodies: [squareLeg],
bomRows: groupBodiesIntoBom([squareLeg]),
})
expect(html).toContain('data-cross-section="rectangular"')
expect(html).not.toContain('class="circular-part-profile"')
})
test('localizes the hardware validation assembly for a Chinese BOM', () => {
expect(localizePartName('seatBody')).toBe('座板')
expect(localizePartName('tableBody')).toBe('桌体')
expect(localizePartName('mountingPanel')).toBe('安装基板')
expect(localizePartName('connectorPlate')).toBe('连接片')
expect(localizePartName('connectorBolt4')).toBe('连接螺栓')
expect(localizePartName('washer3')).toBe('垫圈')
})
test('builds a production-ready hardware BOM and detailed hole schedule', async ({
page,
}, testInfo) => {
const assembly = hardwareAssemblyFixture()
const rows = groupBodiesIntoBom(assembly)
expect(assembly).toHaveLength(10)
expect(rows).toHaveLength(4)
expect(rows.find((row) => row.name === '安装基板')?.holes).toHaveLength(4)
expect(rows.find((row) => row.name === '连接片')?.holes).toHaveLength(4)
expect(rows.find((row) => row.name === '连接螺栓')?.quantity).toBe(4)
expect(rows.find((row) => row.name === '垫圈')?.quantity).toBe(4)
const html = createEngineeringSheetHtml({
projectName: '五金连接安装板',
generatedAt: '2026-07-27 22:30',
overallDimensions: { x: 400, y: 300, z: 30 },
bodies: assembly,
bomRows: rows,
})
expect(html).toContain('安装基板')
expect(html).toContain('连接片')
expect(html).toContain('连接螺栓')
expect(html).toContain('垫圈')
expect(html).toContain('H1:Ø8 通孔')
expect(html).toContain('横向中心孔距 320 mm')
expect(html).toContain('纵向中心孔距 200 mm')
expect(html.match(/class="part-detail"/g)).toHaveLength(4)
expect(html).toContain('data-generated-exploded-view="true"')
expect(html.match(/class="explode-part"/g)).toHaveLength(10)
expect(html).not.toContain('暂无爆炸图快照')
expect(html.match(/class="part-front-view"/g)).toHaveLength(4)
expect(html.match(/class="part-side-view"/g)).toHaveLength(4)
expect(html).toContain('正面图')
expect(html).toContain('侧面图')
await page.setViewportSize({ width: 1600, height: 900 })
await page.setContent(html)
expect(
await page.locator('.part-detail').evaluateAll((parts) =>
parts.map((part) => part.textContent)
)
).toEqual(
expect.arrayContaining([
expect.stringContaining('安装基板'),
expect.stringContaining('连接片'),
expect.stringContaining('连接螺栓'),
expect.stringContaining('垫圈'),
])
)
await page.screenshot({
path: testInfo.outputPath('hardware-engineering-sheet.png'),
fullPage: true,
})
})
test('renders every BOM part without limiting the number of process drawings', () => {
const manyParts: BodyMeasurement[] = Array.from(
{ length: 7 },
(_, index) => ({
id: `custom-part-${index + 1}`,
name: `自定义零件${index + 1}`,
center: { x: index * 30, y: 0, z: 0 },
dimensions: { x: 20 + index, y: 12 + index, z: 4 + index },
})
)
const rows = groupBodiesIntoBom(manyParts)
const html = createEngineeringSheetHtml({
projectName: '多配件生产图',
generatedAt: '2026-07-27 23:00',
overallDimensions: getOverallBounds(manyParts).dimensions,
bodies: manyParts,
bomRows: rows,
})
expect(rows).toHaveLength(7)
expect(html.match(/class="part-detail"/g)).toHaveLength(7)
expect(html.match(/class="part-front-view"/g)).toHaveLength(7)
expect(html.match(/class="part-side-view"/g)).toHaveLength(7)
})
test('normalizes URL-safe engine snapshots for browser image rendering', () => {
expect(createSnapshotDataUrl('iVBORw0KGgo_8A')).toBe(
'data:image/png;base64,iVBORw0KGgo/8A=='
)
expect(
createSnapshotDataUrl('data:image/png;base64,iVBORw0KGgo_8A')
).toBe('data:image/png;base64,iVBORw0KGgo/8A==')
})
test('detects through holes on a panel and deduplicates both face edges', () => {
const panel: BodyMeasurement = {
id: 'side-panel',
name: 'sidePanel',
center: { x: 0, y: 0, z: 9 },
dimensions: { x: 400, y: 300, z: 18 },
}
const holeCenters = [
[-160, -100],
[160, -100],
[-160, 100],
[160, 100],
]
const edgeSamples = holeCenters.flatMap(([x, y]) => [
circularEdge(x, y, 0, 4),
circularEdge(x, y, 18, 4),
])
const holes = detectPanelHoles(panel, edgeSamples)
expect(holes).toHaveLength(4)
expect(holes.every((hole) => hole.axis === 'z')).toBe(true)
expect(holes.every((hole) => hole.diameter === 8)).toBe(true)
expect(holes.every((hole) => hole.depth === 18)).toBe(true)
const rows = groupBodiesIntoBom([{ ...panel, holes }])
const html = createEngineeringSheetHtml({
projectName: '五金连接侧板',
generatedAt: '2026-07-27 22:00',
overallDimensions: panel.dimensions,
bodies: [{ ...panel, holes }],
bomRows: rows,
})
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
expect(html).toContain('4 × Ø8')
expect(html).toContain('H1:Ø8 通孔')
expect(html).toContain('距左 40')
expect(html).toContain('距下 50')
expect(html).toContain('横向中心孔距 320 mm')
expect(html).toContain('纵向中心孔距 200 mm')
})
test('builds a printable dimensioned engineering sheet safely', () => {
const rows = groupBodiesIntoBom(bodies)
const html = createEngineeringSheetHtml({
projectName: '<工作台>',
generatedAt: '2026-07-27 20:00',
overallDimensions: { x: 120, y: 60, z: 92 },
bodies,
brandName: '由壹点AI',
brandLogoUrl: '/api/favicon?surface=hicad',
snapshotDataUrl: 'data:image/png;base64,abc',
bomRows: rows,
})
expect(html).toContain('&lt;工作台&gt;')
expect(html).not.toContain('<工作台>')
expect(html).toContain('正视图')
expect(html).toContain('俯视图')
expect(html).toContain('右视图')
expect(html.match(/class="projection-part"/g)).toHaveLength(9)
expect(html).toContain('data-part-name="支腿"')
expect(html).toContain('data-part-name="台面板"')
expect(html).toContain('120 mm')
expect(html).toContain('配件明细表(BOM)')
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
expect(html.match(/class="part-detail"/g)).toHaveLength(2)
expect(html).toContain('由壹点AI')
expect(html).toContain('/api/favicon?surface=hicad')
expect(html).toContain('@page { size: A3 landscape;')
expect(html).toContain(
'grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr) minmax(180px, 1fr)'
)
expect(html).toContain('h1 { overflow: hidden; margin: 0; font-size: 18px;')
})
test('renders the engineering sheet without horizontal clipping', async ({
page,
}, testInfo) => {
const rows = groupBodiesIntoBom(bodies)
const html = createEngineeringSheetHtml({
projectName: '工作台生产图',
generatedAt: '2026-07-27 20:00',
overallDimensions: { x: 120, y: 60, z: 92 },
bodies,
brandName: '由壹点AI',
brandLogoUrl:
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="80"%3E%3Crect width="80" height="80" fill="%23111827"/%3E%3Cpath d="M20 20h40v40H20z" fill="white"/%3E%3C/svg%3E',
bomRows: rows,
notes:
'材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。',
})
await page.setViewportSize({ width: 1600, height: 900 })
await page.setContent(html)
const layout = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
tableCount: document.querySelectorAll('table').length,
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
partDetailCount: document.querySelectorAll('.part-detail').length,
projectionPartCount:
document.querySelectorAll('.projection-part').length,
brandCount: document.querySelectorAll('.brand-name').length,
}))
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
expect(layout.tableCount).toBe(1)
expect(layout.dimensionViewCount).toBe(3)
expect(layout.partDetailCount).toBe(rows.length)
expect(layout.projectionPartCount).toBe(bodies.length * 3)
expect(layout.brandCount).toBe(1)
await page.screenshot({
path: testInfo.outputPath('engineering-sheet.png'),
fullPage: true,
})
})
})

View File

@ -0,0 +1,66 @@
import fs from 'node:fs'
import path from 'node:path'
import { expect, test } from '@playwright/test'
const projectRoot = process.cwd()
test.describe('HiCAD project management experience', () => {
test('vendors the project dashboard and autosave status', () => {
const script = fs.readFileSync(
path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
'utf8'
)
const experience = fs.readFileSync(
path.join(
projectRoot,
'vendor/zoo-modeling-app/HicadProjectExperience.tsx.template'
),
'utf8'
)
expect(script).toContain('HicadProjectExperience.tsx.template')
expect(script).toContain('projectCount={scopedHomeProjectEntries.length}')
expect(script).toContain('thumbnailFallback')
expect(script).toContain('最近编辑')
expect(experience).toContain('我的 CAD 项目')
expect(experience).toContain('编辑内容会自动保存')
expect(experience).toContain('自动保存已开启')
expect(experience).toContain('data-testid="hicad-create-project"')
expect(experience).toContain('data-testid="hicad-create-from-sample"')
expect(experience).toContain('data-testid="hicad-open-project-list"')
expect(experience).toContain('data-testid="hicad-create-blank-project"')
expect(experience).toContain('?create=blank')
expect(experience).toContain('[data-testid="home-new-file"]')
expect(script).toContain('<HicadEditorProjectActions')
expect(script).toContain('lsp.onProjectClose')
expect(script).toContain('kclManager.switchedFiles = true')
expect(script).toContain('" if (await webHomeRouteEnabled(app))')
expect(script).toContain('" if (!window.electron && !(await webHomeRouteEnabled(app)))')
expect(script).toContain('"src/lib/app.ts"')
expect(script).toContain(` const enableProjectDirectoryCommands =
typeof window !== 'undefined'
`)
})
test('keeps new project creation simple and understandable', () => {
const script = fs.readFileSync(
path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
'utf8'
)
const localization = fs.readFileSync(
path.join(projectRoot, 'vendor/zoo-modeling-app/localization.ts'),
'utf8'
)
expect(script).toContain('新建 CAD 项目')
expect(script).toContain("displayName: '项目名称'")
expect(script).toContain('例如:五金连接安装板')
expect(script).toContain("defaultValue: () => ''")
expect(localization).toContain("['Submit', '确认']")
expect(localization).toContain("['Continue', '下一步']")
expect(localization).toContain("['Step back', '返回上一步']")
expect(localization).toContain('还没有项目,点击“新建项目”开始设计。')
expect(localization).toContain("[data-testid='home-sidebar']")
})
})

View File

@ -0,0 +1,148 @@
import {
Component,
lazy,
Suspense,
useEffect,
useState,
type ErrorInfo,
type ReactNode,
} from 'react'
const ManufacturingPanel = lazy(() =>
import('./HicadManufacturingPanel').then((module) => ({
default: module.HicadManufacturingPanel,
}))
)
class ManufacturingBoundary extends Component<
{
children: ReactNode
onClose: () => void
},
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error('[HiCAD Manufacturing] Extension failed safely', error, info)
}
render() {
if (!this.state.failed) return this.props.children
return (
<div
className="fixed inset-0 z-[100] grid place-items-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-label="工程工具加载失败"
>
<div className="w-full max-w-md border border-chalkboard-30 bg-chalkboard-10 p-6 shadow-2xl dark:border-chalkboard-70 dark:bg-chalkboard-100">
<h2 className="text-lg font-semibold">工程工具暂时无法打开</h2>
<p className="mt-2 text-sm text-chalkboard-60 dark:text-chalkboard-40">
HiCAD 主编辑器没有受到影响。关闭后可以继续建模并稍后重试。
</p>
<button
type="button"
className="mt-5 h-9 bg-chalkboard-100 px-4 text-sm font-medium text-chalkboard-10 dark:bg-chalkboard-10 dark:text-chalkboard-100"
onClick={this.props.onClose}
>
关闭
</button>
</div>
</div>
)
}
}
export function HicadManufacturingLauncher({
projectName,
}: {
projectName: string
}) {
const [active, setActive] = useState(false)
const [triggerPosition, setTriggerPosition] = useState({
left: 16,
bottom: 48,
})
useEffect(() => {
const updateTriggerPosition = () => {
const canvas = [...document.querySelectorAll('canvas')]
.map((element) => ({
element,
rect: element.getBoundingClientRect(),
}))
.filter(({ rect }) => rect.width > 280 && rect.height > 220)
.sort(
(left, right) =>
right.rect.width * right.rect.height -
left.rect.width * left.rect.height
)[0]
if (!canvas) return
setTriggerPosition({
left: Math.round(canvas.rect.left + 16),
bottom: Math.round(
Math.max(window.innerHeight - canvas.rect.bottom + 16, 48)
),
})
}
const observer = new ResizeObserver(updateTriggerPosition)
const observeCanvases = () =>
document.querySelectorAll('canvas').forEach((canvas) => observer.observe(canvas))
const mutationObserver = new MutationObserver(() => {
observeCanvases()
updateTriggerPosition()
})
observeCanvases()
mutationObserver.observe(document.body, { childList: true, subtree: true })
window.addEventListener('resize', updateTriggerPosition)
updateTriggerPosition()
return () => {
observer.disconnect()
mutationObserver.disconnect()
window.removeEventListener('resize', updateTriggerPosition)
}
}, [])
return (
<>
<button
type="button"
data-testid="hicad-manufacturing-trigger"
onClick={() => setActive(true)}
style={triggerPosition}
className="fixed z-20 h-9 border border-chalkboard-30 bg-chalkboard-10 px-3 text-sm font-medium shadow-sm hover:bg-chalkboard-20 dark:border-chalkboard-70 dark:bg-chalkboard-90 dark:hover:bg-chalkboard-80"
>
工程出图
</button>
{active && (
<ManufacturingBoundary onClose={() => setActive(false)}>
<Suspense
fallback={
<div className="fixed inset-0 z-[100] grid place-items-center bg-black/50">
<div className="border border-chalkboard-30 bg-chalkboard-10 px-5 py-4 text-sm shadow-xl dark:border-chalkboard-70 dark:bg-chalkboard-100">
正在加载工程工具…
</div>
</div>
}
>
<ManufacturingPanel
hideTrigger
initialOpen
onRequestClose={() => setActive(false)}
projectName={projectName}
/>
</Suspense>
</ManufacturingBoundary>
)}
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
import { Component, lazy, Suspense, type ErrorInfo, type ReactNode } from 'react'
const ManufacturingLauncher = lazy(() =>
import('./HicadManufacturingLauncher').then((module) => ({
default: module.HicadManufacturingLauncher,
}))
)
class ManufacturingSlotBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
componentDidCatch(error: unknown, info: ErrorInfo) {
console.error('[HiCAD Manufacturing] Launcher failed safely', error, info)
}
render() {
return this.state.failed ? null : this.props.children
}
}
export function HicadManufacturingSlot({
projectName,
}: {
projectName: string
}) {
return (
<ManufacturingSlotBoundary>
<Suspense fallback={null}>
<ManufacturingLauncher projectName={projectName} />
</Suspense>
</ManufacturingSlotBoundary>
)
}

View File

@ -0,0 +1,125 @@
import { CustomIcon } from '@src/components/CustomIcon'
import { PATHS } from '@src/lib/paths'
import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
export function HicadProjectDashboardHeading({
projectCount,
}: {
projectCount: number
}) {
const location = useLocation()
const navigate = useNavigate()
const startCreatingProject = () => {
document
.querySelector<HTMLButtonElement>('[data-testid="home-new-file"]')
?.click()
}
const startFromSample = () => {
document
.querySelector<HTMLButtonElement>(
'[data-testid="home-create-from-sample"]'
)
?.click()
}
useEffect(() => {
const shouldCreateBlankProject =
new URLSearchParams(location.search).get('create') === 'blank'
if (!shouldCreateBlankProject) return
const timer = window.setTimeout(() => {
startCreatingProject()
void navigate(PATHS.HOME, { replace: true })
}, 250)
return () => window.clearTimeout(timer)
}, [location.search, navigate])
return (
<div
className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
data-testid="hicad-project-dashboard-heading"
>
<div className="min-w-0">
<p className="mb-1 text-xs font-medium text-primary">HiCAD 项目空间</p>
<h1 className="text-3xl font-bold">我的 CAD 项目</h1>
<p className="mt-1 text-sm text-chalkboard-70 dark:text-chalkboard-30">
共 {projectCount} 个项目,编辑内容会自动保存
</p>
</div>
<div className="flex flex-none items-center gap-2">
<button
type="button"
onClick={startFromSample}
className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-chalkboard-30 bg-chalkboard-10 px-3 text-sm font-medium text-chalkboard-90 transition-colors hover:border-primary/50 hover:bg-primary/5 dark:border-chalkboard-70 dark:bg-chalkboard-90 dark:text-chalkboard-20"
data-testid="hicad-create-from-sample"
>
<CustomIcon name="file" className="h-4 w-4" />
从示例开始
</button>
<button
type="button"
onClick={startCreatingProject}
className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-primary bg-primary px-4 text-sm font-semibold text-white transition-colors hover:brightness-110 focus-visible:outline-appForeground"
data-testid="hicad-create-project"
>
<CustomIcon name="plus" className="h-5 w-5" />
新建项目
</button>
</div>
</div>
)
}
export function HicadAutosaveStatus() {
return (
<span
className="mr-1 inline-flex h-7 items-center gap-1.5 rounded-md border border-chalkboard-30 bg-chalkboard-10/80 px-2 text-xs text-chalkboard-70 dark:border-chalkboard-70 dark:bg-chalkboard-100/50 dark:text-chalkboard-30"
data-testid="hicad-autosave-status"
title="项目内容会自动保存到当前浏览器"
>
<span className="h-1.5 w-1.5 rounded-full bg-succeed-60" />
自动保存已开启
</span>
)
}
export function HicadEditorProjectActions({
onOpenProjectList,
onCreateBlankProject,
}: {
onOpenProjectList: () => void
onCreateBlankProject: () => void
}) {
const buttonClassName =
'inline-flex h-7 items-center justify-center gap-1.5 rounded-md border border-chalkboard-30 bg-chalkboard-10/80 px-2 text-xs font-medium text-chalkboard-80 transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-primary focus-visible:outline-appForeground dark:border-chalkboard-70 dark:bg-chalkboard-100/50 dark:text-chalkboard-30 dark:hover:border-primary/50 dark:hover:text-primary'
return (
<div
className="ml-2 flex flex-none items-center gap-1.5"
data-testid="hicad-editor-project-actions"
>
<a
href={`#${PATHS.HOME}`}
className={buttonClassName}
onClick={onOpenProjectList}
data-testid="hicad-open-project-list"
title="查看和打开历史项目"
>
<CustomIcon name="folder" className="h-4 w-4" />
项目列表
</a>
<a
href={`#${PATHS.HOME}?create=blank`}
className={buttonClassName}
onClick={onCreateBlankProject}
data-testid="hicad-create-blank-project"
title="新建空白 CAD 项目"
>
<CustomIcon name="plus" className="h-4 w-4" />
新建空白
</a>
</div>
)
}

34
vendor/zoo-modeling-app/README.md vendored Normal file
View File

@ -0,0 +1,34 @@
# HiCAD Official Source Extensions
This directory contains the local HiCAD extension layer applied on top of the
reviewed official `KittyCAD/modeling-app` source.
## Manufacturing extension
- `HicadManufacturingPanel.tsx` integrates engine measurements, reversible
exploded views, BOM editing, CSV export, snapshots, and printable production
sheets.
- `HicadManufacturingLauncher.tsx` lazy-loads the extension only after the user
clicks **工程出图** and contains a dedicated error boundary, so an extension
failure cannot take down the official HiCAD editor.
- `manufacturing.ts` contains the engine-independent geometry, BOM, and report
generation logic.
- `scripts/vendor-zoo-modeling-app.mjs` copies both files into the official
source and registers the panel once in `OpenedProject.tsx`.
The official source is otherwise left unchanged. The registration uses strict
source anchors: if a future official release moves the integration point, the
vendor build stops with `Official source changed; patch failed` instead of
silently dropping the feature.
## Updating the official source
1. Set `ZOO_MODELING_APP_REF` to the reviewed official commit.
2. Run `pnpm zoo-modeling-app:vendor`.
3. Run the HiCAD helper tests and the official source TypeScript check.
4. Verify model analysis, explode/restore, BOM export, and A3 print output before
deployment.
Models must contain at least two independent bodies for a true exploded view.
A single boolean-fused body can still produce dimensions and a one-row BOM but
cannot be separated without changing its CAD construction.

View File

@ -23,6 +23,8 @@ const translations = new Map<string, string>([
['Connected', '已连接'], ['Connected', '已连接'],
['Connecting and setting up scene...', '正在连接并初始化场景...'], ['Connecting and setting up scene...', '正在连接并初始化场景...'],
['Create a new project', '新建项目'], ['Create a new project', '新建项目'],
['Create project', '新建项目'],
['Create from a sample', '从示例开始'],
[ [
'Create a gear with 10 teeth and use sensible defaults for everything else...', 'Create a gear with 10 teeth and use sensible defaults for everything else...',
'创建一个 10 齿齿轮,其余参数使用合理默认值...', '创建一个 10 齿齿轮,其余参数使用合理默认值...',
@ -31,6 +33,7 @@ const translations = new Map<string, string>([
['Design using traditional point-and-click', '使用传统点击方式设计'], ['Design using traditional point-and-click', '使用传统点击方式设计'],
['Dismiss', '关闭'], ['Dismiss', '关闭'],
['Edit', '编辑'], ['Edit', '编辑'],
['Edited', '最近编辑'],
['Export', '导出'], ['Export', '导出'],
['Export part', '导出零件'], ['Export part', '导出零件'],
['Extrude', '拉伸'], ['Extrude', '拉伸'],
@ -67,9 +70,28 @@ const translations = new Map<string, string>([
['No selection.', '未选择。'], ['No selection.', '未选择。'],
['Offset Plane', '偏移基准面'], ['Offset Plane', '偏移基准面'],
['Open project', '打开项目'], ['Open project', '打开项目'],
['Project Libraries', '我的 CAD 项目'],
['Projects', '项目'],
['Project Files', '项目文件'], ['Project Files', '项目文件'],
['Publish', '发布'], ['Publish', '发布'],
['Redo', '重做'], ['Redo', '重做'],
['Rename project', '重命名'],
['Delete project', '删除项目'],
['Search projects', '搜索项目'],
['Sort by', '排序'],
['Name', '名称'],
['Last Modified', '最近编辑'],
['No projects found', '还没有项目'],
[', ready to make your first one?', ',点击“新建项目”开始设计。'],
[
'No projects found, ready to make your first one?',
'还没有项目,点击“新建项目”开始设计。',
],
['Submit', '确认'],
['Continue', '下一步'],
['Step back', '返回上一步'],
['Enter a value', '请输入'],
['Cancel', '取消'],
['Revolve', '旋转'], ['Revolve', '旋转'],
['Settings', '设置'], ['Settings', '设置'],
['Shell', '抽壳'], ['Shell', '抽壳'],
@ -158,7 +180,11 @@ function translateElement(element: Element) {
for (const attribute of translatableAttributes) { for (const attribute of translatableAttributes) {
const value = normalizeLookupKey(element.getAttribute(attribute) || '') const value = normalizeLookupKey(element.getAttribute(attribute) || '')
if (!value) continue if (!value) continue
const translated = translations.get(value) const translated =
translations.get(value) ||
(attribute === 'placeholder' && value.startsWith('Search projects')
? '搜索项目'
: undefined)
if (translated) element.setAttribute(attribute, translated) if (translated) element.setAttribute(attribute, translated)
} }
} }
@ -203,6 +229,36 @@ export function installHiCadLocalization() {
html[data-hicad-embedded='true'] [data-hicad-official-account-link] { html[data-hicad-embedded='true'] [data-hicad-official-account-link] {
display: none !important; display: none !important;
} }
html[data-hicad-embedded='true'] [data-testid='home-sidebar'],
html[data-hicad-embedded='true'] [data-testid='home-new-file'],
html[data-hicad-embedded='true'] [data-testid='environment-subdomain-bar'],
html[data-hicad-embedded='true'] [data-testid='cloud-sync-status'] {
display: none !important;
}
html[data-hicad-embedded='true'] .home-layout {
display: block !important;
column-gap: 2rem;
margin-top: clamp(1.5rem, 5vh, 4rem);
}
html[data-hicad-embedded='true'] [data-testid='home-header'] > p:has(
[data-testid='project-directory-settings-link']
) {
display: none !important;
}
html[data-hicad-embedded='true'] [data-testid='home-section'] {
margin-top: 1.5rem;
overflow-y: auto;
padding-bottom: 3rem;
}
html[data-hicad-embedded='true'] [data-testid='home-section'] > ul {
align-content: start;
}
html[data-hicad-embedded='true'] [data-testid='project-status-badge'] {
box-shadow: 0 1px 2px rgb(0 0 0 / 8%);
}
html[data-hicad-embedded='true'] [data-testid='project-directory-settings-link'] {
overflow-wrap: anywhere;
}
` `
document.head.appendChild(style) document.head.appendChild(style)

2212
vendor/zoo-modeling-app/manufacturing.ts vendored Normal file

File diff suppressed because it is too large Load Diff