From 0397bf3a8886a83183c789fb4cdeae1c24c665db Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 28 Jul 2026 10:41:15 +0800 Subject: [PATCH] fix: correct HiCAD BOM geometry --- .../hicad-manufacturing-extension.test.ts | 112 +++++++ .../HicadManufacturingPanel.tsx.template | 5 +- vendor/zoo-modeling-app/manufacturing.ts | 290 +++++++++++++++--- 3 files changed, 363 insertions(+), 44 deletions(-) diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts index 43e8197b9..b880e7aa7 100644 --- a/tests/routes/hicad-manufacturing-extension.test.ts +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -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('连接螺栓') diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template index fa5743127..459beb830 100644 --- a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template +++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template @@ -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} 个建模模板实体。` diff --git a/vendor/zoo-modeling-app/manufacturing.ts b/vendor/zoo-modeling-app/manufacturing.ts index bf7e4d068..149cd54c1 100644 --- a/vendor/zoo-modeling-app/manufacturing.ts +++ b/vendor/zoo-modeling-app/manufacturing.ts @@ -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 = { @@ -428,6 +439,7 @@ const CHINESE_PART_NAMES: Record = { camlock: '三合一偏心轮', connectorbolt: '连接螺栓', connectorplate: '连接片', + connectorstud: '连接螺柱', dividerpanel: '隔板', doorpanel: '门板', dowel: '木榫', @@ -458,6 +470,136 @@ const CHINESE_PART_NAMES: Record = { washer: '垫圈', } +type DimensionCorrectionCandidate = { + axes: Array + 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).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() + for (const body of bodies) { + const key = correctionGroupKey(body) + groups.set(key, [...(groups.get(key) ?? []), body]) + } + + const candidates = new Map() + 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 ` - - - - - - - 侧面图 + const profileMarkup = isCircularSection + ? ` + + + ` + : ` - ${hiddenHoleMarkup} - - - - ${formatDimension(horizontal)} + )}" fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>` + const verticalDimensionMarkup = isCircularSection + ? '' + : ` ${formatDimension( vertical - )} + )}` + + return ` + + + + + + + 侧面图 + ${profileMarkup} + ${hiddenHoleMarkup} + + + + ${ + isCircularSection ? 'Ø' : '' + }${formatDimension(horizontal)} + ${verticalDimensionMarkup} ` } @@ -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 ` -
+
${row.itemNumber}. ${escapeHtml(row.name)} - 数量 ${row.quantity} · 厚度 ${formatDimension(depth)} + 数量 ${row.quantity} · ${ + isCircularSection ? '直径 Ø' : '厚度 ' + }${formatDimension(depth)}
${formatDimension( - vertical - )} + )} ${roundDimension(objectY + objectHeight / 2)})">${ + isCircularSection ? 'Ø' : '' + }${formatDimension(vertical)} ${sideDimensionSvg(row)}