From ce1e101ffcc2191e14dfd93ae91f47f1b6826285 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 28 Jul 2026 13:42:56 +0800 Subject: [PATCH] fix: stabilize HiCAD manufacturing dimensions --- .../hicad-manufacturing-extension.test.ts | 88 +++++ .../HicadManufacturingPanel.tsx.template | 113 +++++- vendor/zoo-modeling-app/manufacturing.ts | 347 +++++++++++++++++- 3 files changed, 517 insertions(+), 31 deletions(-) diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts index b880e7aa7..50a7bbcc2 100644 --- a/tests/routes/hicad-manufacturing-extension.test.ts +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -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', diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template index 459beb830..1afeb231b 100644 --- a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template +++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template @@ -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[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( + values: T[], + batchSize: number, + worker: (value: T, index: number) => Promise +) { + const results: Array> = [] + 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 => { + 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 => { 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({

- 尺寸来自模型实体包围盒。材料、工艺、公差和连接方式必须由工程人员投产前复核。 + {parametricDimensionCount > 0 + ? `制造尺寸优先采用 KCL 已求值参数(${parametricDimensionCount} 个实体)` + : volumeCheckedDimensionCount > 0 + ? `制造尺寸已通过实体体积交叉校验(${volumeCheckedDimensionCount} 个实体)` + : '当前制造尺寸来自模型实体包围盒'} + 。材料、工艺、公差和连接方式必须由工程人员投产前复核。

diff --git a/vendor/zoo-modeling-app/manufacturing.ts b/vendor/zoo-modeling-app/manufacturing.ts index 149cd54c1..7f84756fd 100644 --- a/vendor/zoo-modeling-app/manufacturing.ts +++ b/vendor/zoo-modeling-app/manufacturing.ts @@ -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 = { + 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).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() 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', } }) }