feat: generate complete front and side part drawings

This commit is contained in:
134 2026-07-27 22:36:27 +08:00 committed by root
parent b5b5398b30
commit 2c81486862
2 changed files with 141 additions and 3 deletions

View File

@ -8,6 +8,7 @@ import {
createSnapshotDataUrl,
detectPanelHoles,
filterManufacturingBodies,
getOverallBounds,
groupBodiesIntoBom,
localizePartName,
type BodyMeasurement,
@ -286,6 +287,10 @@ test.describe('HiCAD manufacturing extension', () => {
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)
@ -307,6 +312,31 @@ test.describe('HiCAD manufacturing extension', () => {
})
})
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=='

View File

@ -881,6 +881,111 @@ function uniqueRounded(values: number[]) {
.sort((left, right) => left - right)
}
function sideDimensionSvg(row: BomRow) {
const axes = panelAxes(row.dimensions)
const horizontal = Math.abs(row.dimensions[axes.depth])
const vertical = Math.abs(row.dimensions[axes.vertical])
const maxObjectWidth = 112
const maxObjectHeight = 145
const scale = Math.min(
maxObjectWidth / Math.max(horizontal, EPSILON),
maxObjectHeight / Math.max(vertical, EPSILON)
)
const objectWidth = Math.max(horizontal * scale, 1.5)
const objectHeight = Math.max(vertical * scale, 1.5)
const objectX = 68 + (maxObjectWidth - objectWidth) / 2
const objectY = 42 + (maxObjectHeight - objectHeight) / 2
const hiddenHoleRows = new Map<
string,
{ center: number; diameter: number }
>()
row.holes
.filter((hole) => hole.axis === axes.depth)
.forEach((hole) => {
const center =
hole.center[axes.vertical] -
row.center[axes.vertical] +
vertical / 2
hiddenHoleRows.set(
`${roundDimension(center)}:${roundDimension(hole.diameter)}`,
{ center, diameter: hole.diameter }
)
})
const hiddenHoleMarkup = [...hiddenHoleRows.values()]
.map(({ center, diameter }) => {
const centerY = objectY + objectHeight - center * scale
const radius = (diameter / 2) * scale
return `
<line class="hidden-line" x1="${roundDimension(objectX)}"
y1="${roundDimension(centerY - radius)}"
x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(centerY - radius)}"/>
<line class="hidden-line" x1="${roundDimension(objectX)}"
y1="${roundDimension(centerY + radius)}"
x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(centerY + radius)}"/>`
})
.join('')
return `
<svg class="part-side-view" viewBox="0 0 250 245" role="img"
aria-label="${escapeHtml(row.name)} 侧面尺寸图">
<defs>
<marker id="side-arrow-${row.itemNumber}" 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>
<text x="125" y="18" text-anchor="middle" font-weight="700">侧面图</text>
<rect x="${roundDimension(objectX)}" y="${roundDimension(objectY)}"
width="${roundDimension(objectWidth)}" height="${roundDimension(
objectHeight
)}" fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>
${hiddenHoleMarkup}
<line x1="${roundDimension(objectX)}" y1="${roundDimension(
objectY + objectHeight + 22
)}" x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(objectY + objectHeight + 22)}" stroke="#111827"
marker-start="url(#side-arrow-${row.itemNumber})"
marker-end="url(#side-arrow-${row.itemNumber})"/>
<line x1="${roundDimension(objectX)}" y1="${roundDimension(
objectY + objectHeight
)}" x2="${roundDimension(objectX)}" y2="${roundDimension(
objectY + objectHeight + 30
)}" stroke="#6b7280"/>
<line x1="${roundDimension(objectX + objectWidth)}" y1="${roundDimension(
objectY + objectHeight
)}" x2="${roundDimension(objectX + objectWidth)}" y2="${roundDimension(
objectY + objectHeight + 30
)}" stroke="#6b7280"/>
<text x="${roundDimension(objectX + objectWidth / 2)}"
y="${roundDimension(objectY + objectHeight + 39)}"
text-anchor="middle">${formatDimension(horizontal)}</text>
<line x1="${roundDimension(objectX - 22)}" y1="${roundDimension(objectY)}"
x2="${roundDimension(objectX - 22)}" y2="${roundDimension(
objectY + objectHeight
)}" stroke="#111827" marker-start="url(#side-arrow-${row.itemNumber})"
marker-end="url(#side-arrow-${row.itemNumber})"/>
<line x1="${roundDimension(objectX - 30)}" y1="${roundDimension(objectY)}"
x2="${roundDimension(objectX)}" y2="${roundDimension(objectY)}"
stroke="#6b7280"/>
<line x1="${roundDimension(objectX - 30)}" y1="${roundDimension(
objectY + objectHeight
)}" x2="${roundDimension(objectX)}" y2="${roundDimension(
objectY + objectHeight
)}" stroke="#6b7280"/>
<text x="${roundDimension(objectX - 37)}"
y="${roundDimension(objectY + objectHeight / 2)}" text-anchor="middle"
transform="rotate(-90 ${roundDimension(
objectX - 37
)} ${roundDimension(objectY + objectHeight / 2)})">${formatDimension(
vertical
)}</text>
</svg>`
}
function machiningDetailSvg(row: BomRow) {
const axes = panelAxes(row.dimensions)
const horizontal = Math.abs(row.dimensions[axes.horizontal])
@ -979,7 +1084,7 @@ function machiningDetailSvg(row: BomRow) {
<span>数量 ${row.quantity} · 厚度 ${formatDimension(depth)}</span>
</div>
<div class="part-detail-layout">
<svg viewBox="0 0 390 245" role="img" aria-label="${escapeHtml(
<svg class="part-front-view" viewBox="0 0 390 245" role="img" aria-label="${escapeHtml(
row.name
)} 加工尺寸图">
<defs>
@ -988,6 +1093,7 @@ function machiningDetailSvg(row: BomRow) {
<path d="M 0 0 L 10 5 L 0 10 z" fill="#111827"/>
</marker>
</defs>
<text x="195" y="18" text-anchor="middle" font-weight="700">正面图</text>
<rect x="${roundDimension(objectX)}" y="${roundDimension(objectY)}"
width="${roundDimension(objectWidth)}" height="${roundDimension(
objectHeight
@ -1033,6 +1139,7 @@ function machiningDetailSvg(row: BomRow) {
vertical
)}</text>
</svg>
${sideDimensionSvg(row)}
<div class="hole-schedule">
<strong>孔位与加工基准</strong>
<ul>${holeNotes}</ul>
@ -1122,13 +1229,14 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
th { background: #f3f4f6; }
.bom { grid-column: 1 / -1; }
.part-details { grid-column: 1 / -1; break-before: page; }
.part-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.part-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.part-detail { border: 1px solid #6b7280; break-inside: avoid; }
.part-detail-heading { display: flex; justify-content: space-between; gap: 12px; padding: 7px 9px; border-bottom: 1px solid #9ca3af; background: #f3f4f6; font-size: 12px; }
.part-detail-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); gap: 8px; align-items: start; padding: 6px; }
.part-detail-layout { display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(220px, .72fr) minmax(230px, .86fr); gap: 10px; align-items: start; padding: 6px; }
.part-detail svg { width: 100%; display: block; font-size: 10px; }
.detail-hole { fill: white; stroke: #111827; stroke-width: 1.5; }
.center-line { stroke: #6b7280; stroke-width: .7; stroke-dasharray: 4 3; }
.hidden-line { stroke: #6b7280; stroke-width: .8; stroke-dasharray: 5 3; }
.hole-schedule { padding: 8px 6px 6px 0; font-size: 10px; line-height: 1.5; }
.hole-schedule ul { margin: 5px 0 8px; padding-left: 18px; }
.hole-schedule p { margin: 5px 0; }