fix: stabilize HiCAD manufacturing dimensions

This commit is contained in:
134 2026-07-28 13:42:56 +08:00 committed by root
parent 0397bf3a88
commit a2c9404a3d
3 changed files with 517 additions and 31 deletions

View File

@ -11,9 +11,11 @@ import {
getOverallBounds,
groupBodiesIntoBom,
localizePartName,
parseManufacturingLengthInMillimeters,
reconcileManufacturingDimensions,
type BodyMeasurement,
type CircularEdgeSample,
type ManufacturingLengthParameter,
} from '../../vendor/zoo-modeling-app/manufacturing'
const bodies: BodyMeasurement[] = [
@ -202,6 +204,17 @@ test.describe('HiCAD manufacturing extension', () => {
expect(buildExplodeOffsets([bodies[0]])).toEqual([])
})
test('converts evaluated KCL length units to millimeters', () => {
expect(parseManufacturingLengthInMillimeters('120 cm')).toBe(1200)
expect(parseManufacturingLengthInMillimeters('1.2m')).toBe(1200)
expect(parseManufacturingLengthInMillimeters('47.24409449 in')).toBeCloseTo(
1200,
5
)
expect(parseManufacturingLengthInMillimeters('1200')).toBeUndefined()
expect(parseManufacturingLengthInMillimeters('90 deg')).toBeUndefined()
})
test('groups matching parts and exports a UTF-8 BOM', () => {
const rows = groupBodiesIntoBom(bodies)
const legRow = rows.find((row) => row.name === '支腿')
@ -339,6 +352,81 @@ test.describe('HiCAD manufacturing extension', () => {
})
})
test('uses evaluated KCL parameters when a cold browser has no physical measurements', () => {
const coldBrowserBodies: BodyMeasurement[] = [
...[
'rearRightStudSource',
'rearLeftStudSource',
'frontRightStudSource',
'frontLeftStud',
].map(
(name, index): BodyMeasurement => ({
id: `cold-stud-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 760 },
dimensions: { x: 6, y: 6, z: 10 },
})
),
...[
'rearRightLegSource',
'rearLeftLegSource',
'frontRightLegSource',
'frontLeftLeg',
].map(
(name, index): BodyMeasurement => ({
id: `cold-leg-${index}`,
name,
center: { x: index % 2 ? 500 : -500, y: index < 2 ? -200 : 200, z: 375 },
dimensions: { x: 60, y: 60, z: 750 },
})
),
{
id: 'cold-tabletop',
name: 'tableTop',
center: { x: 0, y: 0, z: 762.5 },
dimensions: { x: 1440, y: 720, z: 25.1 },
},
]
const parameters: ManufacturingLengthParameter[] = [
{ name: 'tableLength', valueMm: 1200 },
{ name: 'tableWidth', valueMm: 600 },
{ name: 'tableTopThickness', valueMm: 25 },
{ name: 'legDiameter', valueMm: 50 },
{ name: 'legHeight', valueMm: 750 },
{ name: 'studDiameter', valueMm: 5 },
{ name: 'studHeight', valueMm: 10 },
{ name: 'unrelatedCabinetWidth', valueMm: 900 },
]
const reconciled = reconcileManufacturingDimensions(
coldBrowserBodies,
parameters
)
const rows = groupBodiesIntoBom(reconciled)
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.every(
(body) =>
body.dimensionSource === 'kcl-parameters' &&
body.engineDimensions !== undefined &&
body.dimensionReferences &&
body.dimensionReferences.length >= 2
)
).toBe(true)
})
test('keeps a square leg end view rectangular', () => {
const squareLeg: BodyMeasurement = {
id: 'square-leg',

View File

@ -1,5 +1,6 @@
import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
import { humanDisplayNumber, type VariableMap } from '@src/lang/wasm'
import { useSingletons } from '@src/lib/boot'
import { isModelingResponse } from '@src/lib/kcSdkGuards'
import { engineStreamZoomToFit, engineViewIsometric } from '@src/lib/utils'
@ -15,11 +16,13 @@ import {
filterManufacturingBodies,
getOverallBounds,
groupBodiesIntoBom,
parseManufacturingLengthInMillimeters,
reconcileManufacturingDimensions,
type BodyMeasurement,
type BomRow,
type CircularEdgeSample,
type ExplodeOffset,
type ManufacturingLengthParameter,
} from './manufacturing'
type ModelingResponse = {
@ -41,6 +44,42 @@ type BrandSettings = {
logoUrl: string
}
function collectManufacturingLengthParameters(
variables: VariableMap,
wasmInstance: Parameters<typeof humanDisplayNumber>[2]
): ManufacturingLengthParameter[] {
return Object.entries(variables).flatMap(([name, value]) => {
if (!value || value.type !== 'Number') return []
const displayValue = humanDisplayNumber(value.value, value.ty, wasmInstance)
if (displayValue instanceof Error) return []
const valueMm = parseManufacturingLengthInMillimeters(displayValue)
return valueMm === undefined ? [] : [{ name, valueMm }]
})
}
async function settleInBatches<T, Result>(
values: T[],
batchSize: number,
worker: (value: T, index: number) => Promise<Result>
) {
const results: Array<PromiseSettledResult<Result>> = []
for (let offset = 0; offset < values.length; offset += batchSize) {
const batch = values.slice(offset, offset + batchSize)
results.push(
...(await Promise.allSettled(
batch.map((value, index) => worker(value, offset + index))
))
)
}
return results
}
function isTransientEngineFailure(message: string) {
return /(?:not ready|not initialized|initializ|temporar|timeout|timed out|busy|pending|connection|disconnected|stream|try again|retry|尚未就绪|初始化|连接|繁忙|稍后重试)/i.test(
message
)
}
function downloadText(filename: string, text: string, type: string) {
const blob = new Blob([text], { type })
const url = URL.createObjectURL(blob)
@ -202,6 +241,12 @@ export function HicadManufacturingPanel({
}
if (response !== null && response !== undefined) {
const message = engineFailureMessage(response)
if (attempt < maxAttempts - 1 && isTransientEngineFailure(message)) {
await new Promise((resolve) =>
setTimeout(resolve, Math.min(150 * (attempt + 1), 900))
)
continue
}
console.error('[HiCAD manufacturing] Engine command failed', {
command: cmd.type,
message,
@ -262,8 +307,10 @@ export function HicadManufacturingPanel({
const childIds = childResponse.data.entity_ids.filter(
(id): id is string => typeof id === 'string'
)
const typedChildren = await Promise.allSettled(
childIds.map(async (entityId) => {
const typedChildren = await settleInBatches(
childIds,
6,
async (entityId) => {
const response = await sendModelingCommand({
type: 'get_entity_type',
entity_id: entityId,
@ -277,7 +324,7 @@ export function HicadManufacturingPanel({
? response.data.entity_type
: undefined
return { entityId, entityType }
})
}
)
const curveCandidates = typedChildren.flatMap((result) =>
result.status === 'fulfilled' &&
@ -286,8 +333,10 @@ export function HicadManufacturingPanel({
? [result.value]
: []
)
const edgeResults = await Promise.allSettled(
curveCandidates.map(async ({ entityId, entityType }) => {
const edgeResults = await settleInBatches(
curveCandidates,
4,
async ({ entityId, entityType }) => {
const typeResponse = await sendModelingCommand({
type: 'curve_get_type',
curve_id: entityId,
@ -349,7 +398,7 @@ export function HicadManufacturingPanel({
length = lengthResult.value.data.length
}
return { controlPoints, length } satisfies CircularEdgeSample
})
}
)
return edgeResults.flatMap((result) =>
@ -458,12 +507,16 @@ export function HicadManufacturingPanel({
throw new Error('当前模型还没有可识别的实体')
}
const variables = kclManager.variablesSignal.value as Record<
string,
unknown
>
const measurementResults = await Promise.allSettled(
bodyIds.map(async (bodyId, index): Promise<BodyMeasurement> => {
const variables = kclManager.variablesSignal.value as VariableMap
const wasmInstance = await kclManager.wasmInstancePromise
const lengthParameters = collectManufacturingLengthParameters(
variables,
wasmInstance
)
const measurementResults = await settleInBatches(
bodyIds,
3,
async (bodyId, index): Promise<BodyMeasurement> => {
const boundingBox = await readBoundingBox([bodyId])
const [volumeResponse, areaResponse] = await Promise.allSettled([
sendModelingCommand({
@ -495,7 +548,7 @@ export function HicadManufacturingPanel({
const circularEdges = await readCircularEdges(bodyId)
measurement.holes = detectPanelHoles(measurement, circularEdges)
return measurement
})
}
)
const measured = measurementResults.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
@ -535,8 +588,22 @@ export function HicadManufacturingPanel({
}
const manufacturingBodies = reconcileManufacturingDimensions(
filterManufacturingBodies(measured)
filterManufacturingBodies(measured),
lengthParameters
)
const parametricCount = manufacturingBodies.filter(
(body) => body.dimensionSource === 'kcl-parameters'
).length
const correctedCount = manufacturingBodies.filter(
(body) => body.engineDimensions !== undefined
).length
if (parametricCount > 0) {
toast.success(
correctedCount > 0
? `已按 KCL 参数校准 ${parametricCount} 个零件,修正 ${correctedCount} 项场景缩放尺寸。`
: `已使用 KCL 参数核验 ${parametricCount} 个零件的制造尺寸。`
)
}
if (manufacturingBodies.length < measured.length) {
toast(
`已忽略 ${measured.length - manufacturingBodies.length} 个建模模板实体。`
@ -662,6 +729,17 @@ export function HicadManufacturingPanel({
[bodies, overrides]
)
const overall = useMemo(() => getOverallBounds(bodies), [bodies])
const parametricDimensionCount = useMemo(
() =>
bodies.filter((body) => body.dimensionSource === 'kcl-parameters').length,
[bodies]
)
const volumeCheckedDimensionCount = useMemo(
() =>
bodies.filter((body) => body.dimensionSource === 'engine-volume-check')
.length,
[bodies]
)
const updateRow = (
row: BomRow,
@ -915,7 +993,12 @@ export function HicadManufacturingPanel({
</table>
</div>
<p className="mt-3 text-xs text-chalkboard-60 dark:text-chalkboard-40">
尺寸来自模型实体包围盒。材料、工艺、公差和连接方式必须由工程人员投产前复核。
{parametricDimensionCount > 0
? `制造尺寸优先采用 KCL 已求值参数(${parametricDimensionCount} 个实体)`
: volumeCheckedDimensionCount > 0
? `制造尺寸已通过实体体积交叉校验(${volumeCheckedDimensionCount} 个实体)`
: '当前制造尺寸来自模型实体包围盒'}
。材料、工艺、公差和连接方式必须由工程人员投产前复核。
</p>
</section>
</div>

View File

@ -22,11 +22,19 @@ export type BodyMeasurement = {
name: string
center: Point3d
dimensions: Point3d
engineDimensions?: Point3d
dimensionSource?: 'engine' | 'kcl-parameters' | 'engine-volume-check'
dimensionReferences?: string[]
volume?: number
surfaceArea?: number
holes?: HoleFeature[]
}
export type ManufacturingLengthParameter = {
name: string
valueMm: number
}
export type ExplodeOffset = {
id: string
offset: Point3d
@ -60,11 +68,32 @@ export type EngineeringSheetInput = {
}
const EPSILON = 1e-6
const LENGTH_UNIT_TO_MM: Record<string, number> = {
mm: 1,
cm: 10,
m: 1000,
in: 25.4,
ft: 304.8,
yd: 914.4,
}
function roundDimension(value: number) {
return Math.round(value * 100) / 100
}
export function parseManufacturingLengthInMillimeters(value: string) {
const match = value
.trim()
.match(/^(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\s*(mm|cm|m|in|ft|yd)\b/i)
if (!match) return undefined
const numericValue = Number(match[1])
const multiplier = LENGTH_UNIT_TO_MM[match[2].toLowerCase()]
if (!Number.isFinite(numericValue) || multiplier === undefined) {
return undefined
}
return Math.abs(numericValue * multiplier)
}
function maximumDimension(body: BodyMeasurement) {
return Math.max(
Math.abs(body.dimensions.x),
@ -77,6 +106,249 @@ function compactPartName(name: string) {
return name.trim().replaceAll(/[\s_-]+/g, '').toLowerCase()
}
type DimensionRole =
| 'length'
| 'width'
| 'height'
| 'thickness'
| 'diameter'
| 'radius'
type ClassifiedLengthParameter = ManufacturingLengthParameter & {
baseName: string
role: DimensionRole
}
const DIMENSION_ROLE_PATTERNS: Array<{
role: DimensionRole
patterns: RegExp[]
}> = [
{
role: 'thickness',
patterns: [/(?:thickness|thick)$/i, /(?:厚度|板厚|厚)$/],
},
{
role: 'diameter',
patterns: [/(?:diameter|dia)$/i, /(?:直径|外径)$/],
},
{
role: 'radius',
patterns: [/(?:radius|rad)$/i, /(?:半径)$/],
},
{
role: 'length',
patterns: [/(?:length|long|len)$/i, /(?:长度|长)$/],
},
{
role: 'width',
patterns: [/(?:width|wide|depth)$/i, /(?:宽度|深度|宽)$/],
},
{
role: 'height',
patterns: [/(?:height|high)$/i, /(?:高度|高)$/],
},
]
function classifyLengthParameter(
parameter: ManufacturingLengthParameter
): ClassifiedLengthParameter | undefined {
const compactName = compactPartName(parameter.name)
for (const { role, patterns } of DIMENSION_ROLE_PATTERNS) {
const pattern = patterns.find((candidate) => candidate.test(compactName))
if (!pattern) continue
return {
...parameter,
baseName: compactName.replace(pattern, ''),
role,
}
}
return undefined
}
function partParameterAliases(bodyName: string) {
const normalized = compactPartName(normalizePartName(bodyName))
const aliases = new Set([normalized])
if (/(?:tabletop|desktop|worktop|countertop|tablebody|台面|桌面)/.test(normalized)) {
;[
'tabletop',
'table',
'desktop',
'desk',
'worktop',
'countertop',
'top',
'台面',
'桌面',
'桌',
].forEach((alias) => aliases.add(alias))
}
if (/(?:leg|foot|支腿|桌腿)/.test(normalized)) {
;['leg', 'foot', 'tableleg', 'deskleg', '支腿', '桌腿'].forEach((alias) =>
aliases.add(alias)
)
}
if (/(?:stud|bolt|螺柱|螺栓)/.test(normalized)) {
;['stud', 'connectorstud', 'bolt', '螺柱', '螺栓'].forEach((alias) =>
aliases.add(alias)
)
}
if (/(?:panel|board|板)/.test(normalized)) {
;['panel', 'board', '板'].forEach((alias) => aliases.add(alias))
}
return [...aliases].filter(Boolean)
}
function parameterAffinity(baseName: string, aliases: string[]) {
if (!baseName) return 0
let score = 0
for (const alias of aliases) {
if (baseName === alias) score = Math.max(score, 8)
else if (baseName.startsWith(alias) || alias.startsWith(baseName)) {
score = Math.max(score, Math.min(baseName.length, alias.length) >= 3 ? 6 : 2)
} else if (baseName.includes(alias) || alias.includes(baseName)) {
score = Math.max(score, Math.min(baseName.length, alias.length) >= 3 ? 4 : 1)
}
}
return score
}
function bestParameterForRole(
parameters: ClassifiedLengthParameter[],
role: DimensionRole,
aliases: string[]
) {
const candidates = parameters
.filter((parameter) => parameter.role === role)
.map((parameter) => ({
parameter,
score: parameterAffinity(parameter.baseName, aliases),
}))
.filter(({ score }) => score >= 4)
.sort(
(left, right) =>
right.score - left.score ||
left.parameter.name.localeCompare(right.parameter.name)
)
if (candidates.length === 0) return undefined
if (
candidates.length > 1 &&
candidates[0].score === candidates[1].score &&
candidates[0].parameter.valueMm !== candidates[1].parameter.valueMm
) {
return undefined
}
return candidates[0].parameter
}
function mapSortedDimensions(
engineDimensions: Point3d,
desiredAscending: [number, number, number]
) {
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
(left, right) =>
Math.abs(engineDimensions[left]) - Math.abs(engineDimensions[right])
)
const next = { ...engineDimensions }
axes.forEach((axis, index) => {
next[axis] = desiredAscending[index]
})
return next
}
function inferParametricDimensions(
body: BodyMeasurement,
parameters: ClassifiedLengthParameter[]
) {
const aliases = partParameterAliases(body.name)
const length = bestParameterForRole(parameters, 'length', aliases)
const width = bestParameterForRole(parameters, 'width', aliases)
const height = bestParameterForRole(parameters, 'height', aliases)
const thickness = bestParameterForRole(parameters, 'thickness', aliases)
const diameter = bestParameterForRole(parameters, 'diameter', aliases)
const radius = bestParameterForRole(parameters, 'radius', aliases)
const resolvedDiameter =
diameter ??
(radius
? {
...radius,
name: `${radius.name}×2`,
valueMm: radius.valueMm * 2,
}
: undefined)
const values = (['x', 'y', 'z'] as const)
.map((axis) => Math.abs(body.dimensions[axis]))
.sort((left, right) => left - right)
const normalizedName = compactPartName(normalizePartName(body.name))
const looksLikePanel =
/(panel|board|tabletop|desktop|worktop|countertop|top|seat|shelf|板)/.test(
normalizedName
) || (values[0] / Math.max(values[1], EPSILON) < 0.35 &&
values[1] / Math.max(values[2], EPSILON) > 0.25)
const looksLikeRod =
/(leg|foot|stud|dowel|pin|bolt|支腿|桌腿|螺柱|螺栓)/.test(normalizedName) ||
values[2] / Math.max(values[1], EPSILON) > 2
if (looksLikePanel && length && width && thickness) {
const desired = [
Math.abs(thickness.valueMm),
Math.min(Math.abs(length.valueMm), Math.abs(width.valueMm)),
Math.max(Math.abs(length.valueMm), Math.abs(width.valueMm)),
] as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [length.name, width.name, thickness.name],
}
}
if (looksLikeRod && height && resolvedDiameter) {
const desired = [
Math.abs(resolvedDiameter.valueMm),
Math.abs(resolvedDiameter.valueMm),
Math.abs(height.valueMm),
].sort((left, right) => left - right) as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [height.name, resolvedDiameter.name],
}
}
if (looksLikeRod && height && thickness) {
const desired = [
Math.abs(thickness.valueMm),
Math.abs(thickness.valueMm),
Math.abs(height.valueMm),
].sort((left, right) => left - right) as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [height.name, thickness.name],
}
}
if (length && width && height) {
const desired = [
Math.abs(length.valueMm),
Math.abs(width.valueMm),
Math.abs(height.valueMm),
].sort((left, right) => left - right) as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [length.name, width.name, height.name],
}
}
return undefined
}
function dimensionsDiffer(left: Point3d, right: Point3d, tolerance = 0.02) {
return (['x', 'y', 'z'] as const).some((axis) => {
const baseline = Math.max(Math.abs(right[axis]), EPSILON)
return Math.abs(left[axis] - right[axis]) / baseline > tolerance
})
}
function isKnownTemplateBody(body: BodyMeasurement) {
return /^unit(?:box|cube|cylinder[xyz]?|righttriangle[xyz]?)$/.test(
compactPartName(body.name)
@ -545,8 +817,21 @@ function snapCorrectedDimension(value: number) {
* hollow or filleted part.
*/
export function reconcileManufacturingDimensions(
bodies: BodyMeasurement[]
bodies: BodyMeasurement[],
lengthParameters: ManufacturingLengthParameter[] = []
): BodyMeasurement[] {
const classifiedParameters = lengthParameters
.filter(
(parameter) =>
parameter.name.trim() &&
Number.isFinite(parameter.valueMm) &&
Math.abs(parameter.valueMm) > EPSILON
)
.map(classifyLengthParameter)
.filter(
(parameter): parameter is ClassifiedLengthParameter =>
parameter !== undefined
)
const groups = new Map<string, BodyMeasurement[]>()
for (const body of bodies) {
const key = correctionGroupKey(body)
@ -566,36 +851,66 @@ export function reconcileManufacturingDimensions(
}
}
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
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
}
}
return bodies.map((body) => {
const inferred = inferParametricDimensions(body, classifiedParameters)
if (inferred) {
const corrected = {
x: snapCorrectedDimension(inferred.dimensions.x),
y: snapCorrectedDimension(inferred.dimensions.y),
z: snapCorrectedDimension(inferred.dimensions.z),
}
return {
...body,
dimensions: corrected,
engineDimensions: dimensionsDiffer(corrected, body.dimensions)
? body.dimensions
: body.engineDimensions,
dimensionSource: 'kcl-parameters',
dimensionReferences: inferred.references,
}
}
const candidate = candidates.get(correctionGroupKey(body))
if (
consensusScale === undefined ||
!candidate ||
Math.abs(candidate.scale - consensusScale) > 0.055
) {
return body
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),
}
return {
...body,
dimensions: {
x: snapCorrectedDimension(corrected.x),
y: snapCorrectedDimension(corrected.y),
z: snapCorrectedDimension(corrected.z),
},
dimensions: normalized,
engineDimensions: dimensionsDiffer(normalized, body.dimensions)
? body.dimensions
: body.engineDimensions,
dimensionSource: 'engine-volume-check',
}
})
}