feat: add HiCAD hole dimension production drawings
This commit is contained in:
parent
e40c351f80
commit
d84f3dc7d8
@ -6,9 +6,11 @@ import {
|
|||||||
createBomCsv,
|
createBomCsv,
|
||||||
createEngineeringSheetHtml,
|
createEngineeringSheetHtml,
|
||||||
createSnapshotDataUrl,
|
createSnapshotDataUrl,
|
||||||
|
detectPanelHoles,
|
||||||
filterManufacturingBodies,
|
filterManufacturingBodies,
|
||||||
groupBodiesIntoBom,
|
groupBodiesIntoBom,
|
||||||
type BodyMeasurement,
|
type BodyMeasurement,
|
||||||
|
type CircularEdgeSample,
|
||||||
} from '../../vendor/zoo-modeling-app/manufacturing'
|
} from '../../vendor/zoo-modeling-app/manufacturing'
|
||||||
|
|
||||||
const bodies: BodyMeasurement[] = [
|
const bodies: BodyMeasurement[] = [
|
||||||
@ -38,6 +40,23 @@ const bodies: BodyMeasurement[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function circularEdge(
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
z: number,
|
||||||
|
radius: number
|
||||||
|
): CircularEdgeSample {
|
||||||
|
return {
|
||||||
|
controlPoints: [
|
||||||
|
{ x: centerX + radius, y: centerY, z },
|
||||||
|
{ x: centerX, y: centerY + radius, z },
|
||||||
|
{ x: centerX - radius, y: centerY, z },
|
||||||
|
{ x: centerX, y: centerY - radius, z },
|
||||||
|
],
|
||||||
|
length: Math.PI * radius * 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('HiCAD manufacturing extension', () => {
|
test.describe('HiCAD manufacturing extension', () => {
|
||||||
test('keeps the extension isolated behind one official registration point', () => {
|
test('keeps the extension isolated behind one official registration point', () => {
|
||||||
const projectRoot = process.cwd()
|
const projectRoot = process.cwd()
|
||||||
@ -162,6 +181,47 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
).toBe('data:image/png;base64,iVBORw0KGgo/8A==')
|
).toBe('data:image/png;base64,iVBORw0KGgo/8A==')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('detects through holes on a panel and deduplicates both face edges', () => {
|
||||||
|
const panel: BodyMeasurement = {
|
||||||
|
id: 'side-panel',
|
||||||
|
name: 'sidePanel',
|
||||||
|
center: { x: 0, y: 0, z: 9 },
|
||||||
|
dimensions: { x: 400, y: 300, z: 18 },
|
||||||
|
}
|
||||||
|
const holeCenters = [
|
||||||
|
[-160, -100],
|
||||||
|
[160, -100],
|
||||||
|
[-160, 100],
|
||||||
|
[160, 100],
|
||||||
|
]
|
||||||
|
const edgeSamples = holeCenters.flatMap(([x, y]) => [
|
||||||
|
circularEdge(x, y, 0, 4),
|
||||||
|
circularEdge(x, y, 18, 4),
|
||||||
|
])
|
||||||
|
const holes = detectPanelHoles(panel, edgeSamples)
|
||||||
|
|
||||||
|
expect(holes).toHaveLength(4)
|
||||||
|
expect(holes.every((hole) => hole.axis === 'z')).toBe(true)
|
||||||
|
expect(holes.every((hole) => hole.diameter === 8)).toBe(true)
|
||||||
|
expect(holes.every((hole) => hole.depth === 18)).toBe(true)
|
||||||
|
|
||||||
|
const rows = groupBodiesIntoBom([{ ...panel, holes }])
|
||||||
|
const html = createEngineeringSheetHtml({
|
||||||
|
projectName: '五金连接侧板',
|
||||||
|
generatedAt: '2026-07-27 22:00',
|
||||||
|
overallDimensions: panel.dimensions,
|
||||||
|
bodies: [{ ...panel, holes }],
|
||||||
|
bomRows: rows,
|
||||||
|
})
|
||||||
|
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
|
||||||
|
expect(html).toContain('4 × Ø8')
|
||||||
|
expect(html).toContain('H1:Ø8 通孔')
|
||||||
|
expect(html).toContain('距左 40')
|
||||||
|
expect(html).toContain('距下 50')
|
||||||
|
expect(html).toContain('横向中心孔距 320 mm')
|
||||||
|
expect(html).toContain('纵向中心孔距 200 mm')
|
||||||
|
})
|
||||||
|
|
||||||
test('builds a printable dimensioned engineering sheet safely', () => {
|
test('builds a printable dimensioned engineering sheet safely', () => {
|
||||||
const rows = groupBodiesIntoBom(bodies)
|
const rows = groupBodiesIntoBom(bodies)
|
||||||
const html = createEngineeringSheetHtml({
|
const html = createEngineeringSheetHtml({
|
||||||
@ -185,6 +245,8 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
expect(html).toContain('data-part-name="台面板"')
|
expect(html).toContain('data-part-name="台面板"')
|
||||||
expect(html).toContain('120 mm')
|
expect(html).toContain('120 mm')
|
||||||
expect(html).toContain('配件明细表(BOM)')
|
expect(html).toContain('配件明细表(BOM)')
|
||||||
|
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
|
||||||
|
expect(html.match(/class="part-detail"/g)).toHaveLength(2)
|
||||||
expect(html).toContain('由壹点AI')
|
expect(html).toContain('由壹点AI')
|
||||||
expect(html).toContain('/api/favicon?surface=hicad')
|
expect(html).toContain('/api/favicon?surface=hicad')
|
||||||
expect(html).toContain('@page { size: A3 landscape;')
|
expect(html).toContain('@page { size: A3 landscape;')
|
||||||
@ -215,6 +277,7 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
clientWidth: document.documentElement.clientWidth,
|
clientWidth: document.documentElement.clientWidth,
|
||||||
tableCount: document.querySelectorAll('table').length,
|
tableCount: document.querySelectorAll('table').length,
|
||||||
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
|
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
|
||||||
|
partDetailCount: document.querySelectorAll('.part-detail').length,
|
||||||
projectionPartCount:
|
projectionPartCount:
|
||||||
document.querySelectorAll('.projection-part').length,
|
document.querySelectorAll('.projection-part').length,
|
||||||
brandCount: document.querySelectorAll('.brand-name').length,
|
brandCount: document.querySelectorAll('.brand-name').length,
|
||||||
@ -223,6 +286,7 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
||||||
expect(layout.tableCount).toBe(1)
|
expect(layout.tableCount).toBe(1)
|
||||||
expect(layout.dimensionViewCount).toBe(3)
|
expect(layout.dimensionViewCount).toBe(3)
|
||||||
|
expect(layout.partDetailCount).toBe(rows.length)
|
||||||
expect(layout.projectionPartCount).toBe(bodies.length * 3)
|
expect(layout.projectionPartCount).toBe(bodies.length * 3)
|
||||||
expect(layout.brandCount).toBe(1)
|
expect(layout.brandCount).toBe(1)
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
|
|||||||
@ -11,11 +11,13 @@ import {
|
|||||||
createSnapshotDataUrl,
|
createSnapshotDataUrl,
|
||||||
createBomCsv,
|
createBomCsv,
|
||||||
createEngineeringSheetHtml,
|
createEngineeringSheetHtml,
|
||||||
|
detectPanelHoles,
|
||||||
filterManufacturingBodies,
|
filterManufacturingBodies,
|
||||||
getOverallBounds,
|
getOverallBounds,
|
||||||
groupBodiesIntoBom,
|
groupBodiesIntoBom,
|
||||||
type BodyMeasurement,
|
type BodyMeasurement,
|
||||||
type BomRow,
|
type BomRow,
|
||||||
|
type CircularEdgeSample,
|
||||||
type ExplodeOffset,
|
type ExplodeOffset,
|
||||||
} from './manufacturing'
|
} from './manufacturing'
|
||||||
|
|
||||||
@ -241,6 +243,126 @@ export function HicadManufacturingPanel({
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readCircularEdges = async (bodyId: string) => {
|
||||||
|
try {
|
||||||
|
const childResponse = await sendModelingCommand({
|
||||||
|
type: 'entity_get_all_child_uuids',
|
||||||
|
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)
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const childIds = childResponse.data.entity_ids.filter(
|
||||||
|
(id): id is string => typeof id === 'string'
|
||||||
|
)
|
||||||
|
const typedChildren = await Promise.allSettled(
|
||||||
|
childIds.map(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 Promise.allSettled(
|
||||||
|
curveCandidates.map(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] : []
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'[HiCAD manufacturing] Unable to inspect circular body edges',
|
||||||
|
{ bodyId, error }
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const readBoundingBox = async (entityIds: string[]) => {
|
const readBoundingBox = async (entityIds: string[]) => {
|
||||||
const response = await sendModelingCommand({
|
const response = await sendModelingCommand({
|
||||||
type: 'bounding_box',
|
type: 'bounding_box',
|
||||||
@ -355,7 +477,7 @@ export function HicadManufacturingPanel({
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
const measurement: BodyMeasurement = {
|
||||||
id: bodyId,
|
id: bodyId,
|
||||||
name: resolveBodyName(bodyId, variables, index),
|
name: resolveBodyName(bodyId, variables, index),
|
||||||
center: boundingBox.center,
|
center: boundingBox.center,
|
||||||
@ -369,6 +491,9 @@ export function HicadManufacturingPanel({
|
|||||||
? numberValue(areaResponse.value.data, 'surface_area')
|
? numberValue(areaResponse.value.data, 'surface_area')
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
|
const circularEdges = await readCircularEdges(bodyId)
|
||||||
|
measurement.holes = detectPanelHoles(measurement, circularEdges)
|
||||||
|
return measurement
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const measured = measurementResults.flatMap((result) =>
|
const measured = measurementResults.flatMap((result) =>
|
||||||
|
|||||||
436
vendor/zoo-modeling-app/manufacturing.ts
vendored
436
vendor/zoo-modeling-app/manufacturing.ts
vendored
@ -4,6 +4,19 @@ export type Point3d = {
|
|||||||
z: number
|
z: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HoleFeature = {
|
||||||
|
type: 'through-hole' | 'blind-hole'
|
||||||
|
center: Point3d
|
||||||
|
diameter: number
|
||||||
|
axis: keyof Point3d
|
||||||
|
depth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CircularEdgeSample = {
|
||||||
|
controlPoints: Point3d[]
|
||||||
|
length?: number
|
||||||
|
}
|
||||||
|
|
||||||
export type BodyMeasurement = {
|
export type BodyMeasurement = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -11,6 +24,7 @@ export type BodyMeasurement = {
|
|||||||
dimensions: Point3d
|
dimensions: Point3d
|
||||||
volume?: number
|
volume?: number
|
||||||
surfaceArea?: number
|
surfaceArea?: number
|
||||||
|
holes?: HoleFeature[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExplodeOffset = {
|
export type ExplodeOffset = {
|
||||||
@ -29,6 +43,8 @@ export type BomRow = {
|
|||||||
material: string
|
material: string
|
||||||
process: string
|
process: string
|
||||||
bodyIds: string[]
|
bodyIds: string[]
|
||||||
|
center: Point3d
|
||||||
|
holes: HoleFeature[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EngineeringSheetInput = {
|
export type EngineeringSheetInput = {
|
||||||
@ -108,6 +124,198 @@ function vectorLength(point: Point3d) {
|
|||||||
return Math.hypot(point.x, point.y, point.z)
|
return Math.hypot(point.x, point.y, point.z)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function panelAxes(dimensions: Point3d) {
|
||||||
|
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
|
||||||
|
(left, right) =>
|
||||||
|
Math.abs(dimensions[left]) - Math.abs(dimensions[right])
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
depth: axes[0],
|
||||||
|
vertical: axes[1],
|
||||||
|
horizontal: axes[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(left: Point3d, right: Point3d): Point3d {
|
||||||
|
return {
|
||||||
|
x: left.y * right.z - left.z * right.y,
|
||||||
|
y: left.z * right.x - left.x * right.z,
|
||||||
|
z: left.x * right.y - left.y * right.x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtract(left: Point3d, right: Point3d): Point3d {
|
||||||
|
return {
|
||||||
|
x: left.x - right.x,
|
||||||
|
y: left.y - right.y,
|
||||||
|
z: left.z - right.z,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectedCircumcircle(
|
||||||
|
points: Point3d[],
|
||||||
|
horizontal: keyof Point3d,
|
||||||
|
vertical: keyof Point3d
|
||||||
|
) {
|
||||||
|
let best:
|
||||||
|
| {
|
||||||
|
first: Point3d
|
||||||
|
second: Point3d
|
||||||
|
third: Point3d
|
||||||
|
area: number
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
const candidates = points.slice(0, 16)
|
||||||
|
|
||||||
|
for (let firstIndex = 0; firstIndex < candidates.length - 2; firstIndex += 1) {
|
||||||
|
for (
|
||||||
|
let secondIndex = firstIndex + 1;
|
||||||
|
secondIndex < candidates.length - 1;
|
||||||
|
secondIndex += 1
|
||||||
|
) {
|
||||||
|
for (
|
||||||
|
let thirdIndex = secondIndex + 1;
|
||||||
|
thirdIndex < candidates.length;
|
||||||
|
thirdIndex += 1
|
||||||
|
) {
|
||||||
|
const first = candidates[firstIndex]
|
||||||
|
const second = candidates[secondIndex]
|
||||||
|
const third = candidates[thirdIndex]
|
||||||
|
const area = Math.abs(
|
||||||
|
(second[horizontal] - first[horizontal]) *
|
||||||
|
(third[vertical] - first[vertical]) -
|
||||||
|
(third[horizontal] - first[horizontal]) *
|
||||||
|
(second[vertical] - first[vertical])
|
||||||
|
)
|
||||||
|
if (!best || area > best.area) {
|
||||||
|
best = { first, second, third, area }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!best || best.area < EPSILON) return undefined
|
||||||
|
|
||||||
|
const firstHorizontal = best.first[horizontal]
|
||||||
|
const firstVertical = best.first[vertical]
|
||||||
|
const secondHorizontal = best.second[horizontal]
|
||||||
|
const secondVertical = best.second[vertical]
|
||||||
|
const thirdHorizontal = best.third[horizontal]
|
||||||
|
const thirdVertical = best.third[vertical]
|
||||||
|
const denominator =
|
||||||
|
2 *
|
||||||
|
(firstHorizontal * (secondVertical - thirdVertical) +
|
||||||
|
secondHorizontal * (thirdVertical - firstVertical) +
|
||||||
|
thirdHorizontal * (firstVertical - secondVertical))
|
||||||
|
if (Math.abs(denominator) < EPSILON) return undefined
|
||||||
|
|
||||||
|
const firstSquared =
|
||||||
|
firstHorizontal * firstHorizontal + firstVertical * firstVertical
|
||||||
|
const secondSquared =
|
||||||
|
secondHorizontal * secondHorizontal + secondVertical * secondVertical
|
||||||
|
const thirdSquared =
|
||||||
|
thirdHorizontal * thirdHorizontal + thirdVertical * thirdVertical
|
||||||
|
const centerHorizontal =
|
||||||
|
(firstSquared * (secondVertical - thirdVertical) +
|
||||||
|
secondSquared * (thirdVertical - firstVertical) +
|
||||||
|
thirdSquared * (firstVertical - secondVertical)) /
|
||||||
|
denominator
|
||||||
|
const centerVertical =
|
||||||
|
(firstSquared * (thirdHorizontal - secondHorizontal) +
|
||||||
|
secondSquared * (firstHorizontal - thirdHorizontal) +
|
||||||
|
thirdSquared * (secondHorizontal - firstHorizontal)) /
|
||||||
|
denominator
|
||||||
|
const radius = Math.hypot(
|
||||||
|
centerHorizontal - firstHorizontal,
|
||||||
|
centerVertical - firstVertical
|
||||||
|
)
|
||||||
|
const normal = normalize(
|
||||||
|
cross(
|
||||||
|
subtract(best.second, best.first),
|
||||||
|
subtract(best.third, best.first)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
centerHorizontal,
|
||||||
|
centerVertical,
|
||||||
|
radius,
|
||||||
|
normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectPanelHoles(
|
||||||
|
body: BodyMeasurement,
|
||||||
|
circularEdges: CircularEdgeSample[]
|
||||||
|
) {
|
||||||
|
const axes = panelAxes(body.dimensions)
|
||||||
|
const thickness = Math.abs(body.dimensions[axes.depth])
|
||||||
|
const faceSize = Math.min(
|
||||||
|
Math.abs(body.dimensions[axes.horizontal]),
|
||||||
|
Math.abs(body.dimensions[axes.vertical])
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
thickness <= EPSILON ||
|
||||||
|
faceSize <= EPSILON ||
|
||||||
|
thickness / faceSize > 0.35
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = circularEdges.flatMap((edge): HoleFeature[] => {
|
||||||
|
if (edge.controlPoints.length < 3) return []
|
||||||
|
const circle = projectedCircumcircle(
|
||||||
|
edge.controlPoints,
|
||||||
|
axes.horizontal,
|
||||||
|
axes.vertical
|
||||||
|
)
|
||||||
|
if (!circle || circle.radius < 0.5) return []
|
||||||
|
if (Math.abs(circle.normal[axes.depth]) < 0.82) return []
|
||||||
|
|
||||||
|
const diameter = circle.radius * 2
|
||||||
|
if (diameter > faceSize * 0.45) return []
|
||||||
|
const circumference = Math.PI * diameter
|
||||||
|
if (
|
||||||
|
edge.length !== undefined &&
|
||||||
|
edge.length < circumference * 0.82
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = { ...body.center }
|
||||||
|
center[axes.horizontal] = circle.centerHorizontal
|
||||||
|
center[axes.vertical] = circle.centerVertical
|
||||||
|
center[axes.depth] = body.center[axes.depth]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'through-hole',
|
||||||
|
center,
|
||||||
|
diameter: roundDimension(diameter),
|
||||||
|
axis: axes.depth,
|
||||||
|
depth: roundDimension(thickness),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const deduplicated: HoleFeature[] = []
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const duplicate = deduplicated.some(
|
||||||
|
(existing) =>
|
||||||
|
existing.axis === candidate.axis &&
|
||||||
|
Math.abs(existing.diameter - candidate.diameter) < 0.2 &&
|
||||||
|
vectorLength(subtract(existing.center, candidate.center)) < 0.35
|
||||||
|
)
|
||||||
|
if (!duplicate) deduplicated.push(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduplicated.sort((left, right) => {
|
||||||
|
const horizontalDifference =
|
||||||
|
left.center[axes.horizontal] - right.center[axes.horizontal]
|
||||||
|
if (Math.abs(horizontalDifference) > EPSILON) return horizontalDifference
|
||||||
|
return left.center[axes.vertical] - right.center[axes.vertical]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function normalize(point: Point3d): Point3d {
|
function normalize(point: Point3d): Point3d {
|
||||||
const length = vectorLength(point)
|
const length = vectorLength(point)
|
||||||
if (length < EPSILON) {
|
if (length < EPSILON) {
|
||||||
@ -216,6 +424,10 @@ const CHINESE_PART_NAMES: Record<string, string> = {
|
|||||||
baseboard: '底板',
|
baseboard: '底板',
|
||||||
bottompanel: '底板',
|
bottompanel: '底板',
|
||||||
bracket: '支架',
|
bracket: '支架',
|
||||||
|
camconnector: '三合一偏心连接件',
|
||||||
|
camlock: '三合一偏心轮',
|
||||||
|
connectorbolt: '连接螺栓',
|
||||||
|
connectorplate: '连接片',
|
||||||
dividerpanel: '隔板',
|
dividerpanel: '隔板',
|
||||||
doorpanel: '门板',
|
doorpanel: '门板',
|
||||||
dowel: '木榫',
|
dowel: '木榫',
|
||||||
@ -224,10 +436,12 @@ const CHINESE_PART_NAMES: Record<string, string> = {
|
|||||||
foot: '支腿',
|
foot: '支腿',
|
||||||
handle: '拉手',
|
handle: '拉手',
|
||||||
hinge: '铰链',
|
hinge: '铰链',
|
||||||
|
metalbracket: '金属连接角码',
|
||||||
leftsidepanel: '左侧板',
|
leftsidepanel: '左侧板',
|
||||||
leg: '支腿',
|
leg: '支腿',
|
||||||
rightsidepanel: '右侧板',
|
rightsidepanel: '右侧板',
|
||||||
screw: '螺钉',
|
screw: '螺钉',
|
||||||
|
shelfpin: '层板托',
|
||||||
seat: '座板',
|
seat: '座板',
|
||||||
seatboard: '座板',
|
seatboard: '座板',
|
||||||
seatbody: '座板',
|
seatbody: '座板',
|
||||||
@ -240,6 +454,7 @@ const CHINESE_PART_NAMES: Record<string, string> = {
|
|||||||
topboard: '顶板',
|
topboard: '顶板',
|
||||||
topbottompanel: '顶底板',
|
topbottompanel: '顶底板',
|
||||||
toppanel: '顶板',
|
toppanel: '顶板',
|
||||||
|
washer: '垫圈',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function localizePartName(name: string) {
|
export function localizePartName(name: string) {
|
||||||
@ -254,6 +469,26 @@ function dimensionsKey(dimensions: Point3d) {
|
|||||||
.join('x')
|
.join('x')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function holePatternKey(body: BodyMeasurement) {
|
||||||
|
return (body.holes ?? [])
|
||||||
|
.map((hole) => {
|
||||||
|
const local = {
|
||||||
|
x: hole.center.x - body.center.x,
|
||||||
|
y: hole.center.y - body.center.y,
|
||||||
|
z: hole.center.z - body.center.z,
|
||||||
|
}
|
||||||
|
return `${hole.axis}:${roundDimension(hole.diameter).toFixed(2)}:${[
|
||||||
|
local.x,
|
||||||
|
local.y,
|
||||||
|
local.z,
|
||||||
|
]
|
||||||
|
.map((value) => roundDimension(value).toFixed(2))
|
||||||
|
.join(',')}`
|
||||||
|
})
|
||||||
|
.sort()
|
||||||
|
.join('|')
|
||||||
|
}
|
||||||
|
|
||||||
export function inferProcess(dimensions: Point3d) {
|
export function inferProcess(dimensions: Point3d) {
|
||||||
const values = [dimensions.x, dimensions.y, dimensions.z]
|
const values = [dimensions.x, dimensions.y, dimensions.z]
|
||||||
.map((value) => Math.abs(value))
|
.map((value) => Math.abs(value))
|
||||||
@ -278,7 +513,7 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
|
|||||||
const name = localizePartName(normalizedName)
|
const name = localizePartName(normalizedName)
|
||||||
const key = `${normalizedName.toLowerCase()}::${dimensionsKey(
|
const key = `${normalizedName.toLowerCase()}::${dimensionsKey(
|
||||||
body.dimensions
|
body.dimensions
|
||||||
)}`
|
)}::${holePatternKey(body)}`
|
||||||
const existing = groups.get(key)
|
const existing = groups.get(key)
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -302,6 +537,8 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
|
|||||||
material: '待确认',
|
material: '待确认',
|
||||||
process: inferProcess(body.dimensions),
|
process: inferProcess(body.dimensions),
|
||||||
bodyIds: [body.id],
|
bodyIds: [body.id],
|
||||||
|
center: body.center,
|
||||||
|
holes: body.holes ?? [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,6 +709,182 @@ function orthographicViewSvg(
|
|||||||
</figure>`
|
</figure>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueRounded(values: number[]) {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
values.map((value) => roundDimension(value).toFixed(2))
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map(Number)
|
||||||
|
.sort((left, right) => left - right)
|
||||||
|
}
|
||||||
|
|
||||||
|
function machiningDetailSvg(row: BomRow) {
|
||||||
|
const axes = panelAxes(row.dimensions)
|
||||||
|
const horizontal = Math.abs(row.dimensions[axes.horizontal])
|
||||||
|
const vertical = Math.abs(row.dimensions[axes.vertical])
|
||||||
|
const depth = Math.abs(row.dimensions[axes.depth])
|
||||||
|
const objectMaxWidth = 230
|
||||||
|
const objectMaxHeight = 145
|
||||||
|
const scale = Math.min(
|
||||||
|
objectMaxWidth / Math.max(horizontal, EPSILON),
|
||||||
|
objectMaxHeight / Math.max(vertical, EPSILON)
|
||||||
|
)
|
||||||
|
const objectWidth = Math.max(horizontal * scale, 1)
|
||||||
|
const objectHeight = Math.max(vertical * scale, 1)
|
||||||
|
const objectX = 75 + (objectMaxWidth - objectWidth) / 2
|
||||||
|
const objectY = 42 + (objectMaxHeight - objectHeight) / 2
|
||||||
|
const holes = row.holes.filter((hole) => hole.axis === axes.depth)
|
||||||
|
const holePositions = holes.map((hole, index) => {
|
||||||
|
const fromLeft =
|
||||||
|
hole.center[axes.horizontal] -
|
||||||
|
row.center[axes.horizontal] +
|
||||||
|
horizontal / 2
|
||||||
|
const fromBottom =
|
||||||
|
hole.center[axes.vertical] -
|
||||||
|
row.center[axes.vertical] +
|
||||||
|
vertical / 2
|
||||||
|
return {
|
||||||
|
...hole,
|
||||||
|
index: index + 1,
|
||||||
|
fromLeft: roundDimension(fromLeft),
|
||||||
|
fromBottom: roundDimension(fromBottom),
|
||||||
|
x: objectX + fromLeft * scale,
|
||||||
|
y: objectY + objectHeight - fromBottom * scale,
|
||||||
|
radius: Math.max((hole.diameter / 2) * scale, 2.5),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const horizontalHolePositions = uniqueRounded(
|
||||||
|
holePositions.map((hole) => hole.fromLeft)
|
||||||
|
)
|
||||||
|
const verticalHolePositions = uniqueRounded(
|
||||||
|
holePositions.map((hole) => hole.fromBottom)
|
||||||
|
)
|
||||||
|
const horizontalPitch =
|
||||||
|
horizontalHolePositions.length > 1
|
||||||
|
? roundDimension(
|
||||||
|
horizontalHolePositions.at(-1)! - horizontalHolePositions[0]
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const verticalPitch =
|
||||||
|
verticalHolePositions.length > 1
|
||||||
|
? roundDimension(
|
||||||
|
verticalHolePositions.at(-1)! - verticalHolePositions[0]
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const holeMarkup = holePositions
|
||||||
|
.map(
|
||||||
|
(hole) => `
|
||||||
|
<circle class="detail-hole" cx="${roundDimension(hole.x)}" cy="${roundDimension(
|
||||||
|
hole.y
|
||||||
|
)}" r="${roundDimension(hole.radius)}"/>
|
||||||
|
<line class="center-line" x1="${roundDimension(hole.x - hole.radius - 5)}"
|
||||||
|
y1="${roundDimension(hole.y)}" x2="${roundDimension(
|
||||||
|
hole.x + hole.radius + 5
|
||||||
|
)}" y2="${roundDimension(hole.y)}"/>
|
||||||
|
<line class="center-line" x1="${roundDimension(hole.x)}"
|
||||||
|
y1="${roundDimension(hole.y - hole.radius - 5)}" x2="${roundDimension(
|
||||||
|
hole.x
|
||||||
|
)}" y2="${roundDimension(hole.y + hole.radius + 5)}"/>
|
||||||
|
<text x="${roundDimension(hole.x + hole.radius + 4)}"
|
||||||
|
y="${roundDimension(hole.y - hole.radius - 3)}">H${hole.index}</text>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
const holeNotes =
|
||||||
|
holePositions.length > 0
|
||||||
|
? holePositions
|
||||||
|
.map(
|
||||||
|
(hole) =>
|
||||||
|
`<li>H${hole.index}:Ø${roundDimension(
|
||||||
|
hole.diameter
|
||||||
|
)} ${hole.type === 'through-hole' ? '通孔' : `深 ${roundDimension(
|
||||||
|
hole.depth ?? 0
|
||||||
|
)}`},距左 ${hole.fromLeft},距下 ${hole.fromBottom}</li>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
: '<li>该零件未识别到圆孔特征</li>'
|
||||||
|
const pitchNotes = [
|
||||||
|
horizontalPitch === undefined ? '' : `横向中心孔距 ${horizontalPitch} mm`,
|
||||||
|
verticalPitch === undefined ? '' : `纵向中心孔距 ${verticalPitch} mm`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(';')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="part-detail">
|
||||||
|
<div class="part-detail-heading">
|
||||||
|
<strong>${row.itemNumber}. ${escapeHtml(row.name)}</strong>
|
||||||
|
<span>数量 ${row.quantity} · 厚度 ${formatDimension(depth)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="part-detail-layout">
|
||||||
|
<svg viewBox="0 0 390 245" role="img" aria-label="${escapeHtml(
|
||||||
|
row.name
|
||||||
|
)} 加工尺寸图">
|
||||||
|
<defs>
|
||||||
|
<marker id="part-arrow-${row.itemNumber}" viewBox="0 0 10 10" refX="5" refY="5"
|
||||||
|
markerWidth="5" markerHeight="5" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#111827"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<rect x="${roundDimension(objectX)}" y="${roundDimension(objectY)}"
|
||||||
|
width="${roundDimension(objectWidth)}" height="${roundDimension(
|
||||||
|
objectHeight
|
||||||
|
)}" fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>
|
||||||
|
${holeMarkup}
|
||||||
|
<line x1="${roundDimension(objectX)}" y1="${roundDimension(
|
||||||
|
objectY + objectHeight + 22
|
||||||
|
)}" x2="${roundDimension(objectX + objectWidth)}"
|
||||||
|
y2="${roundDimension(objectY + objectHeight + 22)}" stroke="#111827"
|
||||||
|
marker-start="url(#part-arrow-${row.itemNumber})"
|
||||||
|
marker-end="url(#part-arrow-${row.itemNumber})"/>
|
||||||
|
<line x1="${roundDimension(objectX)}" y1="${roundDimension(
|
||||||
|
objectY + objectHeight
|
||||||
|
)}" x2="${roundDimension(objectX)}" y2="${roundDimension(
|
||||||
|
objectY + objectHeight + 30
|
||||||
|
)}" stroke="#6b7280"/>
|
||||||
|
<line x1="${roundDimension(objectX + objectWidth)}" y1="${roundDimension(
|
||||||
|
objectY + objectHeight
|
||||||
|
)}" x2="${roundDimension(objectX + objectWidth)}" y2="${roundDimension(
|
||||||
|
objectY + objectHeight + 30
|
||||||
|
)}" stroke="#6b7280"/>
|
||||||
|
<text x="${roundDimension(objectX + objectWidth / 2)}"
|
||||||
|
y="${roundDimension(objectY + objectHeight + 39)}"
|
||||||
|
text-anchor="middle">${formatDimension(horizontal)}</text>
|
||||||
|
<line x1="${roundDimension(objectX - 22)}" y1="${roundDimension(objectY)}"
|
||||||
|
x2="${roundDimension(objectX - 22)}" y2="${roundDimension(
|
||||||
|
objectY + objectHeight
|
||||||
|
)}" stroke="#111827" marker-start="url(#part-arrow-${row.itemNumber})"
|
||||||
|
marker-end="url(#part-arrow-${row.itemNumber})"/>
|
||||||
|
<line x1="${roundDimension(objectX - 30)}" y1="${roundDimension(objectY)}"
|
||||||
|
x2="${roundDimension(objectX)}" y2="${roundDimension(objectY)}"
|
||||||
|
stroke="#6b7280"/>
|
||||||
|
<line x1="${roundDimension(objectX - 30)}" y1="${roundDimension(
|
||||||
|
objectY + objectHeight
|
||||||
|
)}" x2="${roundDimension(objectX)}" y2="${roundDimension(
|
||||||
|
objectY + objectHeight
|
||||||
|
)}" stroke="#6b7280"/>
|
||||||
|
<text x="${roundDimension(objectX - 37)}"
|
||||||
|
y="${roundDimension(objectY + objectHeight / 2)}" text-anchor="middle"
|
||||||
|
transform="rotate(-90 ${roundDimension(
|
||||||
|
objectX - 37
|
||||||
|
)} ${roundDimension(objectY + objectHeight / 2)})">${formatDimension(
|
||||||
|
vertical
|
||||||
|
)}</text>
|
||||||
|
</svg>
|
||||||
|
<div class="hole-schedule">
|
||||||
|
<strong>孔位与加工基准</strong>
|
||||||
|
<ul>${holeNotes}</ul>
|
||||||
|
${
|
||||||
|
pitchNotes
|
||||||
|
? `<p><strong>孔距:</strong>${escapeHtml(pitchNotes)}</p>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
<p><strong>基准:</strong>左下角为孔位坐标基准,尺寸单位 mm。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>`
|
||||||
|
}
|
||||||
|
|
||||||
export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
||||||
const { overallDimensions: dimensions } = input
|
const { overallDimensions: dimensions } = input
|
||||||
const projectionBodies =
|
const projectionBodies =
|
||||||
@ -495,9 +908,13 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
|||||||
<td>${formatDimension(row.dimensions.x)} × ${formatDimension(row.dimensions.y)} × ${formatDimension(row.dimensions.z)}</td>
|
<td>${formatDimension(row.dimensions.x)} × ${formatDimension(row.dimensions.y)} × ${formatDimension(row.dimensions.z)}</td>
|
||||||
<td>${escapeHtml(row.material)}</td>
|
<td>${escapeHtml(row.material)}</td>
|
||||||
<td>${escapeHtml(row.process)}</td>
|
<td>${escapeHtml(row.process)}</td>
|
||||||
|
<td>${row.holes.length > 0 ? `${row.holes.length} × Ø${escapeHtml(
|
||||||
|
uniqueRounded(row.holes.map((hole) => hole.diameter)).join(' / Ø')
|
||||||
|
)}` : '-'}</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
)
|
)
|
||||||
.join('')
|
.join('')
|
||||||
|
const partDetails = input.bomRows.map(machiningDetailSvg).join('')
|
||||||
|
|
||||||
const snapshot = input.snapshotDataUrl
|
const snapshot = input.snapshotDataUrl
|
||||||
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
|
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
|
||||||
@ -539,6 +956,17 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
|||||||
th, td { border: 1px solid #6b7280; padding: 5px 6px; text-align: left; vertical-align: top; }
|
th, td { border: 1px solid #6b7280; padding: 5px 6px; text-align: left; vertical-align: top; }
|
||||||
th { background: #f3f4f6; }
|
th { background: #f3f4f6; }
|
||||||
.bom { grid-column: 1 / -1; }
|
.bom { grid-column: 1 / -1; }
|
||||||
|
.part-details { grid-column: 1 / -1; break-before: page; }
|
||||||
|
.part-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||||
|
.part-detail { border: 1px solid #6b7280; break-inside: avoid; }
|
||||||
|
.part-detail-heading { display: flex; justify-content: space-between; gap: 12px; padding: 7px 9px; border-bottom: 1px solid #9ca3af; background: #f3f4f6; font-size: 12px; }
|
||||||
|
.part-detail-layout { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(190px, .8fr); gap: 8px; align-items: start; padding: 6px; }
|
||||||
|
.part-detail svg { width: 100%; display: block; font-size: 10px; }
|
||||||
|
.detail-hole { fill: white; stroke: #111827; stroke-width: 1.5; }
|
||||||
|
.center-line { stroke: #6b7280; stroke-width: .7; stroke-dasharray: 4 3; }
|
||||||
|
.hole-schedule { padding: 8px 6px 6px 0; font-size: 10px; line-height: 1.5; }
|
||||||
|
.hole-schedule ul { margin: 5px 0 8px; padding-left: 18px; }
|
||||||
|
.hole-schedule p { margin: 5px 0; }
|
||||||
.notes { grid-column: 1 / -1; border: 1px solid #9ca3af; padding: 10px; min-height: 68px; white-space: pre-wrap; }
|
.notes { grid-column: 1 / -1; border: 1px solid #9ca3af; padding: 10px; min-height: 68px; white-space: pre-wrap; }
|
||||||
footer { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #6b7280; font-size: 11px; }
|
footer { margin-top: 12px; display: grid; grid-template-columns: 1fr 1fr 1fr; border: 1px solid #6b7280; font-size: 11px; }
|
||||||
footer div { min-height: 34px; padding: 8px; border-right: 1px solid #6b7280; }
|
footer div { min-height: 34px; padding: 8px; border-right: 1px solid #6b7280; }
|
||||||
@ -577,11 +1005,15 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
|||||||
<h2>配件明细表(BOM)</h2>
|
<h2>配件明细表(BOM)</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>序号</th><th>零件名称</th><th>数量</th><th>外形尺寸</th><th>材料</th><th>生产工艺</th></tr>
|
<tr><th>序号</th><th>零件名称</th><th>数量</th><th>外形尺寸</th><th>材料</th><th>生产工艺</th><th>孔位</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="part-details">
|
||||||
|
<h2>零件加工详图、孔径与孔位尺寸</h2>
|
||||||
|
<div class="part-detail-grid">${partDetails}</div>
|
||||||
|
</section>
|
||||||
<section class="notes"><strong>生产说明:</strong><br/>${escapeHtml(input.notes || '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。')}</section>
|
<section class="notes"><strong>生产说明:</strong><br/>${escapeHtml(input.notes || '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。')}</section>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user