feat: add HiCAD manufacturing drawings

This commit is contained in:
134 2026-07-27 19:55:14 +08:00
parent c2bc6147c8
commit 891db63b70
6 changed files with 1182 additions and 0 deletions

View File

@ -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,

View File

@ -134,6 +134,14 @@ function patchOfficialSource() {
path.join(customizationDir, "Logo.tsx"),
path.join(sourceDir, "src/components/Logo.tsx")
);
copyFileSync(
path.join(customizationDir, "manufacturing.ts"),
path.join(sourceDir, "src/components/manufacturing.ts")
);
copyFileSync(
path.join(customizationDir, "HicadManufacturingPanel.tsx"),
path.join(sourceDir, "src/components/HicadManufacturingPanel.tsx")
);
copyFileSync(
path.join(customizationDir, "localization.ts"),
path.join(sourceDir, "src/lib/hicadLocalization.ts")
@ -189,6 +197,16 @@ function patchOfficialSource() {
"import { isDesktop } from '@src/lib/isDesktop'\n",
""
);
replaceSource(
"src/components/OpenedProject.tsx",
"import { AppHeader } from '@src/components/AppHeader'\n",
"import { AppHeader } from '@src/components/AppHeader'\nimport { HicadManufacturingPanel } from '@src/components/HicadManufacturingPanel'\n"
);
replaceSource(
"src/components/OpenedProject.tsx",
" <StatusBar\n",
" <HicadManufacturingPanel projectName={projectName ?? 'HiCAD 模型'} />\n <StatusBar\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 })`,

View File

@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'
import {
buildExplodeOffsets,
createBomCsv,
createEngineeringSheetHtml,
groupBodiesIntoBom,
type BodyMeasurement,
} 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,
},
]
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(
'vendor/zoo-modeling-app/HicadManufacturingPanel.tsx'
.split('/')
.at(-1)
)
expect(vendorScript).toContain(
"import { HicadManufacturingPanel } from '@src/components/HicadManufacturingPanel'"
)
expect(vendorScript).toContain(
"<HicadManufacturingPanel projectName={projectName ?? 'HiCAD 模型'} />"
)
})
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('groups matching parts and exports a UTF-8 BOM', () => {
const rows = groupBodiesIntoBom(bodies)
const legRow = rows.find((row) => row.name === 'leg')
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('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 },
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).toContain('120 mm')
expect(html).toContain('配件明细表(BOM)')
expect(html).toContain('@page { size: A3 landscape;')
})
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 },
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,
}))
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
expect(layout.tableCount).toBe(1)
expect(layout.dimensionViewCount).toBe(3)
await page.screenshot({
path: testInfo.outputPath('engineering-sheet.png'),
fullPage: true,
})
})
})

View File

