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', () => {
|
test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => {
|
||||||
const coldBrowserBodies: BodyMeasurement[] = [
|
const coldBrowserBodies: BodyMeasurement[] = [
|
||||||
...[
|
...[
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
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) {
|
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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user