Compare commits
23 Commits
main
...
codex/upgr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
513df646de | ||
|
|
1068072bd3 | ||
|
|
ce1e101ffc | ||
|
|
d2123de778 | ||
|
|
5c798c2214 | ||
|
|
a8de1d2d7c | ||
|
|
bde34dd5f7 | ||
|
|
4b42b8831e | ||
|
|
09502c18f7 | ||
|
|
d6fcd1233a | ||
|
|
592df97568 | ||
|
|
5fadb1ac61 | ||
|
|
81c3a64089 | ||
|
|
194762835f | ||
|
|
c028d2ad00 | ||
|
|
d93410b01b | ||
|
|
1f5aae3b86 | ||
|
|
3d0473bf65 | ||
|
|
36bcb3f5a5 | ||
|
|
1038adae46 | ||
|
|
891db63b70 | ||
|
|
c2bc6147c8 | ||
|
|
957192cc5c |
@ -4,7 +4,8 @@ import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
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 metadata: Metadata = {
|
||||
|
||||
@ -15,6 +15,7 @@ export default defineConfig({
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||
/routes\/hicad-embedded-performance\.test\.ts/,
|
||||
/routes\/hicad-manufacturing-extension\.test\.ts/,
|
||||
],
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
@ -18,7 +18,7 @@ import { cacheBustZooModelingAppAssets } from "./cache-bust-zoo-modeling-app-ass
|
||||
const repoUrl = "https://github.com/KittyCAD/modeling-app.git";
|
||||
const repoRef =
|
||||
process.env.ZOO_MODELING_APP_REF ||
|
||||
"6f87f4e66c07c67f09d8f7db284496a3ae3332e7";
|
||||
"f2b345dbb56a851e10d8a742732ab73ee139f025";
|
||||
const repoTarballUrls = [
|
||||
`https://ghfast.top/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 customizationDir = path.join(root, "vendor/zoo-modeling-app");
|
||||
const proxyToken = "hicad-session";
|
||||
const brandedFaviconUrl = "/api/favicon?surface=hicad";
|
||||
const enableManufacturingExtension =
|
||||
process.env.HICAD_MANUFACTURING_EXTENSION === "1";
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
@ -107,16 +110,54 @@ function replaceSource(relativePath, before, after) {
|
||||
if (!after) {
|
||||
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));
|
||||
}
|
||||
|
||||
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() {
|
||||
copyFileSync(
|
||||
path.join(customizationDir, "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(
|
||||
path.join(customizationDir, "localization.ts"),
|
||||
path.join(sourceDir, "src/lib/hicadLocalization.ts")
|
||||
@ -125,11 +166,26 @@ function patchOfficialSource() {
|
||||
path.join(customizationDir, "FitAllButton.tsx.template"),
|
||||
path.join(sourceDir, "src/components/FitAllButton.tsx")
|
||||
);
|
||||
copyFileSync(
|
||||
path.join(customizationDir, "HicadProjectExperience.tsx.template"),
|
||||
path.join(sourceDir, "src/components/HicadProjectExperience.tsx")
|
||||
);
|
||||
replaceSource(
|
||||
"index.html",
|
||||
' <meta name="description" content="An open-source CAD modeling tool from the future by Zoo." />',
|
||||
' <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(
|
||||
"index.html",
|
||||
' <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",
|
||||
""
|
||||
);
|
||||
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(
|
||||
"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 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(
|
||||
"src/network/websocketConnection.ts",
|
||||
"src/lib/engineConnection/websocketConnection.ts",
|
||||
" if (token) {",
|
||||
` if (token && token !== '${proxyToken}') {`
|
||||
);
|
||||
@ -216,6 +315,379 @@ function patchOfficialSource() {
|
||||
" {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(
|
||||
"src/components/environment/Environment.tsx",
|
||||
" new URL(env().VITE_KITTYCAD_WEBSOCKET_URL ?? ''),",
|
||||
|
||||
@ -3,8 +3,39 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const latestReviewedOfficialCommit =
|
||||
"f2b345dbb56a851e10d8a742732ab73ee139f025";
|
||||
|
||||
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", () => {
|
||||
const vendorScript = fs.readFileSync(
|
||||
path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
|
||||
|
||||
778
tests/routes/hicad-manufacturing-extension.test.ts
Normal file
778
tests/routes/hicad-manufacturing-extension.test.ts
Normal 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('<工作台>')
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
66
tests/routes/hicad-project-experience.test.ts
Normal file
66
tests/routes/hicad-project-experience.test.ts
Normal 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']")
|
||||
})
|
||||
})
|
||||
148
vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template
vendored
Normal file
148
vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template
vendored
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
1045
vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template
vendored
Normal file
1045
vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template
vendored
Normal file
File diff suppressed because it is too large
Load Diff
40
vendor/zoo-modeling-app/HicadManufacturingSlot.tsx.template
vendored
Normal file
40
vendor/zoo-modeling-app/HicadManufacturingSlot.tsx.template
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
125
vendor/zoo-modeling-app/HicadProjectExperience.tsx.template
vendored
Normal file
125
vendor/zoo-modeling-app/HicadProjectExperience.tsx.template
vendored
Normal 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
34
vendor/zoo-modeling-app/README.md
vendored
Normal 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.
|
||||
58
vendor/zoo-modeling-app/localization.ts
vendored
58
vendor/zoo-modeling-app/localization.ts
vendored
@ -23,6 +23,8 @@ const translations = new Map<string, string>([
|
||||
['Connected', '已连接'],
|
||||
['Connecting and setting up scene...', '正在连接并初始化场景...'],
|
||||
['Create a new project', '新建项目'],
|
||||
['Create project', '新建项目'],
|
||||
['Create from a sample', '从示例开始'],
|
||||
[
|
||||
'Create a gear with 10 teeth and use sensible defaults for everything else...',
|
||||
'创建一个 10 齿齿轮,其余参数使用合理默认值...',
|
||||
@ -31,6 +33,7 @@ const translations = new Map<string, string>([
|
||||
['Design using traditional point-and-click', '使用传统点击方式设计'],
|
||||
['Dismiss', '关闭'],
|
||||
['Edit', '编辑'],
|
||||
['Edited', '最近编辑'],
|
||||
['Export', '导出'],
|
||||
['Export part', '导出零件'],
|
||||
['Extrude', '拉伸'],
|
||||
@ -67,9 +70,28 @@ const translations = new Map<string, string>([
|
||||
['No selection.', '未选择。'],
|
||||
['Offset Plane', '偏移基准面'],
|
||||
['Open project', '打开项目'],
|
||||
['Project Libraries', '我的 CAD 项目'],
|
||||
['Projects', '项目'],
|
||||
['Project Files', '项目文件'],
|
||||
['Publish', '发布'],
|
||||
['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', '旋转'],
|
||||
['Settings', '设置'],
|
||||
['Shell', '抽壳'],
|
||||
@ -158,7 +180,11 @@ function translateElement(element: Element) {
|
||||
for (const attribute of translatableAttributes) {
|
||||
const value = normalizeLookupKey(element.getAttribute(attribute) || '')
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -203,6 +229,36 @@ export function installHiCadLocalization() {
|
||||
html[data-hicad-embedded='true'] [data-hicad-official-account-link] {
|
||||
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)
|
||||
|
||||
|
||||
2212
vendor/zoo-modeling-app/manufacturing.ts
vendored
Normal file
2212
vendor/zoo-modeling-app/manufacturing.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user