feat: add HiCAD manufacturing drawings
This commit is contained in:
parent
c2bc6147c8
commit
891db63b70
@ -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,
|
||||
|
||||
@ -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 })`,
|
||||
|
||||
138
tests/routes/hicad-manufacturing-extension.test.ts
Normal file
138
tests/routes/hicad-manufacturing-extension.test.ts
Normal 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('<工作台>')
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
593
vendor/zoo-modeling-app/HicadManufacturingPanel.tsx
vendored
Normal file
593
vendor/zoo-modeling-app/HicadManufacturingPanel.tsx
vendored
Normal 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
31
vendor/zoo-modeling-app/README.md
vendored
Normal 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
401
vendor/zoo-modeling-app/manufacturing.ts
vendored
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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>`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user