fix: correct HiCAD BOM geometry

This commit is contained in:
134 2026-07-28 10:41:15 +08:00 committed by root
parent 8e89bb179e
commit 0397bf3a88
3 changed files with 363 additions and 44 deletions

View File

@ -11,6 +11,7 @@ import {
getOverallBounds,
groupBodiesIntoBom,
localizePartName,
reconcileManufacturingDimensions,
type BodyMeasurement,
type CircularEdgeSample,
} from '../../vendor/zoo-modeling-app/manufacturing'
@ -249,7 +250,118 @@ test.describe('HiCAD manufacturing extension', () => {
expect(rows.some((row) => row.name === 'unitBox')).toBe(false)
})
test('reconciles transformed Safari measurements and groups source instances', async ({
page,
}, testInfo) => {
const transformedBodies: BodyMeasurement[] = [
...[
['rearRightStudSource', 202.52],
['rearLeftStudSource', 188.56],
['frontRightStudSource', 165.4],
['frontLeftStud', 178.53],
].map(
([name, volume], index): BodyMeasurement => ({
id: `stud-${index}`,
name: String(name),
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
dimensions: { x: 6, y: 6, z: 10 },
volume: Number(volume),
})
),
...[
'rearRightLegSource',
'rearLeftLegSource',
'frontRightLegSource',
'frontLeftLeg',
].map(
(name, index): BodyMeasurement => ({
id: `leg-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
dimensions: { x: 60, y: 60, z: 750 },
volume: 1_463_300 + index * 12,
})
),
{
id: 'tabletop',
name: 'tableTop',
center: { x: 0, y: 0, z: 762.5 },
dimensions: { x: 1440, y: 720, z: 25.1 },
volume: 17_976_779.67,
},
]
const reconciled = reconcileManufacturingDimensions(transformedBodies)
const rows = groupBodiesIntoBom(reconciled)
expect(rows).toHaveLength(3)
expect(rows.find((row) => row.name === '连接螺柱')).toMatchObject({
quantity: 4,
dimensions: { x: 5, y: 5, z: 10 },
})
expect(rows.find((row) => row.name === '支腿')).toMatchObject({
quantity: 4,
dimensions: { x: 50, y: 50, z: 750 },
})
expect(rows.find((row) => row.name === '台面板')).toMatchObject({
quantity: 1,
dimensions: { x: 1200, y: 600, z: 25 },
})
const html = createEngineeringSheetHtml({
projectName: '圆腿桌',
generatedAt: '2026-07-28 10:00',
overallDimensions: { x: 1200, y: 600, z: 775 },
bodies: reconciled,
bomRows: rows,
})
expect(html).toContain('data-cross-section="circular"')
expect(html).toContain('class="circular-part-profile"')
expect(html).toContain('data-profile="circular"')
expect(html).toContain('直径 Ø50 mm')
expect(html).toContain('Ø50 mm')
await page.setViewportSize({ width: 1440, height: 1000 })
await page.setContent(html)
await expect(page.locator('.circular-part-profile')).toHaveCount(2)
const legProfile = page
.locator('.part-detail[data-cross-section="circular"]')
.filter({ hasText: '支腿' })
.locator('.circular-part-profile')
const profileBounds = await legProfile.boundingBox()
expect(profileBounds).not.toBeNull()
expect(Math.abs((profileBounds?.width ?? 0) - (profileBounds?.height ?? 0))).toBeLessThan(
1
)
await page.screenshot({
fullPage: true,
path: testInfo.outputPath('safari-round-leg-engineering-sheet.png'),
})
})
test('keeps a square leg end view rectangular', () => {
const squareLeg: BodyMeasurement = {
id: 'square-leg',
name: 'leg',
center: { x: 0, y: 0, z: 375 },
dimensions: { x: 50, y: 50, z: 750 },
volume: 1_875_000,
}
const html = createEngineeringSheetHtml({
projectName: '方腿桌',
generatedAt: '2026-07-28 10:00',
overallDimensions: { x: 50, y: 50, z: 750 },
bodies: [squareLeg],
bomRows: groupBodiesIntoBom([squareLeg]),
})
expect(html).toContain('data-cross-section="rectangular"')
expect(html).not.toContain('class="circular-part-profile"')
})
test('localizes the hardware validation assembly for a Chinese BOM', () => {
expect(localizePartName('seatBody')).toBe('座板')
expect(localizePartName('tableBody')).toBe('桌体')
expect(localizePartName('mountingPanel')).toBe('安装基板')
expect(localizePartName('connectorPlate')).toBe('连接片')
expect(localizePartName('connectorBolt4')).toBe('连接螺栓')

View File

@ -15,6 +15,7 @@ import {
filterManufacturingBodies,
getOverallBounds,
groupBodiesIntoBom,
reconcileManufacturingDimensions,
type BodyMeasurement,
type BomRow,
type CircularEdgeSample,
@ -533,7 +534,9 @@ export function HicadManufacturingPanel({
)
}
const manufacturingBodies = filterManufacturingBodies(measured)
const manufacturingBodies = reconcileManufacturingDimensions(
filterManufacturingBodies(measured)
)
if (manufacturingBodies.length < measured.length) {
toast(
`已忽略 ${measured.length - manufacturingBodies.length} 个建模模板实体。`

View File

@ -410,12 +410,23 @@ export function buildExplodeOffsets(
function normalizePartName(name: string) {
const cleaned = name.trim() || '未命名零件'
const withoutCopySuffix =
cleaned.replace(/(?:[_-]?(?:copy|part))?[_-]?\d+$/i, '').trim() || cleaned
const locationLegMatch = withoutCopySuffix.match(
/^(?:front|back|rear)(?:left|right)(leg|foot)$/i
const withoutInstanceSuffix =
cleaned
.replace(
/(?:[_-]?(?:copy|part|source|solid|instance))[_-]?\d*$/i,
''
)
.replace(/[_-]?\d+$/i, '')
.trim() || cleaned
const directionalPartMatch = withoutInstanceSuffix.match(
/^(?:front|back|rear)(?:left|right)(leg|foot|stud)$/i
)
return locationLegMatch?.[1] ?? withoutCopySuffix
const normalized = directionalPartMatch?.[1] ?? withoutInstanceSuffix
if (/^(?:connector|mounting)?stud$/i.test(normalized)) {
return 'connectorStud'
}
return normalized
}
const CHINESE_PART_NAMES: Record<string, string> = {
@ -428,6 +439,7 @@ const CHINESE_PART_NAMES: Record<string, string> = {
camlock: '三合一偏心轮',
connectorbolt: '连接螺栓',
connectorplate: '连接片',
connectorstud: '连接螺柱',
dividerpanel: '隔板',
doorpanel: '门板',
dowel: '木榫',
@ -458,6 +470,136 @@ const CHINESE_PART_NAMES: Record<string, string> = {
washer: '垫圈',
}
type DimensionCorrectionCandidate = {
axes: Array<keyof Point3d>
scale: number
}
function median(values: number[]) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
function correctionGroupKey(body: BodyMeasurement) {
return `${compactPartName(normalizePartName(body.name))}::${dimensionsKey(
body.dimensions
)}`
}
function dimensionCorrectionCandidate(
body: BodyMeasurement,
representativeVolume: number
): DimensionCorrectionCandidate | undefined {
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
(left, right) =>
Math.abs(body.dimensions[left]) - Math.abs(body.dimensions[right])
)
const values = axes.map((axis) => Math.abs(body.dimensions[axis]))
const boundingVolume = values[0] * values[1] * values[2]
if (!(representativeVolume > EPSILON) || !(boundingVolume > EPSILON)) {
return undefined
}
const partName = compactPartName(normalizePartName(body.name))
const isPanel =
/(panel|board|tabletop|top|seat|shelf)$/.test(partName) &&
values[0] / values[1] < 0.35
const isRoundRod =
/(leg|foot|stud|dowel|pin)$/.test(partName) &&
values[1] / values[0] < 1.25 &&
values[2] / values[1] > 1.4
if (isPanel) {
return {
axes: [axes[1], axes[2]],
scale: Math.sqrt(representativeVolume / boundingVolume),
}
}
if (isRoundRod) {
return {
axes: [axes[0], axes[1]],
scale: Math.sqrt(
representativeVolume / ((Math.PI / 4) * boundingVolume)
),
}
}
return undefined
}
function snapCorrectedDimension(value: number) {
const absolute = Math.abs(value)
const step = absolute >= 100 ? 10 : absolute >= 20 ? 1 : 0.5
const snapped = Math.round(absolute / step) * step
return Math.abs(snapped - absolute) <= Math.max(step * 0.55, absolute * 0.01)
? snapped
: roundDimension(absolute)
}
/**
* Zoo occasionally reports a transformed scene bounding box while volume stays
* in the source part scale. Only correct dimensions when multiple distinct part
* families independently confirm the same scale, avoiding guesses from one
* hollow or filleted part.
*/
export function reconcileManufacturingDimensions(
bodies: BodyMeasurement[]
): BodyMeasurement[] {
const groups = new Map<string, BodyMeasurement[]>()
for (const body of bodies) {
const key = correctionGroupKey(body)
groups.set(key, [...(groups.get(key) ?? []), body])
}
const candidates = new Map<string, DimensionCorrectionCandidate>()
for (const [key, group] of groups) {
const volumes = group
.map((body) => body.volume)
.filter((volume): volume is number => Number.isFinite(volume))
if (volumes.length === 0) continue
const candidate = dimensionCorrectionCandidate(group[0], median(volumes))
if (candidate && candidate.scale >= 0.72 && candidate.scale <= 0.92) {
candidates.set(key, candidate)
}
}
if (candidates.size < 2) return bodies
const consensusScale = median(
[...candidates.values()].map((candidate) => candidate.scale)
)
const supported = [...candidates.values()].filter(
(candidate) => Math.abs(candidate.scale - consensusScale) <= 0.055
)
if (supported.length < 2) return bodies
return bodies.map((body) => {
const candidate = candidates.get(correctionGroupKey(body))
if (
!candidate ||
Math.abs(candidate.scale - consensusScale) > 0.055
) {
return body
}
const corrected = { ...body.dimensions }
for (const axis of candidate.axes) {
corrected[axis] *= consensusScale
}
return {
...body,
dimensions: {
x: snapCorrectedDimension(corrected.x),
y: snapCorrectedDimension(corrected.y),
z: snapCorrectedDimension(corrected.z),
},
}
})
}
export function localizePartName(name: string) {
const cleaned = normalizePartName(name.trim() || '未命名零件')
if (/[\u3400-\u9fff]/.test(cleaned)) return cleaned
@ -881,10 +1023,43 @@ function uniqueRounded(values: number[]) {
.sort((left, right) => left - right)
}
function isCircularPrismaticPart(row: BomRow) {
const values = [
Math.abs(row.dimensions.x),
Math.abs(row.dimensions.y),
Math.abs(row.dimensions.z),
].sort((left, right) => left - right)
const [diameterA, diameterB, length] = values
if (
diameterA <= EPSILON ||
diameterB / diameterA > 1.12 ||
length / diameterB < 1.4
) {
return false
}
const compactName = compactPartName(row.name)
const explicitlyRound =
/(圆形|圆柱|圆管|螺柱|木榫|销|round|cylinder|stud|dowel|pin)/i.test(
compactName
)
const possiblyRound =
explicitlyRound || /(支腿|leg|foot)/i.test(compactName)
if (!possiblyRound) return false
if (explicitlyRound && row.volume === undefined) return true
if (!(row.volume && row.volume > EPSILON)) return false
const cylinderVolume =
(Math.PI / 4) * diameterA * diameterB * length
const fillRatio = row.volume / cylinderVolume
return fillRatio >= 0.65 && fillRatio <= 1.15
}
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 isCircularSection = isCircularPrismaticPart(row)
const maxObjectWidth = 112
const maxObjectHeight = 145
const scale = Math.min(
@ -928,41 +1103,30 @@ function sideDimensionSvg(row: BomRow) {
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>
const profileMarkup = isCircularSection
? `
<circle class="circular-part-profile" data-profile="circular"
cx="${roundDimension(objectX + objectWidth / 2)}"
cy="${roundDimension(objectY + objectHeight / 2)}"
r="${roundDimension(Math.min(objectWidth, objectHeight) / 2)}"
fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>
<line class="center-line" x1="${roundDimension(objectX - 7)}"
y1="${roundDimension(objectY + objectHeight / 2)}"
x2="${roundDimension(objectX + objectWidth + 7)}"
y2="${roundDimension(objectY + objectHeight / 2)}"/>
<line class="center-line" x1="${roundDimension(
objectX + objectWidth / 2
)}" y1="${roundDimension(objectY - 7)}"
x2="${roundDimension(objectX + objectWidth / 2)}"
y2="${roundDimension(objectY + objectHeight + 7)}"/>`
: `
<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>
)}" fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>`
const verticalDimensionMarkup = isCircularSection
? ''
: `
<line x1="${roundDimension(objectX - 22)}" y1="${roundDimension(objectY)}"
x2="${roundDimension(objectX - 22)}" y2="${roundDimension(
objectY + objectHeight
@ -982,7 +1146,42 @@ function sideDimensionSvg(row: BomRow) {
objectX - 37
)} ${roundDimension(objectY + objectHeight / 2)})">${formatDimension(
vertical
)}</text>
)}</text>`
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>
${profileMarkup}
${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">${
isCircularSection ? 'Ø' : ''
}${formatDimension(horizontal)}</text>
${verticalDimensionMarkup}
</svg>`
}
@ -991,6 +1190,7 @@ function machiningDetailSvg(row: BomRow) {
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 isCircularSection = isCircularPrismaticPart(row)
const objectMaxWidth = 230
const objectMaxHeight = 145
const scale = Math.min(
@ -1078,10 +1278,14 @@ function machiningDetailSvg(row: BomRow) {
.join(';')
return `
<article class="part-detail">
<article class="part-detail" data-cross-section="${
isCircularSection ? 'circular' : 'rectangular'
}">
<div class="part-detail-heading">
<strong>${row.itemNumber}. ${escapeHtml(row.name)}</strong>
<span>数量 ${row.quantity} · 厚度 ${formatDimension(depth)}</span>
<span>数量 ${row.quantity} · ${
isCircularSection ? '直径 Ø' : '厚度 '
}${formatDimension(depth)}</span>
</div>
<div class="part-detail-layout">
<svg class="part-front-view" viewBox="0 0 390 245" role="img" aria-label="${escapeHtml(
@ -1135,9 +1339,9 @@ function machiningDetailSvg(row: BomRow) {
y="${roundDimension(objectY + objectHeight / 2)}" text-anchor="middle"
transform="rotate(-90 ${roundDimension(
objectX - 37
)} ${roundDimension(objectY + objectHeight / 2)})">${formatDimension(
vertical
)}</text>
)} ${roundDimension(objectY + objectHeight / 2)})">${
isCircularSection ? 'Ø' : ''
}${formatDimension(vertical)}</text>
</svg>
${sideDimensionSvg(row)}
<div class="hole-schedule">