feat: add HiCAD hole dimension production drawings

This commit is contained in:
134 2026-07-27 22:03:57 +08:00 committed by root
parent e40c351f80
commit d84f3dc7d8
3 changed files with 624 additions and 3 deletions

View File

@ -6,9 +6,11 @@ import {
createBomCsv,
createEngineeringSheetHtml,
createSnapshotDataUrl,
detectPanelHoles,
filterManufacturingBodies,
groupBodiesIntoBom,
type BodyMeasurement,
type CircularEdgeSample,
} from '../../vendor/zoo-modeling-app/manufacturing'
const bodies: BodyMeasurement[] = [
@ -38,6 +40,23 @@ const bodies: BodyMeasurement[] = [
},
]
function circularEdge(
centerX: number,
centerY: number,
z: number,
radius: number
): CircularEdgeSample {
return {
controlPoints: [
{ x: centerX + radius, y: centerY, z },
{ x: centerX, y: centerY + radius, z },
{ x: centerX - radius, y: centerY, z },
{ x: centerX, y: centerY - radius, z },
],
length: Math.PI * radius * 2,
}
}
test.describe('HiCAD manufacturing extension', () => {
test('keeps the extension isolated behind one official registration point', () => {
const projectRoot = process.cwd()
@ -162,6 +181,47 @@ test.describe('HiCAD manufacturing extension', () => {
).toBe('data:image/png;base64,iVBORw0KGgo/8A==')
})
test('detects through holes on a panel and deduplicates both face edges', () => {
const panel: BodyMeasurement = {
id: 'side-panel',
name: 'sidePanel',
center: { x: 0, y: 0, z: 9 },
dimensions: { x: 400, y: 300, z: 18 },
}
const holeCenters = [
[-160, -100],
[160, -100],
[-160, 100],
[160, 100],
]
const edgeSamples = holeCenters.flatMap(([x, y]) => [
circularEdge(x, y, 0, 4),
circularEdge(x, y, 18, 4),
])
const holes = detectPanelHoles(panel, edgeSamples)
expect(holes).toHaveLength(4)
expect(holes.every((hole) => hole.axis === 'z')).toBe(true)
expect(holes.every((hole) => hole.diameter === 8)).toBe(true)
expect(holes.every((hole) => hole.depth === 18)).toBe(true)
const rows = groupBodiesIntoBom([{ ...panel, holes }])
const html = createEngineeringSheetHtml({
projectName: '五金连接侧板',
generatedAt: '2026-07-27 22:00',
overallDimensions: panel.dimensions,
bodies: [{ ...panel, holes }],
bomRows: rows,
})
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
expect(html).toContain('4 × Ø8')
expect(html).toContain('H1:Ø8 通孔')
expect(html).toContain('距左 40')
expect(html).toContain('距下 50')
expect(html).toContain('横向中心孔距 320 mm')
expect(html).toContain('纵向中心孔距 200 mm')
})
test('builds a printable dimensioned engineering sheet safely', () => {
const rows = groupBodiesIntoBom(bodies)
const html = createEngineeringSheetHtml({
@ -185,6 +245,8 @@ test.describe('HiCAD manufacturing extension', () => {
expect(html).toContain('data-part-name="台面板"')
expect(html).toContain('120 mm')
expect(html).toContain('配件明细表(BOM)')
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
expect(html.match(/class="part-detail"/g)).toHaveLength(2)
expect(html).toContain('由壹点AI')
expect(html).toContain('/api/favicon?surface=hicad')
expect(html).toContain('@page { size: A3 landscape;')
@ -215,6 +277,7 @@ test.describe('HiCAD manufacturing extension', () => {
clientWidth: document.documentElement.clientWidth,
tableCount: document.querySelectorAll('table').length,
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
partDetailCount: document.querySelectorAll('.part-detail').length,
projectionPartCount:
document.querySelectorAll('.projection-part').length,
brandCount: document.querySelectorAll('.brand-name').length,
@ -223,6 +286,7 @@ test.describe('HiCAD manufacturing extension', () => {
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
expect(layout.tableCount).toBe(1)
expect(layout.dimensionViewCount).toBe(3)
expect(layout.partDetailCount).toBe(rows.length)
expect(layout.projectionPartCount).toBe(bodies.length * 3)
expect(layout.brandCount).toBe(1)
await page.screenshot({

View File

@ -11,11 +11,13 @@ import {
createSnapshotDataUrl,
createBomCsv,
createEngineeringSheetHtml,
detectPanelHoles,
filterManufacturingBodies,
getOverallBounds,
groupBodiesIntoBom,
type BodyMeasurement,
type BomRow,
type CircularEdgeSample,
type ExplodeOffset,
} from './manufacturing'
@ -241,6 +243,126 @@ export function HicadManufacturingPanel({
]
}
const readCircularEdges = async (bodyId: string) => {
try {
const childResponse = await sendModelingCommand({
type: 'entity_get_all_child_uuids',
entity_id: bodyId,
})
if (
childResponse.type !== 'entity_get_all_child_uuids' ||
typeof childResponse.data !== 'object' ||
childResponse.data === null ||
!('entity_ids' in childResponse.data) ||
!Array.isArray(childResponse.data.entity_ids)
) {
return []
}
const childIds = childResponse.data.entity_ids.filter(
(id): id is string => typeof id === 'string'
)
const typedChildren = await Promise.allSettled(
childIds.map(async (entityId) => {
const response = await sendModelingCommand({
type: 'get_entity_type',
entity_id: entityId,
})
const entityType =
response.type === 'get_entity_type' &&
typeof response.data === 'object' &&
response.data !== null &&
'entity_type' in response.data &&
typeof response.data.entity_type === 'string'
? response.data.entity_type
: undefined
return { entityId, entityType }
})
)
const curveCandidates = typedChildren.flatMap((result) =>
result.status === 'fulfilled' &&
(result.value.entityType === 'edge' ||
result.value.entityType === 'curve')
? [result.value]
: []
)
const edgeResults = await Promise.allSettled(
curveCandidates.map(async ({ entityId, entityType }) => {
const typeResponse = await sendModelingCommand({
type: 'curve_get_type',
curve_id: entityId,
})
const curveType =
typeResponse.type === 'curve_get_type' &&
typeof typeResponse.data === 'object' &&
typeResponse.data !== null &&
'curve_type' in typeResponse.data
? typeResponse.data.curve_type
: undefined
if (curveType !== 'arc' && curveType !== 'nurbs') return undefined
const [pointsResult, lengthResult] = await Promise.allSettled([
sendModelingCommand({
type: 'curve_get_control_points',
curve_id: entityId,
}),
entityType === 'edge'
? sendModelingCommand({
type: 'edge_get_length',
edge_id: entityId,
})
: Promise.resolve(undefined),
])
if (
pointsResult.status !== 'fulfilled' ||
pointsResult.value.type !== 'curve_get_control_points' ||
typeof pointsResult.value.data !== 'object' ||
pointsResult.value.data === null ||
!('control_points' in pointsResult.value.data) ||
!Array.isArray(pointsResult.value.data.control_points)
) {
return undefined
}
const controlPoints =
pointsResult.value.data.control_points.flatMap((point) =>
typeof point === 'object' &&
point !== null &&
'x' in point &&
'y' in point &&
'z' in point &&
typeof point.x === 'number' &&
typeof point.y === 'number' &&
typeof point.z === 'number'
? [{ x: point.x, y: point.y, z: point.z }]
: []
)
let length: number | undefined
if (
lengthResult.status === 'fulfilled' &&
lengthResult.value &&
lengthResult.value.type === 'edge_get_length' &&
typeof lengthResult.value.data === 'object' &&
lengthResult.value.data !== null &&
'length' in lengthResult.value.data &&
typeof lengthResult.value.data.length === 'number'
) {
length = lengthResult.value.data.length
}
return { controlPoints, length } satisfies CircularEdgeSample
})
)
return edgeResults.flatMap((result) =>
result.status === 'fulfilled' && result.value ? [result.value] : []
)
} catch (error) {
console.warn(
'[HiCAD manufacturing] Unable to inspect circular body edges',
{ bodyId, error }
)
return []
}
}
const readBoundingBox = async (entityIds: string[]) => {
const response = await sendModelingCommand({
type: 'bounding_box',
@ -355,7 +477,7 @@ export function HicadManufacturingPanel({
}),
])
return {
const measurement: BodyMeasurement = {
id: bodyId,
name: resolveBodyName(bodyId, variables, index),
center: boundingBox.center,
@ -369,6 +491,9 @@ export function HicadManufacturingPanel({
? numberValue(areaResponse.value.data, 'surface_area')
: undefined,
}
const circularEdges = await readCircularEdges(bodyId)
measurement.holes = detectPanelHoles(measurement, circularEdges)
return measurement
})
)
const measured = measurementResults.flatMap((result) =>

View File

@ -4,6 +4,19 @@ export type Point3d = {
z: number
}
export type HoleFeature = {
type: 'through-hole' | 'blind-hole'
center: Point3d
diameter: number
axis: keyof Point3d
depth?: number
}
export type CircularEdgeSample = {
controlPoints: Point3d[]
length?: number
}
export type BodyMeasurement = {
id: string
name: string
@ -11,6 +24,7 @@ export type BodyMeasurement = {
dimensions: Point3d
volume?: number
surfaceArea?: number
holes?: HoleFeature[]
}
export type ExplodeOffset = {
@ -29,6 +43,8 @@ export type BomRow = {
material: string
process: string
bodyIds: string[]
center: Point3d
holes: HoleFeature[]
}
export type EngineeringSheetInput = {
@ -108,6 +124,198 @@ function vectorLength(point: Point3d) {
return Math.hypot(point.x, point.y, point.z)
}
function panelAxes(dimensions: Point3d) {
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
(left, right) =>
Math.abs(dimensions[left]) - Math.abs(dimensions[right])
)
return {
depth: axes[0],
vertical: axes[1],
horizontal: axes[2],
}
}
function cross(left: Point3d, right: Point3d): Point3d {
return {
x: left.y * right.z - left.z * right.y,
y: left.z * right.x - left.x * right.z,
z: left.x * right.y - left.y * right.x,
}
}
function subtract(left: Point3d, right: Point3d): Point3d {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z,
}
}
function projectedCircumcircle(
points: Point3d[],
horizontal: keyof Point3d,
vertical: keyof Point3d
) {
let best:
| {
first: Point3d
second: Point3d
third: Point3d
area: number
}
| undefined
const candidates = points.slice(0, 16)
for (let firstIndex = 0; firstIndex < candidates.length - 2; firstIndex += 1) {
for (
let secondIndex = firstIndex + 1;
secondIndex < candidates.length - 1;
secondIndex += 1
) {
for (
let thirdIndex = secondIndex + 1;
thirdIndex < candidates.length;
thirdIndex += 1
) {
const first = candidates[firstIndex]
const second = candidates[secondIndex]
const third = candidates[thirdIndex]
const area = Math.abs(
(second[horizontal] - first[horizontal]) *
(third[vertical] - first[vertical]) -
(third[horizontal] - first[horizontal]) *
(second[vertical] - first[vertical])
)
if (!best || area > best.area) {
best = { first, second, third, area }
}
}
}
}
if (!best || best.area < EPSILON) return undefined
const firstHorizontal = best.first[horizontal]
const firstVertical = best.first[vertical]
const secondHorizontal = best.second[horizontal]
const secondVertical = best.second[vertical]
const thirdHorizontal = best.third[horizontal]
const thirdVertical = best.third[vertical]
const denominator =
2 *
(firstHorizontal * (secondVertical - thirdVertical) +
secondHorizontal * (thirdVertical - firstVertical) +
thirdHorizontal * (firstVertical - secondVertical))
if (Math.abs(denominator) < EPSILON) return undefined
const firstSquared =
firstHorizontal * firstHorizontal + firstVertical * firstVertical
const secondSquared =
secondHorizontal * secondHorizontal + secondVertical * secondVertical
const thirdSquared =
thirdHorizontal * thirdHorizontal + thirdVertical * thirdVertical
const centerHorizontal =
(firstSquared * (secondVertical - thirdVertical) +
secondSquared * (thirdVertical - firstVertical) +
thirdSquared * (firstVertical - secondVertical)) /
denominator
const centerVertical =
(firstSquared * (thirdHorizontal - secondHorizontal) +
secondSquared * (firstHorizontal - thirdHorizontal) +
thirdSquared * (secondHorizontal - firstHorizontal)) /
denominator
const radius = Math.hypot(
centerHorizontal - firstHorizontal,
centerVertical - firstVertical
)
const normal = normalize(
cross(
subtract(best.second, best.first),
subtract(best.third, best.first)
)
)
return {
centerHorizontal,
centerVertical,
radius,
normal,
}
}
export function detectPanelHoles(
body: BodyMeasurement,
circularEdges: CircularEdgeSample[]
) {
const axes = panelAxes(body.dimensions)
const thickness = Math.abs(body.dimensions[axes.depth])
const faceSize = Math.min(
Math.abs(body.dimensions[axes.horizontal]),
Math.abs(body.dimensions[axes.vertical])
)
if (
thickness <= EPSILON ||
faceSize <= EPSILON ||
thickness / faceSize > 0.35
) {
return []
}
const candidates = circularEdges.flatMap((edge): HoleFeature[] => {
if (edge.controlPoints.length < 3) return []
const circle = projectedCircumcircle(
edge.controlPoints,
axes.horizontal,
axes.vertical
)
if (!circle || circle.radius < 0.5) return []
if (Math.abs(circle.normal[axes.depth]) < 0.82) return []
const diameter = circle.radius * 2
if (diameter > faceSize * 0.45) return []
const circumference = Math.PI * diameter
if (
edge.length !== undefined &&
edge.length < circumference * 0.82
) {
return []
}
const center = { ...body.center }
center[axes.horizontal] = circle.centerHorizontal
center[axes.vertical] = circle.centerVertical
center[axes.depth] = body.center[axes.depth]
return [
{
type: 'through-hole',
center,
diameter: roundDimension(diameter),
axis: axes.depth,
depth: roundDimension(thickness),
},
]
})
const deduplicated: HoleFeature[] = []
for (const candidate of candidates) {
const duplicate = deduplicated.some(
(existing) =>
existing.axis === candidate.axis &&
Math.abs(existing.diameter - candidate.diameter) < 0.2 &&
vectorLength(subtract(existing.center, candidate.center)) < 0.35
)
if (!duplicate) deduplicated.push(candidate)
}
return deduplicated.sort((left, right) => {
const horizontalDifference =
left.center[axes.horizontal] - right.center[axes.horizontal]
if (Math.abs(horizontalDifference) > EPSILON) return horizontalDifference
return left.center[axes.vertical] - right.center[axes.vertical]
})
}
function normalize(point: Point3d): Point3d {
const length = vectorLength(point)
if (length < EPSILON) {
@ -216,6 +424,10 @@ const CHINESE_PART_NAMES: Record<string, string> = {
baseboard: '底板',
bottompanel: '底板',
bracket: '支架',
camconnector: '三合一偏心连接件',
camlock: '三合一偏心轮',
connectorbolt: '连接螺栓',
connectorplate: '连接片',
dividerpanel: '隔板',
doorpanel: '门板',
dowel: '木榫',
@ -224,10 +436,12 @@ const CHINESE_PART_NAMES: Record<string, string> = {
foot: '支腿',
handle: '拉手',
hinge: '铰链',
metalbracket: '金属连接角码',
leftsidepanel: '左侧板',
leg: '支腿',
rightsidepanel: '右侧板',
screw: '螺钉',
shelfpin: '层板托',
seat: '座板',
seatboard: '座板',
seatbody: '座板',
@ -240,6 +454,7 @@ const CHINESE_PART_NAMES: Record<string, string> = {
topboard: '顶板',
topbottompanel: '顶底板',
toppanel: '顶板',
washer: '垫圈',
}
export function localizePartName(name: string) {
@ -254,6 +469,26 @@ function dimensionsKey(dimensions: Point3d) {
.join('x')
}
function holePatternKey(body: BodyMeasurement) {
return (body.holes ?? [])
.map((hole) => {
const local = {
x: hole.center.x - body.center.x,
y: hole.center.y - body.center.y,
z: hole.center.z - body.center.z,
}
return `${hole.axis}:${roundDimension(hole.diameter).toFixed(2)}:${[
local.x,
local.y,
local.z,
]
.map((value) => roundDimension(value).toFixed(2))
.join(',')}`
})
.sort()
.join('|')
}
export function inferProcess(dimensions: Point3d) {
const values = [dimensions.x, dimensions.y, dimensions.z]
.map((value) => Math.abs(value))
@ -278,7 +513,7 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
const name = localizePartName(normalizedName)
const key = `${normalizedName.toLowerCase()}::${dimensionsKey(
body.dimensions
)}`
)}::${holePatternKey(body)}`
const existing = groups.get(key)
if (existing) {
@ -302,6 +537,8 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
material: '待确认',
process: inferProcess(body.dimensions),
bodyIds: [body.id],
center: body.center,
holes: body.holes ?? [],
})
}
@ -472,6 +709,182 @@ function orthographicViewSvg(
</figure>`
}
function uniqueRounded(values: number[]) {
return [
...new Set(
values.map((value) => roundDimension(value).toFixed(2))
),
]
.map(Number)
.sort((left, right) => left - right)
}
function machiningDetailSvg(row: BomRow) {
const axes = panelAxes(row.dimensions)
const horizontal = Math.abs(row.dimensions[axes.horizontal])
const vertical = Math.abs(row.dimensions[axes.vertical])
const depth = Math.abs(row.dimensions[axes.depth])
const objectMaxWidth = 230
const objectMaxHeight = 145
const scale = Math.min(
objectMaxWidth / Math.max(horizontal, EPSILON),
objectMaxHeight / Math.max(vertical, EPSILON)
)
const objectWidth = Math.max(horizontal * scale, 1)
const objectHeight = Math.max(vertical * scale, 1)
const objectX = 75 + (objectMaxWidth - objectWidth) / 2
const objectY = 42 + (objectMaxHeight - objectHeight) / 2
const holes = row.holes.filter((hole) => hole.axis === axes.depth)
const holePositions = holes.map((hole, index) => {
const fromLeft =
hole.center[axes.horizontal] -
row.center[axes.horizontal] +
horizontal / 2
const fromBottom =
hole.center[axes.vertical] -
row.center[axes.vertical] +
vertical / 2
return {
...hole,
index: index + 1,
fromLeft: roundDimension(fromLeft),
fromBottom: roundDimension(fromBottom),
x: objectX + fromLeft * scale,
y: objectY + objectHeight - fromBottom * scale,
radius: Math.max((hole.diameter / 2) * scale, 2.5),
}
})
const horizontalHolePositions = uniqueRounded(
holePositions.map((hole) => hole.fromLeft)
)
const verticalHolePositions = uniqueRounded(
holePositions.map((hole) => hole.fromBottom)
)
const horizontalPitch =
horizontalHolePositions.length > 1
? roundDimension(
horizontalHolePositions.at(-1)! - horizontalHolePositions[0]
)
: undefined
const verticalPitch =
verticalHolePositions.length > 1
? roundDimension(
verticalHolePositions.at(-1)! - verticalHolePositions[0]
)
: undefined
const holeMarkup = holePositions
.map(
(hole) => `
<circle class="detail-hole" cx="${roundDimension(hole.x)}" cy="${roundDimension(
hole.y
)}" r="${roundDimension(hole.radius)}"/>
<line class="center-line" x1="${roundDimension(hole.x - hole.radius - 5)}"
y1="${roundDimension(hole.y)}" x2="${roundDimension(
hole.x + hole.radius + 5
)}" y2="${roundDimension(hole.y)}"/>
<line class="center-line" x1="${roundDimension(hole.x)}"
y1="${roundDimension(hole.y - hole.radius - 5)}" x2="${roundDimension(
hole.x
)}" y2="${roundDimension(hole.y + hole.radius + 5)}"/>
<text x="${roundDimension(hole.x + hole.radius + 4)}"
y="${roundDimension(hole.y - hole.radius - 3)}">H${hole.index}</text>`
)
.join('')
const holeNotes =
holePositions.length > 0
? holePositions
.map(
(hole) =>
`<li>H${hole.index}:Ø${roundDimension(
hole.diameter
)} ${hole.type === 'through-hole' ? '通孔' : `深 ${roundDimension(
hole.depth ?? 0
)}`},距左 ${hole.fromLeft},距下 ${hole.fromBottom}</li>`
)
.join('')
: '<li>该零件未识别到圆孔特征</li>'
const pitchNotes = [
horizontalPitch === undefined ? '' : `横向中心孔距 ${horizontalPitch} mm`,
verticalPitch === undefined ? '' : `纵向中心孔距 ${verticalPitch} mm`,
]
.filter(Boolean)
.join(';')
return `
<article class="part-detail">
<div class="part-detail-heading">
<strong>${row.itemNumber}. ${escapeHtml(row.name)}</strong>
<span>数量 ${row.quantity} · 厚度 ${formatDimension(depth)}</span>
</div>
<div class="part-detail-layout">
<svg viewBox="0 0 390 245" role="img" aria-label="${escapeHtml(
row.name
)} 加工尺寸图">
<defs>
<marker id="part-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>
<rect x="${roundDimension(objectX)}" y="${roundDimension(objectY)}"
width="${roundDimension(objectWidth)}" height="${roundDimension(
objectHeight
)}" fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>
${holeMarkup}
<line x1="${roundDimension(objectX)}" y1="${roundDimension(
objectY + objectHeight + 22
)}" x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(objectY + objectHeight + 22)}" stroke="#111827"
marker-start="url(#part-arrow-${row.itemNumber})"
marker-end="url(#part-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(#part-arrow-${row.itemNumber})"
marker-end="url(#part-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>
<div class="hole-schedule">
<strong>孔位与加工基准</strong>
<ul>${holeNotes}</ul>
${
pitchNotes
? `<p><strong>孔距:</strong>${escapeHtml(pitchNotes)}</p>`
: ''
}
<p><strong>基准:</strong>左下角为孔位坐标基准,尺寸单位 mm。</p>
</div>
</div>
</article>`
}
export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
const { overallDimensions: dimensions } = input
const projectionBodies =
@ -495,9 +908,13 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
<td>${formatDimension(row.dimensions.x)} × ${formatDimension(row.dimensions.y)} × ${formatDimension(row.dimensions.z)}</td>
<td>${escapeHtml(row.material)}</td>
<td>${escapeHtml(row.process)}</td>
<td>${row.holes.length > 0 ? `${row.holes.length} × Ø${escapeHtml(
uniqueRounded(row.holes.map((hole) => hole.diameter)).join(' / Ø')
)}` : '-'}</td>
</tr>`
)
.join('')
const partDetails = input.bomRows.map(machiningDetailSvg).join('')
const snapshot = input.snapshotDataUrl
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
@ -539,6 +956,17 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
th, td { border: 1px solid #6b7280; padding: 5px 6px; text-align: left; vertical-align: top; }
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 { 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 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; }
.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; }
.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; }
@ -577,11 +1005,15 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
<h2>配件明细表(BOM)</h2>
<table>
<thead>
<tr><th>序号</th><th>零件名称</th><th>数量</th><th>外形尺寸</th><th>材料</th><th>生产工艺</th></tr>
<tr><th>序号</th><th>零件名称</th><th>数量</th><th>外形尺寸</th><th>材料</th><th>生产工艺</th><th>孔位</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</section>
<section class="part-details">
<h2>零件加工详图、孔径与孔位尺寸</h2>
<div class="part-detail-grid">${partDetails}</div>
</section>
<section class="notes"><strong>生产说明:</strong><br/>${escapeHtml(input.notes || '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。')}</section>
</main>
<footer>