fix: preserve HiCAD hole annotations

This commit is contained in:
134 2026-07-28 13:52:13 +08:00 committed by root
parent a2c9404a3d
commit 07aef767b9
3 changed files with 460 additions and 106 deletions

View File

@ -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', () => {

View File

@ -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',

View File

@ -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<BodyMeasurement>((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) =>
`<li>H${hole.index}:Ø${roundDimension(
hole.diameter
)} ${hole.type === 'through-hole' ? '通孔' : `深 ${roundDimension(
hole.depth ?? 0
)}`},距左 ${hole.fromLeft},距下 ${hole.fromBottom}</li>`
)} ${
hole.type === 'through-hole'
? '通孔'
: `盲孔,深 ${roundDimension(hole.depth ?? 0)} mm`
},距左 ${hole.fromLeft},距下 ${hole.fromBottom}</li>`
)
.join('')
: '<li>该零件未识别到圆孔特征</li>'