fix: reconcile HiCAD manufacturing geometry

This commit is contained in:
134 2026-07-28 14:24:08 +08:00
parent 1068072bd3
commit 513df646de
3 changed files with 236 additions and 48 deletions

View File

@ -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', () => { test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => {
const coldBrowserBodies: BodyMeasurement[] = [ const coldBrowserBodies: BodyMeasurement[] = [
...[ ...[

View File

@ -823,7 +823,6 @@ export function HicadManufacturingPanel({
bodies, bodies,
brandName: brand.name, brandName: brand.name,
brandLogoUrl: brand.logoUrl, brandLogoUrl: brand.logoUrl,
snapshotDataUrl: snapshot,
bomRows, bomRows,
notes, notes,
}) })

View File

@ -682,14 +682,18 @@ export function buildExplodeOffsets(
function normalizePartName(name: string) { function normalizePartName(name: string) {
const cleaned = name.trim() || '未命名零件' const cleaned = name.trim() || '未命名零件'
const withoutInstanceSuffix = let withoutInstanceSuffix = cleaned
cleaned for (let index = 0; index < 4; index += 1) {
const next = withoutInstanceSuffix
.replace( .replace(
/(?:[_-]?(?:copy|part|source|solid|instance))[_-]?\d*$/i, /(?:[_-]?(?:copy|part|source|solid|instance|assembly))[_-]?\d*$/i,
'' ''
) )
.replace(/[_-]?\d+$/i, '') .replace(/[_-]?\d+$/i, '')
.trim() || cleaned .trim()
if (!next || next === withoutInstanceSuffix) break
withoutInstanceSuffix = next
}
const directionalPartMatch = withoutInstanceSuffix.match( const directionalPartMatch = withoutInstanceSuffix.match(
/^(?:front|back|rear)(?:left|right)(leg|foot|stud)$/i /^(?:front|back|rear)(?:left|right)(leg|foot|stud)$/i
) )
@ -743,8 +747,7 @@ const CHINESE_PART_NAMES: Record<string, string> = {
} }
type DimensionCorrectionCandidate = { type DimensionCorrectionCandidate = {
axes: Array<keyof Point3d> dimensions: Point3d
scale: number
} }
function median(values: number[]) { function median(values: number[]) {
@ -777,24 +780,66 @@ function dimensionCorrectionCandidate(
const partName = compactPartName(normalizePartName(body.name)) const partName = compactPartName(normalizePartName(body.name))
const isPanel = 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 values[0] / values[1] < 0.35
const isRoundRod = const isRoundRod =
/(leg|foot|stud|dowel|pin)$/.test(partName) && /(leg|foot|stud|dowel|pin|bolt)$/.test(partName) &&
values[1] / values[0] < 1.25 && values[1] / values[0] < 1.25 &&
values[2] / values[1] > 1.4 values[2] / values[1] > 1.4
if (isPanel) { 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 { return {
axes: [axes[1], axes[2]], dimensions: mapSortedDimensions(body.dimensions, correctedAscending),
scale: Math.sqrt(representativeVolume / boundingVolume),
} }
} }
if (isRoundRod) { 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 { return {
axes: [axes[0], axes[1]], dimensions: mapSortedDimensions(
scale: Math.sqrt( body.dimensions,
representativeVolume / ((Math.PI / 4) * boundingVolume) [
correctedDiameter,
correctedDiameter,
snapCorrectedDimension(axialLength),
].sort((left, right) => left - right) as [number, number, number]
), ),
} }
} }
@ -955,6 +1000,92 @@ function bestHoleParameter(
return candidates[0].parameter 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<keyof Point3d>).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( export function reconcileManufacturingHoles(
bodies: BodyMeasurement[], bodies: BodyMeasurement[],
lengthParameters: ManufacturingLengthParameter[] lengthParameters: ManufacturingLengthParameter[]
@ -971,14 +1102,14 @@ export function reconcileManufacturingHoles(
(parameter): parameter is ClassifiedHoleParameter => (parameter): parameter is ClassifiedHoleParameter =>
parameter !== undefined parameter !== undefined
) )
if (holeParameters.length === 0) return bodies if (holeParameters.length === 0) return inferFastenerHoles(bodies)
const panelBodies = bodies.filter(isPanelBody) const panelBodies = bodies.filter(isPanelBody)
const genericTarget = [...panelBodies].sort( const genericTarget = [...panelBodies].sort(
(left, right) => panelFaceArea(right) - panelFaceArea(left) (left, right) => panelFaceArea(right) - panelFaceArea(left)
)[0] )[0]
return bodies.map((body) => { const parameterBodies = bodies.map((body) => {
if (!isPanelBody(body)) return body if (!isPanelBody(body)) return body
const allowGeneric = body.id === genericTarget?.id const allowGeneric = body.id === genericTarget?.id
const diameterParameter = bestHoleParameter( const diameterParameter = bestHoleParameter(
@ -1092,13 +1223,13 @@ export function reconcileManufacturingHoles(
) )
return { ...body, holes } return { ...body, holes }
}) })
return inferFastenerHoles(parameterBodies)
} }
/** /**
* Zoo occasionally reports a transformed scene bounding box while volume stays * Zoo occasionally reports transformed scene bounds while physical volume
* in the source part scale. Only correct dimensions when multiple distinct part * remains in source-part scale. Reconcile each recognized manufacturing shape
* families independently confirm the same scale, avoiding guesses from one * independently so unlike shapes do not need to share a browser transform.
* hollow or filleted part.
*/ */
export function reconcileManufacturingDimensions( export function reconcileManufacturingDimensions(
bodies: BodyMeasurement[], bodies: BodyMeasurement[],
@ -1130,22 +1261,7 @@ export function reconcileManufacturingDimensions(
if (volumes.length === 0) continue if (volumes.length === 0) continue
const candidate = dimensionCorrectionCandidate(group[0], median(volumes)) const candidate = dimensionCorrectionCandidate(group[0], median(volumes))
if (candidate && candidate.scale >= 0.72 && candidate.scale <= 0.92) { if (candidate) candidates.set(key, candidate)
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
}
} }
const dimensionBodies = bodies.map<BodyMeasurement>((body) => { const dimensionBodies = bodies.map<BodyMeasurement>((body) => {
@ -1168,25 +1284,17 @@ export function reconcileManufacturingDimensions(
} }
const candidate = candidates.get(correctionGroupKey(body)) const candidate = candidates.get(correctionGroupKey(body))
if ( if (!candidate) {
consensusScale === undefined ||
!candidate ||
Math.abs(candidate.scale - consensusScale) > 0.055
) {
return { return {
...body, ...body,
dimensionSource: body.dimensionSource ?? 'engine', dimensionSource: body.dimensionSource ?? 'engine',
} }
} }
const corrected = { ...body.dimensions }
for (const axis of candidate.axes) {
corrected[axis] *= consensusScale
}
const normalized = { const normalized = {
x: snapCorrectedDimension(corrected.x), x: snapCorrectedDimension(candidate.dimensions.x),
y: snapCorrectedDimension(corrected.y), y: snapCorrectedDimension(candidate.dimensions.y),
z: snapCorrectedDimension(corrected.z), z: snapCorrectedDimension(candidate.dimensions.z),
} }
return { return {
...body, ...body,