From 513df646de0e4565527f15e9c485e429e27c6313 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 28 Jul 2026 14:24:08 +0800 Subject: [PATCH] fix: reconcile HiCAD manufacturing geometry --- .../hicad-manufacturing-extension.test.ts | 81 +++++++ .../HicadManufacturingPanel.tsx.template | 1 - vendor/zoo-modeling-app/manufacturing.ts | 202 ++++++++++++++---- 3 files changed, 236 insertions(+), 48 deletions(-) diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts index 83df0833d..fa4d47a8d 100644 --- a/tests/routes/hicad-manufacturing-extension.test.ts +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -352,6 +352,87 @@ test.describe('HiCAD manufacturing extension', () => { }) }) + test('reconciles mixed assembly bounds without requiring one shared scale', () => { + const mixedBrowserBodies: BodyMeasurement[] = [ + ...[ + 'rearRightStudSource', + 'rearLeftStudSource', + 'frontRightStudSource', + 'frontLeftStudSource', + ].map( + (name, index): BodyMeasurement => ({ + id: `mixed-stud-${index}`, + name, + center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 }, + dimensions: { x: 5, y: 5, z: 10 }, + volume: 196.35, + }) + ), + ...[ + 'rearRightLegSource', + 'rearLeftLegSource', + 'frontRightLegSource', + 'frontLeftLegSource', + ].map( + (name, index): BodyMeasurement => ({ + id: `mixed-leg-${index}`, + name, + center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 }, + dimensions: { x: 49, y: 49, z: 750 }, + volume: 1_463_167.94, + }) + ), + { + id: 'mixed-tabletop', + name: 'tableTopAssembly', + center: { x: 0, y: 0, z: 762.5 }, + dimensions: { x: 1440, y: 720, z: 25.2 }, + volume: 17_976_787.12, + }, + ] + + const reconciled = reconcileManufacturingDimensions(mixedBrowserBodies) + 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 }, + }) + expect( + reconciled.find((body) => body.id === 'mixed-tabletop')?.holes + ).toMatchObject([ + { type: 'blind-hole', diameter: 5, depth: 10 }, + { type: 'blind-hole', diameter: 5, depth: 10 }, + { type: 'blind-hole', diameter: 5, depth: 10 }, + { type: 'blind-hole', diameter: 5, depth: 10 }, + ]) + expect(rows.some((row) => row.name === 'tableTopAssembly')).toBe(false) + }) + + test('does not reinterpret a solid square leg as a round rod', () => { + const [leg] = reconcileManufacturingDimensions([ + { + id: 'square-leg', + name: 'leg', + center: { x: 0, y: 0, z: 375 }, + dimensions: { x: 50, y: 50, z: 750 }, + volume: 1_875_000, + }, + ]) + + expect(leg.dimensions).toEqual({ x: 50, y: 50, z: 750 }) + expect(leg.dimensionSource).toBe('engine') + }) + test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => { const coldBrowserBodies: BodyMeasurement[] = [ ...[ diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template index a3f4728e6..8248fefd1 100644 --- a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template +++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template @@ -823,7 +823,6 @@ export function HicadManufacturingPanel({ bodies, brandName: brand.name, brandLogoUrl: brand.logoUrl, - snapshotDataUrl: snapshot, bomRows, notes, }) diff --git a/vendor/zoo-modeling-app/manufacturing.ts b/vendor/zoo-modeling-app/manufacturing.ts index 9af4259e6..1ef2b81d7 100644 --- a/vendor/zoo-modeling-app/manufacturing.ts +++ b/vendor/zoo-modeling-app/manufacturing.ts @@ -682,14 +682,18 @@ export function buildExplodeOffsets( function normalizePartName(name: string) { const cleaned = name.trim() || '未命名零件' - const withoutInstanceSuffix = - cleaned + let withoutInstanceSuffix = cleaned + for (let index = 0; index < 4; index += 1) { + const next = withoutInstanceSuffix .replace( - /(?:[_-]?(?:copy|part|source|solid|instance))[_-]?\d*$/i, + /(?:[_-]?(?:copy|part|source|solid|instance|assembly))[_-]?\d*$/i, '' ) .replace(/[_-]?\d+$/i, '') - .trim() || cleaned + .trim() + if (!next || next === withoutInstanceSuffix) break + withoutInstanceSuffix = next + } const directionalPartMatch = withoutInstanceSuffix.match( /^(?:front|back|rear)(?:left|right)(leg|foot|stud)$/i ) @@ -743,8 +747,7 @@ const CHINESE_PART_NAMES: Record = { } type DimensionCorrectionCandidate = { - axes: Array - scale: number + dimensions: Point3d } function median(values: number[]) { @@ -777,24 +780,66 @@ function dimensionCorrectionCandidate( const partName = compactPartName(normalizePartName(body.name)) const isPanel = - /(panel|board|tabletop|top|seat|shelf)$/.test(partName) && + /(panel|board|tabletop|desktop|worktop|countertop|top|seat|shelf)$/.test( + partName + ) && values[0] / values[1] < 0.35 const isRoundRod = - /(leg|foot|stud|dowel|pin)$/.test(partName) && + /(leg|foot|stud|dowel|pin|bolt)$/.test(partName) && values[1] / values[0] < 1.25 && values[2] / values[1] > 1.4 if (isPanel) { + const thickness = snapCorrectedDimension(values[0]) + const faceAspectRatio = values[2] / Math.max(values[1], EPSILON) + const faceArea = representativeVolume / Math.max(thickness, EPSILON) + const shortFace = Math.sqrt(faceArea / faceAspectRatio) + const longFace = shortFace * faceAspectRatio + const faceScale = shortFace / Math.max(values[1], EPSILON) + const correctedAscending = [ + thickness, + snapCorrectedDimension(shortFace), + snapCorrectedDimension(longFace), + ] as [number, number, number] + + // This branch repairs the known transformed Safari scene bounds. A broad + // cutout can also reduce volume, so only accept the characteristic scale + // range and manufacturing-grid dimensions instead of shrinking every panel. + if ( + faceScale < 0.72 || + faceScale > 0.92 || + correctedAscending.some((value) => !(value > EPSILON)) + ) { + return undefined + } return { - axes: [axes[1], axes[2]], - scale: Math.sqrt(representativeVolume / boundingVolume), + dimensions: mapSortedDimensions(body.dimensions, correctedAscending), } } if (isRoundRod) { + const axialLength = values[2] + const crossSectionFill = + representativeVolume / + Math.max(values[0] * values[1] * axialLength, EPSILON) + if (crossSectionFill < 0.42 || crossSectionFill > 0.9) { + return undefined + } + const diameter = Math.sqrt( + (4 * representativeVolume) / (Math.PI * axialLength) + ) + const diameterScale = diameter / Math.max((values[0] + values[1]) / 2, EPSILON) + if (diameterScale < 0.72 || diameterScale > 1.08) { + return undefined + } + const correctedDiameter = snapCorrectedDimension(diameter) return { - axes: [axes[0], axes[1]], - scale: Math.sqrt( - representativeVolume / ((Math.PI / 4) * boundingVolume) + dimensions: mapSortedDimensions( + body.dimensions, + [ + correctedDiameter, + correctedDiameter, + snapCorrectedDimension(axialLength), + ].sort((left, right) => left - right) as [number, number, number] ), } } @@ -955,6 +1000,92 @@ function bestHoleParameter( return candidates[0].parameter } +function inferFastenerHoles(bodies: BodyMeasurement[]) { + const fasteners = bodies.filter((body) => + /(?:stud|bolt|dowel|pin|screw|螺柱|螺栓|木榫|销|螺钉)/i.test( + normalizePartName(body.name) + ) + ) + if (fasteners.length === 0) return bodies + + return bodies.map((body) => { + if (!isPanelBody(body) || (body.holes ?? []).length > 0) return body + + const axes = panelAxes(body.dimensions) + const thickness = Math.abs(body.dimensions[axes.depth]) + const engineDimensions = body.engineDimensions ?? body.dimensions + const inferred = fasteners.flatMap((fastener): HoleFeature[] => { + const fastenerAxes = (['x', 'y', 'z'] as Array).sort( + (left, right) => + Math.abs(fastener.dimensions[left]) - + Math.abs(fastener.dimensions[right]) + ) + const fastenerDepth = Math.abs( + fastener.dimensions[fastenerAxes[2]] + ) + const diameter = + (Math.abs(fastener.dimensions[fastenerAxes[0]]) + + Math.abs(fastener.dimensions[fastenerAxes[1]])) / + 2 + if ( + !(diameter > EPSILON) || + fastenerDepth / Math.max(diameter, EPSILON) < 1.25 + ) { + return [] + } + + const depthDistance = Math.abs( + fastener.center[axes.depth] - body.center[axes.depth] + ) + if ( + depthDistance > + thickness / 2 + fastenerDepth / 2 + Math.max(1, thickness * 0.08) + ) { + return [] + } + + const center = { ...body.center } + for (const faceAxis of [axes.horizontal, axes.vertical]) { + const engineSize = Math.abs(engineDimensions[faceAxis]) + const correctedSize = Math.abs(body.dimensions[faceAxis]) + const local = fastener.center[faceAxis] - body.center[faceAxis] + center[faceAxis] = + body.center[faceAxis] + + local * (correctedSize / Math.max(engineSize, EPSILON)) + if ( + Math.abs(center[faceAxis] - body.center[faceAxis]) + diameter / 2 > + correctedSize / 2 + EPSILON + ) { + return [] + } + } + + const depth = Math.min(fastenerDepth, thickness) + return [ + { + type: + depth < thickness - Math.max(0.1, thickness * 0.02) + ? 'blind-hole' + : 'through-hole', + center, + diameter: roundDimension(diameter), + axis: axes.depth, + depth: roundDimension(depth), + }, + ] + }) + const holes = inferred.filter( + (candidate, index) => + inferred.findIndex( + (existing) => + existing.axis === candidate.axis && + vectorLength(subtract(existing.center, candidate.center)) < 0.35 + ) === index + ) + return holes.length > 0 ? { ...body, holes } : body + }) +} + export function reconcileManufacturingHoles( bodies: BodyMeasurement[], lengthParameters: ManufacturingLengthParameter[] @@ -971,14 +1102,14 @@ export function reconcileManufacturingHoles( (parameter): parameter is ClassifiedHoleParameter => parameter !== undefined ) - if (holeParameters.length === 0) return bodies + if (holeParameters.length === 0) return inferFastenerHoles(bodies) const panelBodies = bodies.filter(isPanelBody) const genericTarget = [...panelBodies].sort( (left, right) => panelFaceArea(right) - panelFaceArea(left) )[0] - return bodies.map((body) => { + const parameterBodies = bodies.map((body) => { if (!isPanelBody(body)) return body const allowGeneric = body.id === genericTarget?.id const diameterParameter = bestHoleParameter( @@ -1092,13 +1223,13 @@ export function reconcileManufacturingHoles( ) return { ...body, holes } }) + return inferFastenerHoles(parameterBodies) } /** - * 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. + * Zoo occasionally reports transformed scene bounds while physical volume + * remains in source-part scale. Reconcile each recognized manufacturing shape + * independently so unlike shapes do not need to share a browser transform. */ export function reconcileManufacturingDimensions( bodies: BodyMeasurement[], @@ -1130,22 +1261,7 @@ export function reconcileManufacturingDimensions( 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) - } - } - - let consensusScale: number | undefined - if (candidates.size >= 2) { - const proposedScale = median( - [...candidates.values()].map((candidate) => candidate.scale) - ) - const supported = [...candidates.values()].filter( - (candidate) => Math.abs(candidate.scale - proposedScale) <= 0.055 - ) - if (supported.length >= 2) { - consensusScale = proposedScale - } + if (candidate) candidates.set(key, candidate) } const dimensionBodies = bodies.map((body) => { @@ -1168,25 +1284,17 @@ export function reconcileManufacturingDimensions( } const candidate = candidates.get(correctionGroupKey(body)) - if ( - consensusScale === undefined || - !candidate || - Math.abs(candidate.scale - consensusScale) > 0.055 - ) { + if (!candidate) { return { ...body, dimensionSource: body.dimensionSource ?? 'engine', } } - const corrected = { ...body.dimensions } - for (const axis of candidate.axes) { - corrected[axis] *= consensusScale - } const normalized = { - x: snapCorrectedDimension(corrected.x), - y: snapCorrectedDimension(corrected.y), - z: snapCorrectedDimension(corrected.z), + x: snapCorrectedDimension(candidate.dimensions.x), + y: snapCorrectedDimension(candidate.dimensions.y), + z: snapCorrectedDimension(candidate.dimensions.z), } return { ...body,