@ -0,0 +1,593 @@
import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
import { useSignals } from '@preact/signals-react/runtime'
import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
import { useSingletons } from '@src/lib/boot'
import { isModelingResponse } from '@src/lib/kcSdkGuards'
import { engineStreamZoomToFit, engineViewIsometric } from '@src/lib/utils'
import { uuidv4 } from '@src/lib/utils'
import { useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import {
buildExplodeOffsets,
createBomCsv,
createEngineeringSheetHtml,
getOverallBounds,
groupBodiesIntoBom,
type BodyMeasurement,
type BomRow,
type ExplodeOffset,
} from './manufacturing'
type ModelingResponse = {
type: string
data?: unknown
}
type BomOverrides = Record<
string,
{
material?: string
process?: string
}
>
function downloadText(filename: string, text: string, type: string) {
const blob = new Blob([text], { type })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
}
function collectArtifactIds(
value: unknown,
ids: Set<string>,
seen = new WeakSet<object>(),
depth = 0
) {
if (depth > 8 || value === null || typeof value !== 'object') return
if (seen.has(value)) return
seen.add(value)
if ('artifactId' in value && typeof value.artifactId === 'string') {
ids.add(value.artifactId)
}
if ('artifact_id' in value && typeof value.artifact_id === 'string') {
ids.add(value.artifact_id)
}
if ('id' in value && typeof value.id === 'string') {
ids.add(value.id)
}
for (const nested of Object.values(value)) {
collectArtifactIds(nested, ids, seen, depth + 1)
}
}
function resolveBodyName(
bodyId: string,
variables: Record<string, unknown>,
fallbackIndex: number
) {
for (const [name, value] of Object.entries(variables)) {
const ids = new Set<string>()
collectArtifactIds(value, ids)
if (ids.has(bodyId)) return name
}
return `零件 ${String(fallbackIndex + 1).padStart(2, '0')}`
}
function modelingResponseData(response: unknown): ModelingResponse {
if (!isModelingResponse(response)) {
throw new Error('HiCAD 引擎未返回有效结果')
}
return response.resp.data.modeling_response as ModelingResponse
}
function numberValue(data: unknown, key: string) {
if (typeof data !== 'object' || data === null) return undefined
const value = (data as Record<string, unknown>)[key]
return typeof value === 'number' ? value : undefined
}
function formatNumber(value: number | undefined) {
if (value === undefined) return '-'
return Math.round(value * 100) / 100
}
function dataUrlFromSnapshot(contents: string) {
return contents.startsWith('data:')
? contents
: `data:image/png;base64,${contents}`
}
export function HicadManufacturingPanel({
projectName,
}: {
projectName: string
}) {
useSignals()
const { kclManager } = useSingletons()
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [bodies, setBodies] = useState<BodyMeasurement[]>([])
const [snapshot, setSnapshot] = useState<string>()
const [exploded, setExploded] = useState(false)
const [overrides, setOverrides] = useState<BomOverrides>({})
const [notes, setNotes] = useState(
'材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。'
)
const explodeOffsetsRef = useRef<ExplodeOffset[]>([])
const resolvedProjectName = projectName || 'HiCAD 模型'
const sendModelingCommand = async (cmd: ModelingCmd) => {
const response = await kclManager.engineCommandManager.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: uuidv4(),
cmd,
})
return modelingResponseData(response)
}
const captureSnapshot = async () => {
const response = await sendModelingCommand({
type: 'take_snapshot',
format: 'png',
})
if (
response.type !== 'take_snapshot' ||
typeof response.data !== 'object' ||
response.data === null ||
!('contents' in response.data) ||
typeof response.data.contents !== 'string'
) {
return undefined
}
const dataUrl = dataUrlFromSnapshot(response.data.contents)
setSnapshot(dataUrl)
return dataUrl
}
const resetExplodeTransforms = async () => {
const offsets = explodeOffsetsRef.current
if (offsets.length === 0) return
await Promise.allSettled(
offsets.map(({ id, offset }) =>
sendModelingCommand({
type: 'set_object_transform',
object_id: id,
transforms: [
{
translate: {
property: {
x: -offset.x,
y: -offset.y,
z: -offset.z,
},
set: false,
origin: { type: 'global' },
},
},
],
})
)
)
explodeOffsetsRef.current = []
setExploded(false)
}
const analyzeModel = async (manageBusy = true) => {
if (manageBusy) setBusy(true)
try {
if (explodeOffsetsRef.current.length > 0) {
await resetExplodeTransforms()
}
const bodyEntries = [...getBodiesFromArtifactGraph(kclManager.artifactGraph)]
if (bodyEntries.length === 0) {
throw new Error('当前模型还没有可识别的实体')
}
const variables = kclManager.variablesSignal.value as Record<
string,
unknown
>
const measured = await Promise.all(
bodyEntries.map(async ([bodyId], index): Promise<BodyMeasurement> => {
const boundingResponse = await sendModelingCommand({
type: 'bounding_box',
entity_ids: [bodyId],
output_unit: 'mm',
})
if (
boundingResponse.type !== 'bounding_box' ||
typeof boundingResponse.data !== 'object' ||
boundingResponse.data === null
) {
throw new Error(`无法读取零件 ${index + 1} 的尺寸`)
}
const boundingBox = boundingResponse.data as BoundingBox
const [volumeResponse, areaResponse] = await Promise.allSettled([
sendModelingCommand({
type: 'volume',
entity_ids: [bodyId],
output_unit: 'mm3',
}),
sendModelingCommand({
type: 'surface_area',
entity_ids: [bodyId],
output_unit: 'mm2',
}),
])
return {
id: bodyId,
name: resolveBodyName(bodyId, variables, index),
center: boundingBox.center,
dimensions: boundingBox.dimensions,
volume:
volumeResponse.status === 'fulfilled'
? numberValue(volumeResponse.value.data, 'volume')
: undefined,
surfaceArea:
areaResponse.status === 'fulfilled'
? numberValue(areaResponse.value.data, 'surface_area')
: undefined,
}
})
)
setBodies(measured)
await engineViewIsometric({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.22,
})
await engineStreamZoomToFit({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.24,
})
await new Promise((resolve) => setTimeout(resolve, 180))
await captureSnapshot()
return measured
} catch (error) {
const message =
error instanceof Error ? error.message : '工程数据分析失败'
toast.error(message)
return []
} finally {
if (manageBusy) setBusy(false)
}
}
const handleOpen = () => {
setOpen(true)
if (bodies.length === 0) {
void analyzeModel()
}
}
const toggleExplodedView = async () => {
if (busy) return
setBusy(true)
try {
if (exploded) {
await resetExplodeTransforms()
} else {
const measured =
bodies.length > 0 ? bodies : await analyzeModel(false)
if (measured.length < 2) {
throw new Error(
'爆炸图需要至少两个独立实体;当前模型可能已经布尔合并为一个整体'
)
}
const offsets = buildExplodeOffsets(measured)
const transformResults = await Promise.allSettled(
offsets.map(({ id, offset }) =>
sendModelingCommand({
type: 'set_object_transform',
object_id: id,
transforms: [
{
translate: {
property: offset,
set: false,
origin: { type: 'global' },
},
},
],
})
)
)
const successfulOffsets = offsets.filter(
(_, index) => transformResults[index]?.status === 'fulfilled'
)
explodeOffsetsRef.current = successfulOffsets
const failedCount = transformResults.length - successfulOffsets.length
if (failedCount > 0) {
await resetExplodeTransforms()
throw new Error(
`${failedCount} 个实体无法移动,已自动复原装配,请检查模型实体`
)
}
explodeOffsetsRef.current = offsets
setExploded(true)
}
await engineViewIsometric({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.18,
})
await engineStreamZoomToFit({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.18,
})
await new Promise((resolve) => setTimeout(resolve, 240))
await captureSnapshot()
} catch (error) {
toast.error(error instanceof Error ? error.message : '爆炸图生成失败')
} finally {
setBusy(false)
}
}
const bomRows = useMemo(
() =>
groupBodiesIntoBom(bodies).map((row) => ({
...row,
...overrides[row.key],
})),
[bodies, overrides]
)
const overall = useMemo(() => getOverallBounds(bodies), [bodies])
const updateRow = (
row: BomRow,
field: 'material' | 'process',
value: string
) => {
setOverrides((current) => ({
...current,
[row.key]: {
...current[row.key],
[field]: value,
},
}))
}
const downloadBom = () => {
if (bomRows.length === 0) {
toast.error('请先分析模型')
return
}
downloadText(
`${resolvedProjectName}-BOM.csv`,
createBomCsv(resolvedProjectName, bomRows),
'text/csv;charset=utf-8'
)
}
const printEngineeringSheet = () => {
if (bomRows.length === 0) {
toast.error('请先分析模型')
return
}
const reportWindow = window.open('', '_blank')
if (!reportWindow) {
toast.error('浏览器阻止了工艺图窗口,请允许本站打开新窗口')
return
}
reportWindow.opener = null
reportWindow.document.open()
reportWindow.document.write(
createEngineeringSheetHtml({
projectName: resolvedProjectName,
generatedAt: new Date().toLocaleString('zh-CN'),
overallDimensions: overall.dimensions,
snapshotDataUrl: snapshot,
bomRows,
notes,
})
)
reportWindow.document.close()
}
const handleClose = async () => {
if (busy) return
setBusy(true)
try {
if (explodeOffsetsRef.current.length > 0) {
await resetExplodeTransforms()
await engineStreamZoomToFit({
engineCommandManager: kclManager.engineCommandManager,
padding: 0.24,
})
}
setOpen(false)
} finally {
setBusy(false)
}
}
return (
<>
<button
type="button"
data-testid="hicad-manufacturing-trigger"
onClick={handleOpen}
className="absolute right-4 top-14 z-20 h-9 px-3 border border-chalkboard-30 dark:border-chalkboard-70 bg-chalkboard-10 dark:bg-chalkboard-90 hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 shadow-sm text-sm font-medium"
>
工程出图
</button>
{open && (
<div
className="fixed inset-0 z-[100] bg-black/50 p-4 md:p-8"
role="dialog"
aria-modal="true"
aria-label="工程出图"
>
<div className="mx-auto flex h-full max-w-[1500px] flex-col overflow-hidden bg-chalkboard-10 dark:bg-chalkboard-100 border border-chalkboard-30 dark:border-chalkboard-70 shadow-2xl">
<header className="flex shrink-0 items-center justify-between gap-4 border-b border-chalkboard-30 dark:border-chalkboard-70 px-5 py-4">
<div>
<h2 className="text-lg font-semibold">工程出图</h2>
<p className="text-xs text-chalkboard-60 dark:text-chalkboard-40">
真实实体爆炸、尺寸、BOM 与可打印生产工艺图
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={busy}
onClick={() => void analyzeModel()}
className="h-9 border border-chalkboard-30 dark:border-chalkboard-70 px-3 text-sm disabled:opacity-50"
>
重新分析
</button>
<button
type="button"
aria-label="关闭"
disabled={busy}
onClick={() => void handleClose()}
className="grid size-9 place-items-center border border-chalkboard-30 dark:border-chalkboard-70 text-xl disabled:opacity-50"
>
×
</button>
</div>
</header>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-chalkboard-30 dark:border-chalkboard-70 px-5 py-3">
<button
type="button"
disabled={busy}
onClick={() => void toggleExplodedView()}
className="h-9 bg-chalkboard-100 px-4 text-sm font-medium text-chalkboard-10 dark:bg-chalkboard-10 dark:text-chalkboard-100 disabled:opacity-50"
>
{busy ? '处理中…' : exploded ? '复原装配' : '一键爆炸'}
</button>
<button
type="button"
onClick={downloadBom}
className="h-9 border border-chalkboard-30 dark:border-chalkboard-70 px-4 text-sm"
>
下载 BOM
</button>
<button
type="button"
onClick={printEngineeringSheet}
className="h-9 border border-chalkboard-30 dark:border-chalkboard-70 px-4 text-sm"
>
打印 / PDF 工艺图
</button>
<span className="ml-auto text-xs text-chalkboard-60 dark:text-chalkboard-40">
{bodies.length} 个独立实体 · {bomRows.length} 类零件 · 总尺寸{' '}
{formatNumber(overall.dimensions.x)} ×{' '}
{formatNumber(overall.dimensions.y)} ×{' '}
{formatNumber(overall.dimensions.z)} mm
</span>
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-y-auto xl:grid-cols-[minmax(360px,0.8fr)_minmax(700px,1.4fr)]">
<section className="border-b border-chalkboard-30 dark:border-chalkboard-70 p-5 xl:border-b-0 xl:border-r">
<h3 className="mb-3 text-sm font-semibold">装配 / 爆炸预览</h3>
<div className="grid min-h-[320px] place-items-center border border-chalkboard-30 dark:border-chalkboard-70 bg-white dark:bg-black">
{snapshot ? (
<img
src={snapshot}
alt={exploded ? '模型爆炸图' : '模型装配图'}
className="max-h-[520px] w-full object-contain"
/>
) : (
<span className="text-sm text-chalkboard-60">
{busy ? '正在读取工程数据…' : '暂无预览'}
</span>
)}
</div>
<h3 className="mb-2 mt-5 text-sm font-semibold">生产说明</h3>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
className="min-h-28 w-full resize-y border border-chalkboard-30 bg-transparent p-3 text-sm dark:border-chalkboard-70"
/>
</section>
<section className="min-w-0 p-5">
<h3 className="mb-3 text-sm font-semibold">配件明细表</h3>
<div className="overflow-x-auto border border-chalkboard-30 dark:border-chalkboard-70">
<table className="w-full min-w-[920px] border-collapse text-sm">
<thead className="bg-chalkboard-20 dark:bg-chalkboard-90">
<tr>
<th className="border-b p-2 text-left">序号</th>
<th className="border-b p-2 text-left">零件名称</th>
<th className="border-b p-2 text-left">数量</th>
<th className="border-b p-2 text-left">长 × 宽 × 高 (mm)</th>
<th className="border-b p-2 text-left">材料</th>
<th className="border-b p-2 text-left">生产工艺</th>
<th className="border-b p-2 text-left">体积 / 表面积</th>
</tr>
</thead>
<tbody>
{bomRows.map((row) => (
<tr
key={row.key}
className="border-b border-chalkboard-30 last:border-b-0 dark:border-chalkboard-70"
>
<td className="p-2">{row.itemNumber}</td>
<td className="p-2 font-medium">{row.name}</td>
<td className="p-2">{row.quantity}</td>
<td className="p-2 whitespace-nowrap">
{formatNumber(row.dimensions.x)} ×{' '}
{formatNumber(row.dimensions.y)} ×{' '}
{formatNumber(row.dimensions.z)}
</td>
<td className="p-2">
<input
value={row.material}
onChange={(event) =>
updateRow(row, 'material', event.target.value)
}
className="h-8 w-28 border border-chalkboard-30 bg-transparent px-2 dark:border-chalkboard-70"
/>
</td>
<td className="p-2">
<input
value={row.process}
onChange={(event) =>
updateRow(row, 'process', event.target.value)
}
className="h-8 w-64 border border-chalkboard-30 bg-transparent px-2 dark:border-chalkboard-70"
/>
</td>
<td className="p-2 text-xs whitespace-nowrap">
{formatNumber(row.volume)} mm³ /{' '}
{formatNumber(row.surfaceArea)} mm²
</td>
</tr>
))}
{bomRows.length === 0 && (
<tr>
<td
colSpan={7}
className="p-10 text-center text-chalkboard-60"
>
{busy ? '正在分析模型…' : '暂无可识别零件'}
</td>
</tr>
)}
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-chalkboard-60 dark:text-chalkboard-40">
尺寸来自模型实体包围盒。材料、工艺、公差和连接方式必须由工程人员投产前复核。
</p>
</section>
</div>
</div>
</div>
)}
</>
)
}

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

