feat: add HiCAD hole dimension production drawings
This commit is contained in:
parent
e40c351f80
commit
d84f3dc7d8
@ -6,9 +6,11 @@ import {
|
||||
createBomCsv,
|
||||
createEngineeringSheetHtml,
|
||||
createSnapshotDataUrl,
|
||||
detectPanelHoles,
|
||||
filterManufacturingBodies,
|
||||
groupBodiesIntoBom,
|
||||
type BodyMeasurement,
|
||||
type CircularEdgeSample,
|
||||
} from '../../vendor/zoo-modeling-app/manufacturing'
|
||||
|
||||
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('keeps the extension isolated behind one official registration point', () => {
|
||||
const projectRoot = process.cwd()
|
||||
@ -162,6 +181,47 @@ test.describe('HiCAD manufacturing extension', () => {
|
||||
).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', () => {
|
||||
const rows = groupBodiesIntoBom(bodies)
|
||||
const html = createEngineeringSheetHtml({
|
||||
@ -185,6 +245,8 @@ test.describe('HiCAD manufacturing extension', () => {
|
||||
expect(html).toContain('data-part-name="台面板"')
|
||||
expect(html).toContain('120 mm')
|
||||
expect(html).toContain('配件明细表(BOM)')
|
||||
expect(html).toContain('零件加工详图、孔径与孔位尺寸')
|
||||
expect(html.match(/class="part-detail"/g)).toHaveLength(2)
|
||||
expect(html).toContain('由壹点AI')
|
||||
expect(html).toContain('/api/favicon?surface=hicad')
|
||||
expect(html).toContain('@page { size: A3 landscape;')
|
||||
@ -215,6 +277,7 @@ test.describe('HiCAD manufacturing extension', () => {
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
tableCount: document.querySelectorAll('table').length,
|
||||
dimensionViewCount: document.querySelectorAll('.dimension-view').length,
|
||||
partDetailCount: document.querySelectorAll('.part-detail').length,
|
||||
projectionPartCount:
|
||||
document.querySelectorAll('.projection-part').length,
|
||||
brandCount: document.querySelectorAll('.brand-name').length,
|
||||
@ -223,6 +286,7 @@ test.describe('HiCAD manufacturing extension', () => {
|
||||
expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
|
||||
expect(layout.tableCount).toBe(1)
|
||||
expect(layout.dimensionViewCount).toBe(3)
|
||||
expect(layout.partDetailCount).toBe(rows.length)
|
||||
expect(layout.projectionPartCount).toBe(bodies.length * 3)
|
||||
expect(layout.brandCount).toBe(1)
|
||||
await page.screenshot({
|
||||
|
||||
@ -11,11 +11,13 @@ import {
|
||||
createSnapshotDataUrl,
|
||||
createBomCsv,
|
||||
createEngineeringSheetHtml,
|
||||
detectPanelHoles,
|
||||
filterManufacturingBodies,
|
||||
getOverallBounds,
|
||||
groupBodiesIntoBom,
|
||||
type BodyMeasurement,
|
||||
type BomRow,
|
||||
type CircularEdgeSample,
|
||||
type ExplodeOffset,
|
||||
} 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 response = await sendModelingCommand({
|
||||
type: 'bounding_box',
|
||||
@ -355,7 +477,7 @@ export function HicadManufacturingPanel({
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
const measurement: BodyMeasurement = {
|
||||
id: bodyId,
|
||||
name: resolveBodyName(bodyId, variables, index),
|
||||
center: boundingBox.center,
|
||||
@ -369,6 +491,9 @@ export function HicadManufacturingPanel({
|
||||
? numberValue(areaResponse.value.data, 'surface_area')
|
||||
: undefined,
|
||||
}
|
||||
const circularEdges = await readCircularEdges(bodyId)
|
||||
measurement.holes = detectPanelHoles(measurement, circularEdges)
|
||||
return measurement
|
||||
})
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: string
|
||||
name: string
|
||||
@ -11,6 +24,7 @@ export type BodyMeasurement = {
|
||||
dimensions: Point3d
|
||||
volume?: number
|
||||
surfaceArea?: number
|
||||
holes?: HoleFeature[]
|
||||
}
|
||||
|
||||
export type ExplodeOffset = {
|
||||
@ -29,6 +43,8 @@ export type BomRow = {
|
||||
material: string
|
||||
process: string
|
||||
bodyIds: string[]
|
||||
center: Point3d
|
||||
holes: HoleFeature[]
|
||||
}
|
||||
|
||||
export type EngineeringSheetInput = {
|
||||
@ -108,6 +124,198 @@ function vectorLength(point: Point3d) {
|
||||
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 {
|
||||
const length = vectorLength(point)
|
||||
if (length < EPSILON) {
|
||||
@ -216,6 +424,10 @@ const CHINESE_PART_NAMES: Record<string, string> = {
|
||||
baseboard: '底板',
|
||||
bottompanel: '底板',
|
||||
bracket: '支架',
|
||||
camconnector: '三合一偏心连接件',
|
||||
camlock: '三合一偏心轮',
|
||||
connectorbolt: '连接螺栓',
|
||||
connectorplate: '连接片',
|
||||
dividerpanel: '隔板',
|
||||
doorpanel: '门板',
|
||||
dowel: '木榫',
|
||||
@ -224,10 +436,12 @@ const CHINESE_PART_NAMES: Record<string, string> = {
|
||||
foot: '支腿',
|
||||
handle: '拉手',
|
||||
hinge: '铰链',
|
||||
metalbracket: '金属连接角码',
|
||||
leftsidepanel: '左侧板',
|
||||
leg: '支腿',
|
||||
rightsidepanel: '右侧板',
|
||||
screw: '螺钉',
|
||||
shelfpin: '层板托',
|
||||
seat: '座板',
|
||||
seatboard: '座板',
|
||||
seatbody: '座板',
|
||||
@ -240,6 +454,7 @@ const CHINESE_PART_NAMES: Record<string, string> = {
|
||||
topboard: '顶板',
|
||||
topbottompanel: '顶底板',
|
||||
toppanel: '顶板',
|
||||
washer: '垫圈',
|
||||
}
|
||||
|
||||
export function localizePartName(name: string) {
|
||||
@ -254,6 +469,26 @@ function dimensionsKey(dimensions: Point3d) {
|
||||
.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) {
|
||||
const values = [dimensions.x, dimensions.y, dimensions.z]
|
||||
.map((value) => Math.abs(value))
|
||||
@ -278,7 +513,7 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
|
||||
const name = localizePartName(normalizedName)
|
||||
const key = `${normalizedName.toLowerCase()}::${dimensionsKey(
|
||||
body.dimensions
|
||||
)}`
|
||||
)}::${holePatternKey(body)}`
|
||||
const existing = groups.get(key)
|
||||
|
||||
if (existing) {
|
||||
@ -302,6 +537,8 @@ export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
|
||||
material: '待确认',
|
||||
process: inferProcess(body.dimensions),
|
||||
bodyIds: [body.id],
|
||||
center: body.center,
|
||||
holes: body.holes ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
@ -472,6 +709,182 @@ function orthographicViewSvg(
|
||||
</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) {
|
||||
const { overallDimensions: dimensions } = input
|
||||
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>${escapeHtml(row.material)}</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>`
|
||||
)
|
||||
.join('')
|
||||
const partDetails = input.bomRows.map(machiningDetailSvg).join('')
|
||||
|
||||
const snapshot = input.snapshotDataUrl
|
||||
? `<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 { background: #f3f4f6; }
|
||||
.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; }
|
||||
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; }
|
||||
@ -577,11 +1005,15 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
||||
<h2>配件明细表(BOM)</h2>
|
||||
<table>
|
||||
<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>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user