From 07aef767b9d9b9f4b14fd3b8ddf639a51d9ffc65 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 28 Jul 2026 13:52:13 +0800 Subject: [PATCH] fix: preserve HiCAD hole annotations --- .../hicad-manufacturing-extension.test.ts | 31 ++ .../HicadManufacturingPanel.tsx.template | 240 ++++++++------ vendor/zoo-modeling-app/manufacturing.ts | 295 +++++++++++++++++- 3 files changed, 460 insertions(+), 106 deletions(-) diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts index 50a7bbcc2..83df0833d 100644 --- a/tests/routes/hicad-manufacturing-extension.test.ts +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -395,6 +395,9 @@ test.describe('HiCAD manufacturing extension', () => { { name: 'legHeight', valueMm: 750 }, { name: 'studDiameter', valueMm: 5 }, { name: 'studHeight', valueMm: 10 }, + { name: 'holeDiameter', valueMm: 5 }, + { name: 'holeDepth', valueMm: 10 }, + { name: 'holeInset', valueMm: 50 }, { name: 'unrelatedCabinetWidth', valueMm: 900 }, ] @@ -416,6 +419,24 @@ test.describe('HiCAD manufacturing extension', () => { quantity: 1, dimensions: { x: 1200, y: 600, z: 25 }, }) + const tabletop = reconciled.find((body) => body.id === 'cold-tabletop') + expect(tabletop?.holes).toHaveLength(4) + expect( + tabletop?.holes?.every( + (hole) => + hole.type === 'blind-hole' && + hole.diameter === 5 && + hole.depth === 10 + ) + ).toBe(true) + expect( + tabletop?.holes + ?.map((hole) => [ + Math.abs(hole.center.x - (tabletop?.center.x ?? 0)), + Math.abs(hole.center.y - (tabletop?.center.y ?? 0)), + ]) + .every(([fromCenterX, fromCenterY]) => fromCenterX === 550 && fromCenterY === 250) + ).toBe(true) expect( reconciled.every( (body) => @@ -425,6 +446,16 @@ test.describe('HiCAD manufacturing extension', () => { body.dimensionReferences.length >= 2 ) ).toBe(true) + + const html = createEngineeringSheetHtml({ + projectName: '冷启动孔位桌', + generatedAt: '2026-07-28 12:00', + overallDimensions: { x: 1200, y: 600, z: 775 }, + bodies: reconciled, + bomRows: rows, + }) + expect(html).toContain('4 × Ø5') + expect(html).toContain('Ø5 盲孔,深 10 mm') }) test('keeps a square leg end view rectangular', () => { diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template index 1afeb231b..a3f4728e6 100644 --- a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template +++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx.template @@ -289,121 +289,157 @@ export function HicadManufacturingPanel({ ] } + const readEntityChildIds = async (entityId: string) => { + const response = await sendModelingCommand({ + type: 'entity_get_all_child_uuids', + entity_id: entityId, + }) + if ( + response.type !== 'entity_get_all_child_uuids' || + typeof response.data !== 'object' || + response.data === null || + !('entity_ids' in response.data) || + !Array.isArray(response.data.entity_ids) + ) { + return [] + } + return response.data.entity_ids.filter( + (id): id is string => typeof id === 'string' + ) + } + + const readCircularSamples = async (entityIds: string[]) => { + const typedEntities = await settleInBatches( + [...new Set(entityIds)], + 6, + async (entityId) => { + const response = await sendModelingCommand({ + type: 'get_entity_type', + entity_id: entityId, + }) + const entityType = + response.type === 'get_entity_type' && + typeof response.data === 'object' && + response.data !== null && + 'entity_type' in response.data && + typeof response.data.entity_type === 'string' + ? response.data.entity_type + : undefined + return { entityId, entityType } + } + ) + const curveCandidates = typedEntities.flatMap((result) => + result.status === 'fulfilled' && + (result.value.entityType === 'edge' || + result.value.entityType === 'curve') + ? [result.value] + : [] + ) + const edgeResults = await settleInBatches( + curveCandidates, + 4, + async ({ entityId, entityType }) => { + const typeResponse = await sendModelingCommand({ + type: 'curve_get_type', + curve_id: entityId, + }) + const curveType = + typeResponse.type === 'curve_get_type' && + typeof typeResponse.data === 'object' && + typeResponse.data !== null && + 'curve_type' in typeResponse.data + ? typeResponse.data.curve_type + : undefined + if (curveType !== 'arc' && curveType !== 'nurbs') return undefined + + const [pointsResult, lengthResult] = await Promise.allSettled([ + sendModelingCommand({ + type: 'curve_get_control_points', + curve_id: entityId, + }), + entityType === 'edge' + ? sendModelingCommand({ + type: 'edge_get_length', + edge_id: entityId, + }) + : Promise.resolve(undefined), + ]) + if ( + pointsResult.status !== 'fulfilled' || + pointsResult.value.type !== 'curve_get_control_points' || + typeof pointsResult.value.data !== 'object' || + pointsResult.value.data === null || + !('control_points' in pointsResult.value.data) || + !Array.isArray(pointsResult.value.data.control_points) + ) { + return undefined + } + const controlPoints = + pointsResult.value.data.control_points.flatMap((point) => + typeof point === 'object' && + point !== null && + 'x' in point && + 'y' in point && + 'z' in point && + typeof point.x === 'number' && + typeof point.y === 'number' && + typeof point.z === 'number' + ? [{ x: point.x, y: point.y, z: point.z }] + : [] + ) + let length: number | undefined + if ( + lengthResult.status === 'fulfilled' && + lengthResult.value && + lengthResult.value.type === 'edge_get_length' && + typeof lengthResult.value.data === 'object' && + lengthResult.value.data !== null && + 'length' in lengthResult.value.data && + typeof lengthResult.value.data.length === 'number' + ) { + length = lengthResult.value.data.length + } + return { controlPoints, length } satisfies CircularEdgeSample + } + ) + + return edgeResults.flatMap((result) => + result.status === 'fulfilled' && result.value ? [result.value] : [] + ) + } + const readCircularEdges = async (bodyId: string) => { try { - const childResponse = await sendModelingCommand({ - type: 'entity_get_all_child_uuids', + const childSamples = await readCircularSamples( + await readEntityChildIds(bodyId) + ) + if (childSamples.length > 0) return childSamples + + const sketchResponse = await sendModelingCommand({ + type: 'entity_get_sketch_paths', entity_id: bodyId, }) if ( - childResponse.type !== 'entity_get_all_child_uuids' || - typeof childResponse.data !== 'object' || - childResponse.data === null || - !('entity_ids' in childResponse.data) || - !Array.isArray(childResponse.data.entity_ids) + sketchResponse.type !== 'entity_get_sketch_paths' || + typeof sketchResponse.data !== 'object' || + sketchResponse.data === null || + !('entity_ids' in sketchResponse.data) || + !Array.isArray(sketchResponse.data.entity_ids) ) { return [] } - const childIds = childResponse.data.entity_ids.filter( + const sketchIds = sketchResponse.data.entity_ids.filter( (id): id is string => typeof id === 'string' ) - const typedChildren = await settleInBatches( - childIds, - 6, - async (entityId) => { - const response = await sendModelingCommand({ - type: 'get_entity_type', - entity_id: entityId, - }) - const entityType = - response.type === 'get_entity_type' && - typeof response.data === 'object' && - response.data !== null && - 'entity_type' in response.data && - typeof response.data.entity_type === 'string' - ? response.data.entity_type - : undefined - return { entityId, entityType } - } - ) - const curveCandidates = typedChildren.flatMap((result) => - result.status === 'fulfilled' && - (result.value.entityType === 'edge' || - result.value.entityType === 'curve') - ? [result.value] - : [] - ) - const edgeResults = await settleInBatches( - curveCandidates, + const sketchChildren = await settleInBatches( + sketchIds, 4, - async ({ entityId, entityType }) => { - const typeResponse = await sendModelingCommand({ - type: 'curve_get_type', - curve_id: entityId, - }) - const curveType = - typeResponse.type === 'curve_get_type' && - typeof typeResponse.data === 'object' && - typeResponse.data !== null && - 'curve_type' in typeResponse.data - ? typeResponse.data.curve_type - : undefined - if (curveType !== 'arc' && curveType !== 'nurbs') return undefined - - const [pointsResult, lengthResult] = await Promise.allSettled([ - sendModelingCommand({ - type: 'curve_get_control_points', - curve_id: entityId, - }), - entityType === 'edge' - ? sendModelingCommand({ - type: 'edge_get_length', - edge_id: entityId, - }) - : Promise.resolve(undefined), - ]) - if ( - pointsResult.status !== 'fulfilled' || - pointsResult.value.type !== 'curve_get_control_points' || - typeof pointsResult.value.data !== 'object' || - pointsResult.value.data === null || - !('control_points' in pointsResult.value.data) || - !Array.isArray(pointsResult.value.data.control_points) - ) { - return undefined - } - const controlPoints = - pointsResult.value.data.control_points.flatMap((point) => - typeof point === 'object' && - point !== null && - 'x' in point && - 'y' in point && - 'z' in point && - typeof point.x === 'number' && - typeof point.y === 'number' && - typeof point.z === 'number' - ? [{ x: point.x, y: point.y, z: point.z }] - : [] - ) - let length: number | undefined - if ( - lengthResult.status === 'fulfilled' && - lengthResult.value && - lengthResult.value.type === 'edge_get_length' && - typeof lengthResult.value.data === 'object' && - lengthResult.value.data !== null && - 'length' in lengthResult.value.data && - typeof lengthResult.value.data.length === 'number' - ) { - length = lengthResult.value.data.length - } - return { controlPoints, length } satisfies CircularEdgeSample - } + async (sketchId) => [sketchId, ...(await readEntityChildIds(sketchId))] ) - - return edgeResults.flatMap((result) => - result.status === 'fulfilled' && result.value ? [result.value] : [] + const sketchEntityIds = sketchChildren.flatMap((result) => + result.status === 'fulfilled' ? result.value : [] ) + return readCircularSamples(sketchEntityIds) } catch (error) { console.warn( '[HiCAD manufacturing] Unable to inspect circular body edges', diff --git a/vendor/zoo-modeling-app/manufacturing.ts b/vendor/zoo-modeling-app/manufacturing.ts index 7f84756fd..9af4259e6 100644 --- a/vendor/zoo-modeling-app/manufacturing.ts +++ b/vendor/zoo-modeling-app/manufacturing.ts @@ -810,6 +810,290 @@ function snapCorrectedDimension(value: number) { : roundDimension(absolute) } +type HoleParameterRole = + | 'diameter' + | 'radius' + | 'depth' + | 'inset' + | 'insetX' + | 'insetY' + | 'offsetX' + | 'offsetY' + | 'spacingX' + | 'spacingY' + +type ClassifiedHoleParameter = ManufacturingLengthParameter & { + role: HoleParameterRole + scope: string +} + +const HOLE_PARAMETER_PATTERNS: Array<{ + role: HoleParameterRole + pattern: RegExp +}> = [ + { + role: 'diameter', + pattern: + /(?:hole.*(?:diameter|dia)|(?:diameter|dia).*hole|孔直径|孔径)/i, + }, + { + role: 'radius', + pattern: /(?:hole.*radius|radius.*hole|孔半径)/i, + }, + { + role: 'depth', + pattern: /(?:hole.*depth|depth.*hole|孔深)/i, + }, + { + role: 'insetX', + pattern: + /(?:hole.*(?:insetx|marginx|edgedistancex)|(?:insetx|marginx|edgedistancex).*hole|孔横向边距)/i, + }, + { + role: 'insetY', + pattern: + /(?:hole.*(?:insety|marginy|edgedistancey)|(?:insety|marginy|edgedistancey).*hole|孔纵向边距)/i, + }, + { + role: 'spacingX', + pattern: /(?:hole.*spacingx|spacingx.*hole|孔横向间距)/i, + }, + { + role: 'spacingY', + pattern: /(?:hole.*spacingy|spacingy.*hole|孔纵向间距)/i, + }, + { + role: 'offsetX', + pattern: + /(?:hole.*(?:offsetx|centerx|positionx|posx)|(?:offsetx|centerx|positionx|posx).*hole|孔横向中心距)/i, + }, + { + role: 'offsetY', + pattern: + /(?:hole.*(?:offsety|centery|positiony|posy)|(?:offsety|centery|positiony|posy).*hole|孔纵向中心距)/i, + }, + { + role: 'inset', + pattern: + /(?:hole.*(?:inset|margin|edgedistance)|(?:inset|margin|edgedistance).*hole|孔边距|孔距边)/i, + }, +] + +function classifyHoleParameter( + parameter: ManufacturingLengthParameter +): ClassifiedHoleParameter | undefined { + const compactName = compactPartName(parameter.name) + const matched = HOLE_PARAMETER_PATTERNS.find(({ pattern }) => + pattern.test(compactName) + ) + if (!matched) return undefined + const scope = compactName + .replace(matched.pattern, '') + .replaceAll( + /(?:mounting|hole|diameter|dia|radius|depth|inset|margin|edge|distance|offset|center|position|pos|spacing|孔位|孔径|孔深|孔边距|孔距边|孔)/gi, + '' + ) + return { + ...parameter, + role: matched.role, + scope, + } +} + +function isPanelBody(body: BodyMeasurement) { + 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)) + return ( + /(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) + ) +} + +function panelFaceArea(body: BodyMeasurement) { + const values = (['x', 'y', 'z'] as const) + .map((axis) => Math.abs(body.dimensions[axis])) + .sort((left, right) => left - right) + return values[1] * values[2] +} + +function bestHoleParameter( + parameters: ClassifiedHoleParameter[], + role: HoleParameterRole, + body: BodyMeasurement, + allowGeneric: boolean +) { + const aliases = partParameterAliases(body.name) + const candidates = parameters + .filter((parameter) => parameter.role === role) + .map((parameter) => ({ + parameter, + score: parameter.scope + ? parameterAffinity(parameter.scope, aliases) + : allowGeneric + ? 1 + : 0, + })) + .filter(({ score }) => score > 0) + .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 +} + +export function reconcileManufacturingHoles( + bodies: BodyMeasurement[], + lengthParameters: ManufacturingLengthParameter[] +) { + const holeParameters = lengthParameters + .filter( + (parameter) => + parameter.name.trim() && + Number.isFinite(parameter.valueMm) && + Math.abs(parameter.valueMm) > EPSILON + ) + .map(classifyHoleParameter) + .filter( + (parameter): parameter is ClassifiedHoleParameter => + parameter !== undefined + ) + if (holeParameters.length === 0) return bodies + + const panelBodies = bodies.filter(isPanelBody) + const genericTarget = [...panelBodies].sort( + (left, right) => panelFaceArea(right) - panelFaceArea(left) + )[0] + + return bodies.map((body) => { + if (!isPanelBody(body)) return body + const allowGeneric = body.id === genericTarget?.id + const diameterParameter = bestHoleParameter( + holeParameters, + 'diameter', + body, + allowGeneric + ) + const radiusParameter = bestHoleParameter( + holeParameters, + 'radius', + body, + allowGeneric + ) + const depthParameter = bestHoleParameter( + holeParameters, + 'depth', + body, + allowGeneric + ) + const diameter = + diameterParameter?.valueMm ?? + (radiusParameter ? radiusParameter.valueMm * 2 : undefined) + const axes = panelAxes(body.dimensions) + const thickness = Math.abs(body.dimensions[axes.depth]) + const depth = Math.min(depthParameter?.valueMm ?? thickness, thickness) + const type: HoleFeature['type'] = + depth < thickness - Math.max(0.1, thickness * 0.02) + ? 'blind-hole' + : 'through-hole' + + if ((body.holes ?? []).length > 0) { + return { + ...body, + holes: (body.holes ?? []).map((hole) => ({ + ...hole, + diameter: roundDimension(diameter ?? hole.diameter), + depth: roundDimension(depthParameter ? depth : hole.depth ?? thickness), + type: depthParameter ? type : hole.type, + })), + } + } + if (diameter === undefined) return body + + const genericInset = bestHoleParameter( + holeParameters, + 'inset', + body, + allowGeneric + )?.valueMm + const insetX = + bestHoleParameter(holeParameters, 'insetX', body, allowGeneric)?.valueMm ?? + genericInset + const insetY = + bestHoleParameter(holeParameters, 'insetY', body, allowGeneric)?.valueMm ?? + genericInset + const spacingX = bestHoleParameter( + holeParameters, + 'spacingX', + body, + allowGeneric + )?.valueMm + const spacingY = bestHoleParameter( + holeParameters, + 'spacingY', + body, + allowGeneric + )?.valueMm + const offsetX = + bestHoleParameter(holeParameters, 'offsetX', body, allowGeneric)?.valueMm ?? + (spacingX !== undefined + ? spacingX / 2 + : insetX === undefined + ? undefined + : Math.abs(body.dimensions[axes.horizontal]) / 2 - insetX) + const offsetY = + bestHoleParameter(holeParameters, 'offsetY', body, allowGeneric)?.valueMm ?? + (spacingY !== undefined + ? spacingY / 2 + : insetY === undefined + ? undefined + : Math.abs(body.dimensions[axes.vertical]) / 2 - insetY) + if ( + offsetX === undefined || + offsetY === undefined || + offsetX < 0 || + offsetY < 0 || + offsetX + diameter / 2 > + Math.abs(body.dimensions[axes.horizontal]) / 2 + EPSILON || + offsetY + diameter / 2 > + Math.abs(body.dimensions[axes.vertical]) / 2 + EPSILON + ) { + return body + } + + const holes = [-1, 1].flatMap((horizontalDirection) => + [-1, 1].map((verticalDirection): HoleFeature => { + const center = { ...body.center } + center[axes.horizontal] = + body.center[axes.horizontal] + horizontalDirection * offsetX + center[axes.vertical] = + body.center[axes.vertical] + verticalDirection * offsetY + return { + type, + center, + diameter: roundDimension(diameter), + axis: axes.depth, + depth: roundDimension(depth), + } + }) + ) + return { ...body, holes } + }) +} + /** * Zoo occasionally reports a transformed scene bounding box while volume stays * in the source part scale. Only correct dimensions when multiple distinct part @@ -864,7 +1148,7 @@ export function reconcileManufacturingDimensions( } } - return bodies.map((body) => { + const dimensionBodies = bodies.map((body) => { const inferred = inferParametricDimensions(body, classifiedParameters) if (inferred) { const corrected = { @@ -913,6 +1197,7 @@ export function reconcileManufacturingDimensions( dimensionSource: 'engine-volume-check', } }) + return reconcileManufacturingHoles(dimensionBodies, lengthParameters) } export function localizePartName(name: string) { @@ -1579,9 +1864,11 @@ function machiningDetailSvg(row: BomRow) { (hole) => `
  • H${hole.index}:Ø${roundDimension( hole.diameter - )} ${hole.type === 'through-hole' ? '通孔' : `深 ${roundDimension( - hole.depth ?? 0 - )}`},距左 ${hole.fromLeft},距下 ${hole.fromBottom}
  • ` + )} ${ + hole.type === 'through-hole' + ? '通孔' + : `盲孔,深 ${roundDimension(hole.depth ?? 0)} mm` + },距左 ${hole.fromLeft},距下 ${hole.fromBottom}` ) .join('') : '
  • 该零件未识别到圆孔特征
  • '