@ -0,0 +1,31 @@
# 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.
- `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.

401
vendor/zoo-modeling-app/manufacturing.ts vendored Normal file
View File

@ -0,0 +1,401 @@
export type Point3d = {
x: number
y: number
z: number
}
export type BodyMeasurement = {
id: string
name: string
center: Point3d
dimensions: Point3d
volume?: number
surfaceArea?: number
}
export type ExplodeOffset = {
id: string
offset: Point3d
}
export type BomRow = {
key: string
itemNumber: number
name: string
quantity: number
dimensions: Point3d
volume?: number
surfaceArea?: number
material: string
process: string
bodyIds: string[]
}
export type EngineeringSheetInput = {
projectName: string
generatedAt: string
overallDimensions: Point3d
snapshotDataUrl?: string
bomRows: BomRow[]
notes?: string
}
const EPSILON = 1e-6
function roundDimension(value: number) {
return Math.round(value * 100) / 100
}
function vectorLength(point: Point3d) {
return Math.hypot(point.x, point.y, point.z)
}
function normalize(point: Point3d): Point3d {
const length = vectorLength(point)
if (length < EPSILON) {
return { x: 1, y: 0, z: 0 }
}
return {
x: point.x / length,
y: point.y / length,
z: point.z / length,
}
}
function fallbackDirection(index: number, count: number): Point3d {
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
const y = count <= 1 ? 0 : 1 - (index / (count - 1)) * 2
const radius = Math.sqrt(Math.max(0, 1 - y * y))
const angle = index * goldenAngle
return normalize({
x: Math.cos(angle) * radius,
y,
z: Math.sin(angle) * radius,
})
}
export function getOverallBounds(bodies: BodyMeasurement[]) {
if (bodies.length === 0) {
return {
center: { x: 0, y: 0, z: 0 },
dimensions: { x: 0, y: 0, z: 0 },
}
}
const min = { x: Infinity, y: Infinity, z: Infinity }
const max = { x: -Infinity, y: -Infinity, z: -Infinity }
for (const body of bodies) {
for (const axis of ['x', 'y', 'z'] as const) {
const half = body.dimensions[axis] / 2
min[axis] = Math.min(min[axis], body.center[axis] - half)
max[axis] = Math.max(max[axis], body.center[axis] + half)
}
}
return {
center: {
x: (min.x + max.x) / 2,
y: (min.y + max.y) / 2,
z: (min.z + max.z) / 2,
},
dimensions: {
x: max.x - min.x,
y: max.y - min.y,
z: max.z - min.z,
},
}
}
export function buildExplodeOffsets(
bodies: BodyMeasurement[],
strength = 0.7
): ExplodeOffset[] {
if (bodies.length < 2) return []
const bounds = getOverallBounds(bodies)
const diagonal = Math.max(vectorLength(bounds.dimensions), 25)
return bodies.map((body, index) => {
const fromCenter = {
x: body.center.x - bounds.center.x,
y: body.center.y - bounds.center.y,
z: body.center.z - bounds.center.z,
}
const direction =
vectorLength(fromCenter) < diagonal * 0.025
? fallbackDirection(index, bodies.length)
: normalize(fromCenter)
const bodyDiagonal = vectorLength(body.dimensions)
const distance =
diagonal * strength * (0.7 + index / Math.max(bodies.length * 5, 1)) +
bodyDiagonal * 0.18
return {
id: body.id,
offset: {
x: roundDimension(direction.x * distance),
y: roundDimension(direction.y * distance),
z: roundDimension(direction.z * distance),
},
}
})
}
function normalizePartName(name: string) {
const cleaned = name.trim() || '未命名零件'
return cleaned.replace(/(?:[_-]?(?:copy|part))?[_-]?\d+$/i, '').trim() || cleaned
}
function dimensionsKey(dimensions: Point3d) {
return [dimensions.x, dimensions.y, dimensions.z]
.map((value) => roundDimension(value).toFixed(2))
.join('x')
}
export function inferProcess(dimensions: Point3d) {
const values = [dimensions.x, dimensions.y, dimensions.z]
.map((value) => Math.abs(value))
.sort((a, b) => a - b)
const [smallest, middle, largest] = values
if (largest <= 0) return '待确认'
if (smallest / largest < 0.08 && middle / largest > 0.25) {
return '板材下料 / 折弯 / 表面处理'
}
if (middle / largest < 0.2) {
return '棒料下料 / 车削或磨削'
}
return '机加工或增材制造(待工艺确认)'
}
export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
const groups = new Map<string, BomRow>()
for (const body of bodies) {
const name = normalizePartName(body.name)
const key = `${name.toLowerCase()}::${dimensionsKey(body.dimensions)}`
const existing = groups.get(key)
if (existing) {
existing.quantity += 1
existing.bodyIds.push(body.id)
continue
}
groups.set(key, {
key,
itemNumber: groups.size + 1,
name,
quantity: 1,
dimensions: {
x: roundDimension(body.dimensions.x),
y: roundDimension(body.dimensions.y),
z: roundDimension(body.dimensions.z),
},
volume: body.volume,
surfaceArea: body.surfaceArea,
material: '待确认',
process: inferProcess(body.dimensions),
bodyIds: [body.id],
})
}
return [...groups.values()]
}
function csvCell(value: string | number | undefined) {
const text = value === undefined ? '' : String(value)
return `"${text.replaceAll('"', '""')}"`
}
export function createBomCsv(projectName: string, rows: BomRow[]) {
const header = [
'项目',
'序号',
'零件名称',
'数量',
'长(mm)',
'宽(mm)',
'高(mm)',
'材料',
'生产工艺',
'体积(mm³)',
'表面积(mm²)',
]
const dataRows = rows.map((row) => [
projectName,
row.itemNumber,
row.name,
row.quantity,
row.dimensions.x,
row.dimensions.y,
row.dimensions.z,
row.material,
row.process,
row.volume === undefined ? '' : roundDimension(row.volume),
row.surfaceArea === undefined ? '' : roundDimension(row.surfaceArea),
])
return `\uFEFF${[header, ...dataRows]
.map((row) => row.map(csvCell).join(','))
.join('\r\n')}`
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
function formatDimension(value: number) {
return `${roundDimension(value)} mm`
}
function dimensionViewSvg(
label: string,
horizontal: number,
vertical: number,
width = 310,
height = 210
) {
const objectX = 72
const objectY = 40
const objectWidth = 180
const objectHeight = 112
const horizontalText = escapeHtml(formatDimension(horizontal))
const verticalText = escapeHtml(formatDimension(vertical))
return `
<figure class="dimension-view">
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(label)}">
<defs>
<marker id="arrow-${escapeHtml(label)}" viewBox="0 0 10 10" refX="5" refY="5"
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#111827"/>
</marker>
</defs>
<rect x="${objectX}" y="${objectY}" width="${objectWidth}" height="${objectHeight}"
fill="none" stroke="#111827" stroke-width="2"/>
<line x1="${objectX}" y1="${objectY + objectHeight + 25}"
x2="${objectX + objectWidth}" y2="${objectY + objectHeight + 25}"
stroke="#111827" marker-start="url(#arrow-${escapeHtml(label)})"
marker-end="url(#arrow-${escapeHtml(label)})"/>
<line x1="${objectX}" y1="${objectY + objectHeight}" x2="${objectX}"
y2="${objectY + objectHeight + 35}" stroke="#6b7280"/>
<line x1="${objectX + objectWidth}" y1="${objectY + objectHeight}"
x2="${objectX + objectWidth}" y2="${objectY + objectHeight + 35}" stroke="#6b7280"/>
<text x="${objectX + objectWidth / 2}" y="${objectY + objectHeight + 48}"
text-anchor="middle">${horizontalText}</text>
<line x1="${objectX - 25}" y1="${objectY}" x2="${objectX - 25}"
y2="${objectY + objectHeight}" stroke="#111827"
marker-start="url(#arrow-${escapeHtml(label)})"
marker-end="url(#arrow-${escapeHtml(label)})"/>
<line x1="${objectX - 35}" y1="${objectY}" x2="${objectX}"
y2="${objectY}" stroke="#6b7280"/>
<line x1="${objectX - 35}" y1="${objectY + objectHeight}" x2="${objectX}"
y2="${objectY + objectHeight}" stroke="#6b7280"/>
<text x="${objectX - 42}" y="${objectY + objectHeight / 2}"
text-anchor="middle" transform="rotate(-90 ${objectX - 42} ${objectY + objectHeight / 2})">
${verticalText}
</text>
<text x="${width / 2}" y="20" text-anchor="middle" font-weight="700">
${escapeHtml(label)}
</text>
</svg>
</figure>`
}
export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
const { overallDimensions: dimensions } = input
const rows = input.bomRows
.map(
(row) => `
<tr>
<td>${row.itemNumber}</td>
<td>${escapeHtml(row.name)}</td>
<td>${row.quantity}</td>
<td>${formatDimension(row.dimensions.x)} × ${formatDimension(row.dimensions.y)} × ${formatDimension(row.dimensions.z)}</td>
<td>${escapeHtml(row.material)}</td>
<td>${escapeHtml(row.process)}</td>
</tr>`
)
.join('')
const snapshot = input.snapshotDataUrl
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
: '<div class="snapshot-empty">暂无爆炸图快照</div>'
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>${escapeHtml(input.projectName)} - 生产工艺图</title>
<style>
@page { size: A3 landscape; margin: 12mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #111827; font-family: Arial, "Microsoft YaHei", sans-serif; }
header { display: flex; align-items: end; justify-content: space-between; border-bottom: 2px solid #111827; padding-bottom: 8px; }
h1 { margin: 0; font-size: 24px; }
h2 { margin: 0 0 8px; font-size: 16px; }
p { margin: 4px 0; }
.meta { color: #4b5563; font-size: 12px; text-align: right; }
.sheet-grid { display: grid; grid-template-columns: 1.05fr 1fr; gap: 16px; margin-top: 14px; }
section { break-inside: avoid; }
.snapshot { width: 100%; height: 310px; object-fit: contain; border: 1px solid #9ca3af; }
.snapshot-empty { height: 310px; display: grid; place-items: center; border: 1px dashed #9ca3af; color: #6b7280; }
.views { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.dimension-view { margin: 0; border: 1px solid #d1d5db; }
.dimension-view svg { width: 100%; display: block; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
th, td { border: 1px solid #6b7280; padding: 5px 6px; text-align: left; vertical-align: top; }
th { background: #f3f4f6; }
.bom { grid-column: 1 / -1; }
.notes { grid-column: 1 / -1; border: 1px solid #9ca3af; padding: 10px; min-height: 68px; white-space: pre-wrap; }
footer { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #6b7280; font-size: 11px; }
footer div { min-height: 34px; padding: 8px; border-right: 1px solid #6b7280; }
footer div:last-child { border-right: 0; }
</style>
</head>
<body>
<header>
<div>
<h1>${escapeHtml(input.projectName)} · 生产工艺图</h1>
<p>总体尺寸:${formatDimension(dimensions.x)} × ${formatDimension(dimensions.y)} × ${formatDimension(dimensions.z)}</p>
</div>
<div class="meta">单位:mm<br/>生成时间:${escapeHtml(input.generatedAt)}</div>
</header>
<main class="sheet-grid">
<section>
<h2>装配爆炸图</h2>
${snapshot}
</section>
<section>
<h2>总体三视图与尺寸</h2>
<div class="views">
${dimensionViewSvg('正视图', dimensions.x, dimensions.z)}
${dimensionViewSvg('俯视图', dimensions.x, dimensions.y)}
${dimensionViewSvg('右视图', dimensions.y, dimensions.z)}
</div>
</section>
<section class="bom">
<h2>配件明细表(BOM)</h2>
<table>
<thead>
<tr><th>序号</th><th>零件名称</th><th>数量</th><th>外形尺寸</th><th>材料</th><th>生产工艺</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</section>
<section class="notes"><strong>生产说明:</strong><br/>${escapeHtml(input.notes || '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。')}</section>
</main>
<footer>
<div>制图:</div><div>审核:</div><div>批准:</div>
</footer>
<script>window.addEventListener('load', () => setTimeout(() => window.print(), 250));</script>
</body>
</html>`
}