fix: reconcile HiCAD manufacturing geometry
This commit is contained in:
parent
1068072bd3
commit
513df646de
@ -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[] = [
|
||||
...[
|
||||
|
||||
@ -823,7 +823,6 @@ export function HicadManufacturingPanel({
|
||||
bodies,
|
||||
brandName: brand.name,
|
||||
brandLogoUrl: brand.logoUrl,
|
||||
snapshotDataUrl: snapshot,
|
||||
bomRows,
|
||||
notes,
|
||||
})
|
||||
|
||||
202
vendor/zoo-modeling-app/manufacturing.ts
vendored
202
vendor/zoo-modeling-app/manufacturing.ts
vendored
@ -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<string, string> = {
|
||||
}
|
||||
|
||||
type DimensionCorrectionCandidate = {
|
||||
axes: Array<keyof Point3d>
|
||||
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<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(
|
||||
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<BodyMeasurement>((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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user