diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index f30d2518d..b0d63cfa4 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -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, diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs index 56cc5a91d..3389f0eb2 100644 --- a/scripts/vendor-zoo-modeling-app.mjs +++ b/scripts/vendor-zoo-modeling-app.mjs @@ -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", + " \n {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`, diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts new file mode 100644 index 000000000..f86ab885d --- /dev/null +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -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( + "" + ) + }) + + 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('<工作台>') + 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, + }) + }) +}) diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx new file mode 100644 index 000000000..84bc0b044 --- /dev/null +++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx @@ -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, + seen = new WeakSet(), + 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, + fallbackIndex: number +) { + for (const [name, value] of Object.entries(variables)) { + const ids = new Set() + 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)[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([]) + const [snapshot, setSnapshot] = useState() + const [exploded, setExploded] = useState(false) + const [overrides, setOverrides] = useState({}) + const [notes, setNotes] = useState( + '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。' + ) + const explodeOffsetsRef = useRef([]) + 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 => { + 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 ( + <> + + + {open && ( +
+
+
+
+

工程出图

+

+ 真实实体爆炸、尺寸、BOM 与可打印生产工艺图 +

+
+
+ + +
+
+ +
+ + + + + {bodies.length} 个独立实体 · {bomRows.length} 类零件 · 总尺寸{' '} + {formatNumber(overall.dimensions.x)} ×{' '} + {formatNumber(overall.dimensions.y)} ×{' '} + {formatNumber(overall.dimensions.z)} mm + +
+ +
+
+

装配 / 爆炸预览

+
+ {snapshot ? ( + {exploded + ) : ( + + {busy ? '正在读取工程数据…' : '暂无预览'} + + )} +
+

生产说明

+