2213 lines
72 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

export type Point3d = {
x: number
y: 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 = {
id: string
name: string
center: Point3d
dimensions: Point3d
engineDimensions?: Point3d
dimensionSource?: 'engine' | 'kcl-parameters' | 'engine-volume-check'
dimensionReferences?: string[]
volume?: number
surfaceArea?: number
holes?: HoleFeature[]
}
export type ManufacturingLengthParameter = {
name: string
valueMm: number
}
export type ExplodeOffset = {
id: string
offset: Point3d
}
export type BomRow = {
key: string
itemNumber: number
name: string
quantity: number
dimensions: Point3d
volume?: number
surfaceArea?: number
material: string
process: string
bodyIds: string[]
center: Point3d
holes: HoleFeature[]
}
export type EngineeringSheetInput = {
projectName: string
generatedAt: string
overallDimensions: Point3d
bodies?: BodyMeasurement[]
brandName?: string
brandLogoUrl?: string
snapshotDataUrl?: string
bomRows: BomRow[]
notes?: string
}
const EPSILON = 1e-6
const LENGTH_UNIT_TO_MM: Record<string, number> = {
mm: 1,
cm: 10,
m: 1000,
in: 25.4,
ft: 304.8,
yd: 914.4,
}
function roundDimension(value: number) {
return Math.round(value * 100) / 100
}
export function parseManufacturingLengthInMillimeters(value: string) {
const match = value
.trim()
.match(/^(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\s*(mm|cm|m|in|ft|yd)\b/i)
if (!match) return undefined
const numericValue = Number(match[1])
const multiplier = LENGTH_UNIT_TO_MM[match[2].toLowerCase()]
if (!Number.isFinite(numericValue) || multiplier === undefined) {
return undefined
}
return Math.abs(numericValue * multiplier)
}
function maximumDimension(body: BodyMeasurement) {
return Math.max(
Math.abs(body.dimensions.x),
Math.abs(body.dimensions.y),
Math.abs(body.dimensions.z)
)
}
function compactPartName(name: string) {
return name.trim().replaceAll(/[\s_-]+/g, '').toLowerCase()
}
type DimensionRole =
| 'length'
| 'width'
| 'height'
| 'thickness'
| 'diameter'
| 'radius'
type ClassifiedLengthParameter = ManufacturingLengthParameter & {
baseName: string
role: DimensionRole
}
const DIMENSION_ROLE_PATTERNS: Array<{
role: DimensionRole
patterns: RegExp[]
}> = [
{
role: 'thickness',
patterns: [/(?:thickness|thick)$/i, /(?:厚度|板厚|厚)$/],
},
{
role: 'diameter',
patterns: [/(?:diameter|dia)$/i, /(?:直径|外径)$/],
},
{
role: 'radius',
patterns: [/(?:radius|rad)$/i, /(?:半径)$/],
},
{
role: 'length',
patterns: [/(?:length|long|len)$/i, /(?:长度|长)$/],
},
{
role: 'width',
patterns: [/(?:width|wide|depth)$/i, /(?:宽度|深度|宽)$/],
},
{
role: 'height',
patterns: [/(?:height|high)$/i, /(?:高度|高)$/],
},
]
function classifyLengthParameter(
parameter: ManufacturingLengthParameter
): ClassifiedLengthParameter | undefined {
const compactName = compactPartName(parameter.name)
for (const { role, patterns } of DIMENSION_ROLE_PATTERNS) {
const pattern = patterns.find((candidate) => candidate.test(compactName))
if (!pattern) continue
return {
...parameter,
baseName: compactName.replace(pattern, ''),
role,
}
}
return undefined
}
function partParameterAliases(bodyName: string) {
const normalized = compactPartName(normalizePartName(bodyName))
const aliases = new Set([normalized])
if (/(?:tabletop|desktop|worktop|countertop|tablebody|台面|桌面)/.test(normalized)) {
;[
'tabletop',
'table',
'desktop',
'desk',
'worktop',
'countertop',
'top',
'台面',
'桌面',
'桌',
].forEach((alias) => aliases.add(alias))
}
if (/(?:leg|foot|支腿|桌腿)/.test(normalized)) {
;['leg', 'foot', 'tableleg', 'deskleg', '支腿', '桌腿'].forEach((alias) =>
aliases.add(alias)
)
}
if (/(?:stud|bolt|螺柱|螺栓)/.test(normalized)) {
;['stud', 'connectorstud', 'bolt', '螺柱', '螺栓'].forEach((alias) =>
aliases.add(alias)
)
}
if (/(?:panel|board|板)/.test(normalized)) {
;['panel', 'board', '板'].forEach((alias) => aliases.add(alias))
}
return [...aliases].filter(Boolean)
}
function parameterAffinity(baseName: string, aliases: string[]) {
if (!baseName) return 0
let score = 0
for (const alias of aliases) {
if (baseName === alias) score = Math.max(score, 8)
else if (baseName.startsWith(alias) || alias.startsWith(baseName)) {
score = Math.max(score, Math.min(baseName.length, alias.length) >= 3 ? 6 : 2)
} else if (baseName.includes(alias) || alias.includes(baseName)) {
score = Math.max(score, Math.min(baseName.length, alias.length) >= 3 ? 4 : 1)
}
}
return score
}
function bestParameterForRole(
parameters: ClassifiedLengthParameter[],
role: DimensionRole,
aliases: string[]
) {
const candidates = parameters
.filter((parameter) => parameter.role === role)
.map((parameter) => ({
parameter,
score: parameterAffinity(parameter.baseName, aliases),
}))
.filter(({ score }) => score >= 4)
.sort(
(left, right) =>
right.score - left.score ||
left.parameter.name.localeCompare(right.parameter.name)
)
if (candidates.length === 0) return undefined
if (
candidates.length > 1 &&
candidates[0].score === candidates[1].score &&
candidates[0].parameter.valueMm !== candidates[1].parameter.valueMm
) {
return undefined
}
return candidates[0].parameter
}
function mapSortedDimensions(
engineDimensions: Point3d,
desiredAscending: [number, number, number]
) {
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
(left, right) =>
Math.abs(engineDimensions[left]) - Math.abs(engineDimensions[right])
)
const next = { ...engineDimensions }
axes.forEach((axis, index) => {
next[axis] = desiredAscending[index]
})
return next
}
function inferParametricDimensions(
body: BodyMeasurement,
parameters: ClassifiedLengthParameter[]
) {
const aliases = partParameterAliases(body.name)
const length = bestParameterForRole(parameters, 'length', aliases)
const width = bestParameterForRole(parameters, 'width', aliases)
const height = bestParameterForRole(parameters, 'height', aliases)
const thickness = bestParameterForRole(parameters, 'thickness', aliases)
const diameter = bestParameterForRole(parameters, 'diameter', aliases)
const radius = bestParameterForRole(parameters, 'radius', aliases)
const resolvedDiameter =
diameter ??
(radius
? {
...radius,
name: `${radius.name}×2`,
valueMm: radius.valueMm * 2,
}
: undefined)
const values = (['x', 'y', 'z'] as const)
.map((axis) => Math.abs(body.dimensions[axis]))
.sort((left, right) => left - right)
const normalizedName = compactPartName(normalizePartName(body.name))
const looksLikePanel =
/(panel|board|tabletop|desktop|worktop|countertop|top|seat|shelf|板)/.test(
normalizedName
) || (values[0] / Math.max(values[1], EPSILON) < 0.35 &&
values[1] / Math.max(values[2], EPSILON) > 0.25)
const looksLikeRod =
/(leg|foot|stud|dowel|pin|bolt|支腿|桌腿|螺柱|螺栓)/.test(normalizedName) ||
values[2] / Math.max(values[1], EPSILON) > 2
if (looksLikePanel && length && width && thickness) {
const desired = [
Math.abs(thickness.valueMm),
Math.min(Math.abs(length.valueMm), Math.abs(width.valueMm)),
Math.max(Math.abs(length.valueMm), Math.abs(width.valueMm)),
] as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [length.name, width.name, thickness.name],
}
}
if (looksLikeRod && height && resolvedDiameter) {
const desired = [
Math.abs(resolvedDiameter.valueMm),
Math.abs(resolvedDiameter.valueMm),
Math.abs(height.valueMm),
].sort((left, right) => left - right) as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [height.name, resolvedDiameter.name],
}
}
if (looksLikeRod && height && thickness) {
const desired = [
Math.abs(thickness.valueMm),
Math.abs(thickness.valueMm),
Math.abs(height.valueMm),
].sort((left, right) => left - right) as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [height.name, thickness.name],
}
}
if (length && width && height) {
const desired = [
Math.abs(length.valueMm),
Math.abs(width.valueMm),
Math.abs(height.valueMm),
].sort((left, right) => left - right) as [number, number, number]
return {
dimensions: mapSortedDimensions(body.dimensions, desired),
references: [length.name, width.name, height.name],
}
}
return undefined
}
function dimensionsDiffer(left: Point3d, right: Point3d, tolerance = 0.02) {
return (['x', 'y', 'z'] as const).some((axis) => {
const baseline = Math.max(Math.abs(right[axis]), EPSILON)
return Math.abs(left[axis] - right[axis]) / baseline > tolerance
})
}
function isKnownTemplateBody(body: BodyMeasurement) {
return /^unit(?:box|cube|cylinder[xyz]?|righttriangle[xyz]?)$/.test(
compactPartName(body.name)
)
}
export function filterManufacturingBodies(bodies: BodyMeasurement[]) {
if (bodies.length <= 1) return bodies
const hasProductionScaleBody = bodies.some(
(body) => maximumDimension(body) >= 20
)
const filtered = bodies.filter((body) => {
if (isKnownTemplateBody(body)) return false
return !(hasProductionScaleBody && maximumDimension(body) <= 2)
})
return filtered.length > 0 ? filtered : bodies
}
function normalizeBase64Payload(value: string) {
const normalized = value
.trim()
.replaceAll(/\s+/g, '')
.replaceAll('-', '+')
.replaceAll('_', '/')
return normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
}
export function createSnapshotDataUrl(contents: string) {
const trimmed = contents.trim()
if (trimmed.startsWith('data:')) {
const commaIndex = trimmed.indexOf(',')
if (commaIndex < 0 || !trimmed.slice(0, commaIndex).includes(';base64')) {
return trimmed
}
return `${trimmed.slice(0, commaIndex + 1)}${normalizeBase64Payload(
trimmed.slice(commaIndex + 1)
)}`
}
return `data:image/png;base64,${normalizeBase64Payload(trimmed)}`
}
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) {
return { x: 1, y: 0, z: 0 }
}
return {
x: point.x / length,
y: point.y / length,
z: point.z / length,
}
}
function fallbackDirection(index: number, count: number): Point3d {
const goldenAngle = Math.PI * (3 - Math.sqrt(5))
const y = count <= 1 ? 0 : 1 - (index / (count - 1)) * 2
const radius = Math.sqrt(Math.max(0, 1 - y * y))
const angle = index * goldenAngle
return normalize({
x: Math.cos(angle) * radius,
y,
z: Math.sin(angle) * radius,
})
}
export function getOverallBounds(bodies: BodyMeasurement[]) {
if (bodies.length === 0) {
return {
center: { x: 0, y: 0, z: 0 },
dimensions: { x: 0, y: 0, z: 0 },
}
}
const min = { x: Infinity, y: Infinity, z: Infinity }
const max = { x: -Infinity, y: -Infinity, z: -Infinity }
for (const body of bodies) {
for (const axis of ['x', 'y', 'z'] as const) {
const half = body.dimensions[axis] / 2
min[axis] = Math.min(min[axis], body.center[axis] - half)
max[axis] = Math.max(max[axis], body.center[axis] + half)
}
}
return {
center: {
x: (min.x + max.x) / 2,
y: (min.y + max.y) / 2,
z: (min.z + max.z) / 2,
},
dimensions: {
x: max.x - min.x,
y: max.y - min.y,
z: max.z - min.z,
},
}
}
export function buildExplodeOffsets(
bodies: BodyMeasurement[],
strength = 0.7
): ExplodeOffset[] {
if (bodies.length < 2) return []
const bounds = getOverallBounds(bodies)
const diagonal = Math.max(vectorLength(bounds.dimensions), 25)
return bodies.map((body, index) => {
const fromCenter = {
x: body.center.x - bounds.center.x,
y: body.center.y - bounds.center.y,
z: body.center.z - bounds.center.z,
}
const direction =
vectorLength(fromCenter) < diagonal * 0.025
? fallbackDirection(index, bodies.length)
: normalize(fromCenter)
const bodyDiagonal = vectorLength(body.dimensions)
const distance =
diagonal * strength * (0.7 + index / Math.max(bodies.length * 5, 1)) +
bodyDiagonal * 0.18
return {
id: body.id,
offset: {
x: roundDimension(direction.x * distance),
y: roundDimension(direction.y * distance),
z: roundDimension(direction.z * distance),
},
}
})
}
function normalizePartName(name: string) {
const cleaned = name.trim() || '未命名零件'
let withoutInstanceSuffix = cleaned
for (let index = 0; index < 4; index += 1) {
const next = withoutInstanceSuffix
.replace(
/(?:[_-]?(?:copy|part|source|solid|instance|assembly))[_-]?\d*$/i,
''
)
.replace(/[_-]?\d+$/i, '')
.trim()
if (!next || next === withoutInstanceSuffix) break
withoutInstanceSuffix = next
}
const directionalPartMatch = withoutInstanceSuffix.match(
/^(?:front|back|rear)(?:left|right)(leg|foot|stud)$/i
)
const normalized = directionalPartMatch?.[1] ?? withoutInstanceSuffix
if (/^(?:connector|mounting)?stud$/i.test(normalized)) {
return 'connectorStud'
}
return normalized
}
const CHINESE_PART_NAMES: Record<string, string> = {
backpanel: '背板',
base: '底座',
baseboard: '底板',
bottompanel: '底板',
bracket: '支架',
camconnector: '三合一偏心连接件',
camlock: '三合一偏心轮',
connectorbolt: '连接螺栓',
connectorplate: '连接片',
connectorstud: '连接螺柱',
dividerpanel: '隔板',
doorpanel: '门板',
dowel: '木榫',
drawer: '抽屉',
drawerfront: '抽屉面板',
foot: '支腿',
handle: '拉手',
hinge: '铰链',
metalbracket: '金属连接角码',
mountingpanel: '安装基板',
leftsidepanel: '左侧板',
leg: '支腿',
rightsidepanel: '右侧板',
screw: '螺钉',
shelfpin: '层板托',
seat: '座板',
seatboard: '座板',
seatbody: '座板',
shelf: '层板',
shelfpanel: '层板',
sidepanel: '侧板',
support: '支撑件',
tablebody: '桌体',
tabletop: '台面板',
topboard: '顶板',
topbottompanel: '顶底板',
toppanel: '顶板',
washer: '垫圈',
}
type DimensionCorrectionCandidate = {
dimensions: Point3d
}
function median(values: number[]) {
const sorted = [...values].sort((left, right) => left - right)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2 === 0
? (sorted[middle - 1] + sorted[middle]) / 2
: sorted[middle]
}
function correctionGroupKey(body: BodyMeasurement) {
return `${compactPartName(normalizePartName(body.name))}::${dimensionsKey(
body.dimensions
)}`
}
function dimensionCorrectionCandidate(
body: BodyMeasurement,
representativeVolume: number
): DimensionCorrectionCandidate | undefined {
const axes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
(left, right) =>
Math.abs(body.dimensions[left]) - Math.abs(body.dimensions[right])
)
const values = axes.map((axis) => Math.abs(body.dimensions[axis]))
const boundingVolume = values[0] * values[1] * values[2]
if (!(representativeVolume > EPSILON) || !(boundingVolume > EPSILON)) {
return undefined
}
const partName = compactPartName(normalizePartName(body.name))
const isPanel =
/(panel|board|tabletop|desktop|worktop|countertop|top|seat|shelf)$/.test(
partName
) &&
values[0] / values[1] < 0.35
const isRoundRod =
/(leg|foot|stud|dowel|pin|bolt)$/.test(partName) &&
values[1] / values[0] < 1.25 &&
values[2] / values[1] > 1.4
if (isPanel) {
const thickness = snapCorrectedDimension(values[0])
const faceAspectRatio = values[2] / Math.max(values[1], EPSILON)
const faceArea = representativeVolume / Math.max(thickness, EPSILON)
const shortFace = Math.sqrt(faceArea / faceAspectRatio)
const longFace = shortFace * faceAspectRatio
const faceScale = shortFace / Math.max(values[1], EPSILON)
const correctedAscending = [
thickness,
snapCorrectedDimension(shortFace),
snapCorrectedDimension(longFace),
] as [number, number, number]
// This branch repairs the known transformed Safari scene bounds. A broad
// cutout can also reduce volume, so only accept the characteristic scale
// range and manufacturing-grid dimensions instead of shrinking every panel.
if (
faceScale < 0.72 ||
faceScale > 0.92 ||
correctedAscending.some((value) => !(value > EPSILON))
) {
return undefined
}
return {
dimensions: mapSortedDimensions(body.dimensions, correctedAscending),
}
}
if (isRoundRod) {
const axialLength = values[2]
const crossSectionFill =
representativeVolume /
Math.max(values[0] * values[1] * axialLength, EPSILON)
if (crossSectionFill < 0.42 || crossSectionFill > 0.9) {
return undefined
}
const diameter = Math.sqrt(
(4 * representativeVolume) / (Math.PI * axialLength)
)
const diameterScale = diameter / Math.max((values[0] + values[1]) / 2, EPSILON)
if (diameterScale < 0.72 || diameterScale > 1.08) {
return undefined
}
const correctedDiameter = snapCorrectedDimension(diameter)
return {
dimensions: mapSortedDimensions(
body.dimensions,
[
correctedDiameter,
correctedDiameter,
snapCorrectedDimension(axialLength),
].sort((left, right) => left - right) as [number, number, number]
),
}
}
return undefined
}
function snapCorrectedDimension(value: number) {
const absolute = Math.abs(value)
const step = absolute >= 100 ? 10 : absolute >= 20 ? 1 : 0.5
const snapped = Math.round(absolute / step) * step
return Math.abs(snapped - absolute) <= Math.max(step * 0.55, absolute * 0.01)
? snapped
: 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
}
function inferFastenerHoles(bodies: BodyMeasurement[]) {
const fasteners = bodies.filter((body) =>
/(?:stud|bolt|dowel|pin|screw|螺柱|螺栓|木榫|销|螺钉)/i.test(
normalizePartName(body.name)
)
)
if (fasteners.length === 0) return bodies
return bodies.map((body) => {
if (!isPanelBody(body) || (body.holes ?? []).length > 0) return body
const axes = panelAxes(body.dimensions)
const thickness = Math.abs(body.dimensions[axes.depth])
const engineDimensions = body.engineDimensions ?? body.dimensions
const inferred = fasteners.flatMap((fastener): HoleFeature[] => {
const fastenerAxes = (['x', 'y', 'z'] as Array<keyof Point3d>).sort(
(left, right) =>
Math.abs(fastener.dimensions[left]) -
Math.abs(fastener.dimensions[right])
)
const fastenerDepth = Math.abs(
fastener.dimensions[fastenerAxes[2]]
)
const diameter =
(Math.abs(fastener.dimensions[fastenerAxes[0]]) +
Math.abs(fastener.dimensions[fastenerAxes[1]])) /
2
if (
!(diameter > EPSILON) ||
fastenerDepth / Math.max(diameter, EPSILON) < 1.25
) {
return []
}
const depthDistance = Math.abs(
fastener.center[axes.depth] - body.center[axes.depth]
)
if (
depthDistance >
thickness / 2 + fastenerDepth / 2 + Math.max(1, thickness * 0.08)
) {
return []
}
const center = { ...body.center }
for (const faceAxis of [axes.horizontal, axes.vertical]) {
const engineSize = Math.abs(engineDimensions[faceAxis])
const correctedSize = Math.abs(body.dimensions[faceAxis])
const local = fastener.center[faceAxis] - body.center[faceAxis]
center[faceAxis] =
body.center[faceAxis] +
local * (correctedSize / Math.max(engineSize, EPSILON))
if (
Math.abs(center[faceAxis] - body.center[faceAxis]) + diameter / 2 >
correctedSize / 2 + EPSILON
) {
return []
}
}
const depth = Math.min(fastenerDepth, thickness)
return [
{
type:
depth < thickness - Math.max(0.1, thickness * 0.02)
? 'blind-hole'
: 'through-hole',
center,
diameter: roundDimension(diameter),
axis: axes.depth,
depth: roundDimension(depth),
},
]
})
const holes = inferred.filter(
(candidate, index) =>
inferred.findIndex(
(existing) =>
existing.axis === candidate.axis &&
vectorLength(subtract(existing.center, candidate.center)) < 0.35
) === index
)
return holes.length > 0 ? { ...body, holes } : body
})
}
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 inferFastenerHoles(bodies)
const panelBodies = bodies.filter(isPanelBody)
const genericTarget = [...panelBodies].sort(
(left, right) => panelFaceArea(right) - panelFaceArea(left)
)[0]
const parameterBodies = 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 }
})
return inferFastenerHoles(parameterBodies)
}
/**
* Zoo occasionally reports transformed scene bounds while physical volume
* remains in source-part scale. Reconcile each recognized manufacturing shape
* independently so unlike shapes do not need to share a browser transform.
*/
export function reconcileManufacturingDimensions(
bodies: BodyMeasurement[],
lengthParameters: ManufacturingLengthParameter[] = []
): BodyMeasurement[] {
const classifiedParameters = lengthParameters
.filter(
(parameter) =>
parameter.name.trim() &&
Number.isFinite(parameter.valueMm) &&
Math.abs(parameter.valueMm) > EPSILON
)
.map(classifyLengthParameter)
.filter(
(parameter): parameter is ClassifiedLengthParameter =>
parameter !== undefined
)
const groups = new Map<string, BodyMeasurement[]>()
for (const body of bodies) {
const key = correctionGroupKey(body)
groups.set(key, [...(groups.get(key) ?? []), body])
}
const candidates = new Map<string, DimensionCorrectionCandidate>()
for (const [key, group] of groups) {
const volumes = group
.map((body) => body.volume)
.filter((volume): volume is number => Number.isFinite(volume))
if (volumes.length === 0) continue
const candidate = dimensionCorrectionCandidate(group[0], median(volumes))
if (candidate) candidates.set(key, candidate)
}
const dimensionBodies = bodies.map<BodyMeasurement>((body) => {
const inferred = inferParametricDimensions(body, classifiedParameters)
if (inferred) {
const corrected = {
x: snapCorrectedDimension(inferred.dimensions.x),
y: snapCorrectedDimension(inferred.dimensions.y),
z: snapCorrectedDimension(inferred.dimensions.z),
}
return {
...body,
dimensions: corrected,
engineDimensions: dimensionsDiffer(corrected, body.dimensions)
? body.dimensions
: body.engineDimensions,
dimensionSource: 'kcl-parameters',
dimensionReferences: inferred.references,
}
}
const candidate = candidates.get(correctionGroupKey(body))
if (!candidate) {
return {
...body,
dimensionSource: body.dimensionSource ?? 'engine',
}
}
const normalized = {
x: snapCorrectedDimension(candidate.dimensions.x),
y: snapCorrectedDimension(candidate.dimensions.y),
z: snapCorrectedDimension(candidate.dimensions.z),
}
return {
...body,
dimensions: normalized,
engineDimensions: dimensionsDiffer(normalized, body.dimensions)
? body.dimensions
: body.engineDimensions,
dimensionSource: 'engine-volume-check',
}
})
return reconcileManufacturingHoles(dimensionBodies, lengthParameters)
}
export function localizePartName(name: string) {
const cleaned = normalizePartName(name.trim() || '未命名零件')
if (/[\u3400-\u9fff]/.test(cleaned)) return cleaned
return CHINESE_PART_NAMES[compactPartName(cleaned)] ?? cleaned
}
function dimensionsKey(dimensions: Point3d) {
return [dimensions.x, dimensions.y, dimensions.z]
.map((value) => roundDimension(value).toFixed(2))
.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))
.sort((a, b) => a - b)
const [smallest, middle, largest] = values
if (largest <= 0) return '待确认'
if (smallest / largest < 0.08 && middle / largest > 0.25) {
return '板材下料 / 折弯 / 表面处理'
}
if (middle / largest < 0.2) {
return '棒料下料 / 车削或磨削'
}
return '机加工或增材制造(待工艺确认)'
}
export function groupBodiesIntoBom(bodies: BodyMeasurement[]): BomRow[] {
const groups = new Map<string, BomRow>()
for (const body of bodies) {
const normalizedName = normalizePartName(body.name)
const name = localizePartName(normalizedName)
const key = `${normalizedName.toLowerCase()}::${dimensionsKey(
body.dimensions
)}::${holePatternKey(body)}`
const existing = groups.get(key)
if (existing) {
existing.quantity += 1
existing.bodyIds.push(body.id)
continue
}
groups.set(key, {
key,
itemNumber: groups.size + 1,
name,
quantity: 1,
dimensions: {
x: roundDimension(body.dimensions.x),
y: roundDimension(body.dimensions.y),
z: roundDimension(body.dimensions.z),
},
volume: body.volume,
surfaceArea: body.surfaceArea,
material: '待确认',
process: inferProcess(body.dimensions),
bodyIds: [body.id],
center: body.center,
holes: body.holes ?? [],
})
}
return [...groups.values()]
}
function csvCell(value: string | number | undefined) {
const text = value === undefined ? '' : String(value)
return `"${text.replaceAll('"', '""')}"`
}
export function createBomCsv(projectName: string, rows: BomRow[]) {
const header = [
'项目',
'序号',
'零件名称',
'数量',
'长(mm)',
'宽(mm)',
'高(mm)',
'材料',
'生产工艺',
'体积(mm³)',
'表面积(mm²)',
]
const dataRows = rows.map((row) => [
projectName,
row.itemNumber,
row.name,
row.quantity,
row.dimensions.x,
row.dimensions.y,
row.dimensions.z,
row.material,
row.process,
row.volume === undefined ? '' : roundDimension(row.volume),
row.surfaceArea === undefined ? '' : roundDimension(row.surfaceArea),
])
return `\uFEFF${[header, ...dataRows]
.map((row) => row.map(csvCell).join(','))
.join('\r\n')}`
}
function escapeHtml(value: string) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
function createExplodedAssemblySvg(
bodies: BodyMeasurement[],
bomRows: BomRow[]
) {
const parts = filterManufacturingBodies(bodies)
const offsets = new Map(
buildExplodeOffsets(parts, 0.42).map(({ id, offset }) => [id, offset])
)
const itemNumberByBodyId = new Map(
bomRows.flatMap((row) =>
row.bodyIds.map((bodyId) => [bodyId, row.itemNumber] as const)
)
)
const boxes = parts.map((body, index) => {
const offset = offsets.get(body.id) ?? { x: 0, y: 0, z: 0 }
const center = {
x: body.center.x + offset.x,
y: body.center.y + offset.y,
z: body.center.z + offset.z,
}
const half = {
x: Math.max(Math.abs(body.dimensions.x) / 2, 0.5),
y: Math.max(Math.abs(body.dimensions.y) / 2, 0.5),
z: Math.max(Math.abs(body.dimensions.z) / 2, 0.5),
}
const corner = (x: number, y: number, z: number) => ({
x: center.x + half.x * x,
y: center.y + half.y * y,
z: center.z + half.z * z,
})
return {
body,
center,
itemNumber: itemNumberByBodyId.get(body.id) ?? index + 1,
corners: {
a: corner(-1, -1, -1),
b: corner(1, -1, -1),
c: corner(1, 1, -1),
e: corner(-1, -1, 1),
f: corner(1, -1, 1),
g: corner(1, 1, 1),
h: corner(-1, 1, 1),
},
}
})
const project = (point: Point3d) => ({
x: (point.x - point.y) * 0.866,
y: (point.x + point.y) * 0.42 - point.z,
})
const projectedCorners = boxes.flatMap(({ corners }) =>
Object.values(corners).map(project)
)
const minX = Math.min(...projectedCorners.map((point) => point.x), 0)
const maxX = Math.max(...projectedCorners.map((point) => point.x), 1)
const minY = Math.min(...projectedCorners.map((point) => point.y), 0)
const maxY = Math.max(...projectedCorners.map((point) => point.y), 1)
const width = 760
const height = 310
const drawingLeft = 24
const drawingTop = 18
const drawingWidth = 535
const drawingHeight = 274
const scale = Math.min(
drawingWidth / Math.max(maxX - minX, EPSILON),
drawingHeight / Math.max(maxY - minY, EPSILON)
)
const screenPoint = (point: Point3d) => {
const projected = project(point)
return {
x: drawingLeft + (projected.x - minX) * scale,
y: drawingTop + (projected.y - minY) * scale,
}
}
const points = (values: Point3d[]) =>
values
.map(screenPoint)
.map((point) => `${roundDimension(point.x)},${roundDimension(point.y)}`)
.join(' ')
const palettes = [
['#d6c0a0', '#b79b77', '#947856'],
['#d1d5db', '#9ca3af', '#6b7280'],
['#bfdbfe', '#93c5fd', '#60a5fa'],
['#ddd6fe', '#c4b5fd', '#a78bfa'],
['#bbf7d0', '#86efac', '#4ade80'],
]
const partMarkup = [...boxes]
.sort((left, right) => project(left.center).y - project(right.center).y)
.map(({ body, center, corners, itemNumber }) => {
const palette = palettes[(itemNumber - 1) % palettes.length]
const calloutStart = screenPoint(center)
const calloutEnd = {
x: Math.min(calloutStart.x + 20, drawingLeft + drawingWidth - 12),
y: Math.max(calloutStart.y - 18, drawingTop + 12),
}
return `
<g class="explode-part" data-part-name="${escapeHtml(
localizePartName(body.name)
)}">
<polygon points="${points([
corners.a,
corners.b,
corners.f,
corners.e,
])}" fill="${palette[1]}" stroke="#374151"/>
<polygon points="${points([
corners.b,
corners.c,
corners.g,
corners.f,
])}" fill="${palette[2]}" stroke="#374151"/>
<polygon points="${points([
corners.e,
corners.f,
corners.g,
corners.h,
])}" fill="${palette[0]}" stroke="#374151"/>
<line x1="${roundDimension(calloutStart.x)}" y1="${roundDimension(
calloutStart.y
)}" x2="${roundDimension(calloutEnd.x)}" y2="${roundDimension(
calloutEnd.y
)}" stroke="#111827"/>
<circle cx="${roundDimension(calloutEnd.x)}" cy="${roundDimension(
calloutEnd.y
)}" r="9" fill="#111827"/>
<text x="${roundDimension(calloutEnd.x)}" y="${roundDimension(
calloutEnd.y + 3.4
)}" text-anchor="middle" fill="white" font-size="9">${itemNumber}</text>
</g>`
})
.join('')
const visibleRows = bomRows.slice(0, 9)
const legend = visibleRows
.map(
(row, index) => `
<g transform="translate(0 ${index * 24})">
<circle cx="10" cy="0" r="9" fill="#111827"/>
<text x="10" y="3.5" text-anchor="middle" fill="white" font-size="9">${row.itemNumber}</text>
<text x="27" y="3.5" font-size="11">${escapeHtml(row.name)} × ${row.quantity}</text>
</g>`
)
.join('')
return `
<svg class="generated-exploded-view" data-generated-exploded-view="true"
viewBox="0 0 ${width} ${height}" role="img" aria-label="自动生成装配爆炸图">
<rect width="${width}" height="${height}" fill="white"/>
${partMarkup}
<g class="exploded-legend" transform="translate(575 42)">
<text x="0" y="-22" font-size="12" font-weight="700">零件索引</text>
${legend}
${
bomRows.length > visibleRows.length
? `<text x="0" y="${visibleRows.length * 24 + 4}" font-size="10">另有 ${
bomRows.length - visibleRows.length
} 类零件</text>`
: ''
}
</g>
</svg>`
}
function formatDimension(value: number) {
return `${roundDimension(value)} mm`
}
function projectedBodyRect(
body: BodyMeasurement,
horizontalAxis: keyof Point3d,
verticalAxis: keyof Point3d,
originX: number,
originY: number,
scale: number,
minHorizontal: number,
minVertical: number,
projectedHeight: number
) {
const horizontalStart =
body.center[horizontalAxis] - body.dimensions[horizontalAxis] / 2
const verticalStart =
body.center[verticalAxis] - body.dimensions[verticalAxis] / 2
const rectX = originX + (horizontalStart - minHorizontal) * scale
const rectWidth = Math.max(body.dimensions[horizontalAxis] * scale, 1)
const rectHeight = Math.max(body.dimensions[verticalAxis] * scale, 1)
const rectY =
originY +
projectedHeight -
(verticalStart - minVertical) * scale -
rectHeight
return `<rect class="projection-part"
data-part-name="${escapeHtml(localizePartName(normalizePartName(body.name)))}"
x="${roundDimension(rectX)}" y="${roundDimension(rectY)}"
width="${roundDimension(rectWidth)}" height="${roundDimension(rectHeight)}"
fill="#e5e7eb" fill-opacity="0.64" stroke="#111827" stroke-width="1.4"/>`
}
function orthographicViewSvg(
label: string,
bodies: BodyMeasurement[],
horizontalAxis: keyof Point3d,
verticalAxis: keyof Point3d,
width = 310,
height = 210
) {
const overall = getOverallBounds(bodies)
const horizontal = overall.dimensions[horizontalAxis]
const vertical = overall.dimensions[verticalAxis]
const maxObjectWidth = 180
const maxObjectHeight = 112
const scale = Math.min(
maxObjectWidth / Math.max(horizontal, EPSILON),
maxObjectHeight / Math.max(vertical, EPSILON)
)
const objectWidth = Math.max(horizontal * scale, 1)
const objectHeight = Math.max(vertical * scale, 1)
const objectX = 72 + (maxObjectWidth - objectWidth) / 2
const objectY = 40 + (maxObjectHeight - objectHeight) / 2
const minHorizontal =
overall.center[horizontalAxis] - overall.dimensions[horizontalAxis] / 2
const minVertical =
overall.center[verticalAxis] - overall.dimensions[verticalAxis] / 2
const horizontalText = escapeHtml(formatDimension(horizontal))
const verticalText = escapeHtml(formatDimension(vertical))
const projectedBodies = bodies
.map((body) =>
projectedBodyRect(
body,
horizontalAxis,
verticalAxis,
objectX,
objectY,
scale,
minHorizontal,
minVertical,
objectHeight
)
)
.join('')
return `
<figure class="dimension-view">
<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(label)}">
<defs>
<marker id="arrow-${escapeHtml(label)}" 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>
${projectedBodies}
<line x1="${objectX}" y1="${objectY + objectHeight + 25}"
x2="${objectX + objectWidth}" y2="${objectY + objectHeight + 25}"
stroke="#111827" marker-start="url(#arrow-${escapeHtml(label)})"
marker-end="url(#arrow-${escapeHtml(label)})"/>
<line x1="${objectX}" y1="${objectY + objectHeight}" x2="${objectX}"
y2="${objectY + objectHeight + 35}" stroke="#6b7280"/>
<line x1="${objectX + objectWidth}" y1="${objectY + objectHeight}"
x2="${objectX + objectWidth}" y2="${objectY + objectHeight + 35}" stroke="#6b7280"/>
<text x="${objectX + objectWidth / 2}" y="${objectY + objectHeight + 48}"
text-anchor="middle">${horizontalText}</text>
<line x1="${objectX - 25}" y1="${objectY}" x2="${objectX - 25}"
y2="${objectY + objectHeight}" stroke="#111827"
marker-start="url(#arrow-${escapeHtml(label)})"
marker-end="url(#arrow-${escapeHtml(label)})"/>
<line x1="${objectX - 35}" y1="${objectY}" x2="${objectX}"
y2="${objectY}" stroke="#6b7280"/>
<line x1="${objectX - 35}" y1="${objectY + objectHeight}" x2="${objectX}"
y2="${objectY + objectHeight}" stroke="#6b7280"/>
<text x="${objectX - 42}" y="${objectY + objectHeight / 2}"
text-anchor="middle" transform="rotate(-90 ${objectX - 42} ${objectY + objectHeight / 2})">
${verticalText}
</text>
<text x="${width / 2}" y="20" text-anchor="middle" font-weight="700">
${escapeHtml(label)}
</text>
</svg>
</figure>`
}
function uniqueRounded(values: number[]) {
return [
...new Set(
values.map((value) => roundDimension(value).toFixed(2))
),
]
.map(Number)
.sort((left, right) => left - right)
}
function isCircularPrismaticPart(row: BomRow) {
const values = [
Math.abs(row.dimensions.x),
Math.abs(row.dimensions.y),
Math.abs(row.dimensions.z),
].sort((left, right) => left - right)
const [diameterA, diameterB, length] = values
if (
diameterA <= EPSILON ||
diameterB / diameterA > 1.12 ||
length / diameterB < 1.4
) {
return false
}
const compactName = compactPartName(row.name)
const explicitlyRound =
/(圆形|圆柱|圆管|螺柱|木榫|销|round|cylinder|stud|dowel|pin)/i.test(
compactName
)
const possiblyRound =
explicitlyRound || /(支腿|leg|foot)/i.test(compactName)
if (!possiblyRound) return false
if (explicitlyRound && row.volume === undefined) return true
if (!(row.volume && row.volume > EPSILON)) return false
const cylinderVolume =
(Math.PI / 4) * diameterA * diameterB * length
const fillRatio = row.volume / cylinderVolume
return fillRatio >= 0.65 && fillRatio <= 1.15
}
function sideDimensionSvg(row: BomRow) {
const axes = panelAxes(row.dimensions)
const horizontal = Math.abs(row.dimensions[axes.depth])
const vertical = Math.abs(row.dimensions[axes.vertical])
const isCircularSection = isCircularPrismaticPart(row)
const maxObjectWidth = 112
const maxObjectHeight = 145
const scale = Math.min(
maxObjectWidth / Math.max(horizontal, EPSILON),
maxObjectHeight / Math.max(vertical, EPSILON)
)
const objectWidth = Math.max(horizontal * scale, 1.5)
const objectHeight = Math.max(vertical * scale, 1.5)
const objectX = 68 + (maxObjectWidth - objectWidth) / 2
const objectY = 42 + (maxObjectHeight - objectHeight) / 2
const hiddenHoleRows = new Map<
string,
{ center: number; diameter: number }
>()
row.holes
.filter((hole) => hole.axis === axes.depth)
.forEach((hole) => {
const center =
hole.center[axes.vertical] -
row.center[axes.vertical] +
vertical / 2
hiddenHoleRows.set(
`${roundDimension(center)}:${roundDimension(hole.diameter)}`,
{ center, diameter: hole.diameter }
)
})
const hiddenHoleMarkup = [...hiddenHoleRows.values()]
.map(({ center, diameter }) => {
const centerY = objectY + objectHeight - center * scale
const radius = (diameter / 2) * scale
return `
<line class="hidden-line" x1="${roundDimension(objectX)}"
y1="${roundDimension(centerY - radius)}"
x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(centerY - radius)}"/>
<line class="hidden-line" x1="${roundDimension(objectX)}"
y1="${roundDimension(centerY + radius)}"
x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(centerY + radius)}"/>`
})
.join('')
const profileMarkup = isCircularSection
? `
<circle class="circular-part-profile" data-profile="circular"
cx="${roundDimension(objectX + objectWidth / 2)}"
cy="${roundDimension(objectY + objectHeight / 2)}"
r="${roundDimension(Math.min(objectWidth, objectHeight) / 2)}"
fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>
<line class="center-line" x1="${roundDimension(objectX - 7)}"
y1="${roundDimension(objectY + objectHeight / 2)}"
x2="${roundDimension(objectX + objectWidth + 7)}"
y2="${roundDimension(objectY + objectHeight / 2)}"/>
<line class="center-line" x1="${roundDimension(
objectX + objectWidth / 2
)}" y1="${roundDimension(objectY - 7)}"
x2="${roundDimension(objectX + objectWidth / 2)}"
y2="${roundDimension(objectY + objectHeight + 7)}"/>`
: `
<rect x="${roundDimension(objectX)}" y="${roundDimension(objectY)}"
width="${roundDimension(objectWidth)}" height="${roundDimension(
objectHeight
)}" fill="#f9fafb" stroke="#111827" stroke-width="1.6"/>`
const verticalDimensionMarkup = isCircularSection
? ''
: `
<line x1="${roundDimension(objectX - 22)}" y1="${roundDimension(objectY)}"
x2="${roundDimension(objectX - 22)}" y2="${roundDimension(
objectY + objectHeight
)}" stroke="#111827" marker-start="url(#side-arrow-${row.itemNumber})"
marker-end="url(#side-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>`
return `
<svg class="part-side-view" viewBox="0 0 250 245" role="img"
aria-label="${escapeHtml(row.name)} 侧面尺寸图">
<defs>
<marker id="side-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>
<text x="125" y="18" text-anchor="middle" font-weight="700">侧面图</text>
${profileMarkup}
${hiddenHoleMarkup}
<line x1="${roundDimension(objectX)}" y1="${roundDimension(
objectY + objectHeight + 22
)}" x2="${roundDimension(objectX + objectWidth)}"
y2="${roundDimension(objectY + objectHeight + 22)}" stroke="#111827"
marker-start="url(#side-arrow-${row.itemNumber})"
marker-end="url(#side-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">${
isCircularSection ? 'Ø' : ''
}${formatDimension(horizontal)}</text>
${verticalDimensionMarkup}
</svg>`
}
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 isCircularSection = isCircularPrismaticPart(row)
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)} mm`
},距左 ${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" data-cross-section="${
isCircularSection ? 'circular' : 'rectangular'
}">
<div class="part-detail-heading">
<strong>${row.itemNumber}. ${escapeHtml(row.name)}</strong>
<span>数量 ${row.quantity} · ${
isCircularSection ? '直径 Ø' : '厚度 '
}${formatDimension(depth)}</span>
</div>
<div class="part-detail-layout">
<svg class="part-front-view" 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>
<text x="195" y="18" text-anchor="middle" font-weight="700">正面图</text>
<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)})">${
isCircularSection ? 'Ø' : ''
}${formatDimension(vertical)}</text>
</svg>
${sideDimensionSvg(row)}
<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 =
input.bodies && input.bodies.length > 0
? input.bodies
: [
{
id: 'overall',
name: '总体',
center: { x: 0, y: 0, z: 0 },
dimensions,
},
]
const rows = input.bomRows
.map(
(row) => `
<tr>
<td>${row.itemNumber}</td>
<td>${escapeHtml(row.name)}</td>
<td>${row.quantity}</td>
<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="爆炸图"/>`
: createExplodedAssemblySvg(projectionBodies, input.bomRows)
const brandName = escapeHtml(input.brandName || '由壹点AI')
const brandLogo = input.brandLogoUrl
? `<img class="brand-logo" src="${escapeHtml(input.brandLogoUrl)}" alt="${brandName}"/>`
: ''
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>${escapeHtml(input.projectName)} - 生产工艺图</title>
<style>
@page { size: A3 landscape; margin: 12mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #111827; font-family: Arial, "Microsoft YaHei", sans-serif; }
header { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(0, 2fr) minmax(180px, 1fr); align-items: center; gap: 20px; min-height: 52px; border-bottom: 2px solid #111827; padding: 5px 0 7px; }
.brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
.brand-logo { width: 30px; height: 30px; object-fit: contain; }
.brand-name { overflow: hidden; font-size: 15px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.title { min-width: 0; text-align: center; }
.title p { margin-top: 2px; color: #4b5563; font-size: 11px; line-height: 1.25; }
h1 { overflow: hidden; margin: 0; font-size: 18px; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap; }
h2 { margin: 0 0 8px; font-size: 16px; }
p { margin: 4px 0; }
.meta { color: #4b5563; font-size: 10px; line-height: 1.45; text-align: right; white-space: nowrap; }
.sheet-grid { display: grid; grid-template-columns: 1.05fr 1fr; gap: 16px; margin-top: 14px; }
section { break-inside: avoid; }
.snapshot-wrap { position: relative; }
.snapshot-brand { position: absolute; left: 10px; top: 10px; z-index: 1; display: flex; align-items: center; gap: 6px; padding: 5px 8px; background: rgba(255,255,255,.9); border: 1px solid #d1d5db; font-size: 11px; font-weight: 700; }
.snapshot-brand img { width: 20px; height: 20px; object-fit: contain; }
.snapshot { width: 100%; height: 310px; object-fit: contain; border: 1px solid #9ca3af; }
.generated-exploded-view { width: 100%; height: 310px; display: block; border: 1px solid #9ca3af; background: white; }
.explode-part polygon { stroke-width: 1.1; vector-effect: non-scaling-stroke; }
.exploded-legend { color: #111827; }
.views { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.dimension-view { margin: 0; border: 1px solid #d1d5db; }
.dimension-view svg { width: 100%; display: block; font-size: 12px; }
table { width: 100%; border-collapse: collapse; font-size: 11px; }
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: 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.12fr) minmax(220px, .72fr) minmax(230px, .86fr); gap: 10px; 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; }
.hidden-line { stroke: #6b7280; stroke-width: .8; stroke-dasharray: 5 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; }
footer div:last-child { border-right: 0; }
</style>
</head>
<body>
<header>
<div class="brand">
${brandLogo}
<span class="brand-name">${brandName}</span>
</div>
<div class="title">
<h1>${escapeHtml(input.projectName)} · 生产工艺图</h1>
<p>总体尺寸:${formatDimension(dimensions.x)} × ${formatDimension(dimensions.y)} × ${formatDimension(dimensions.z)}</p>
</div>
<div class="meta">单位:mm<br/>生成时间:${escapeHtml(input.generatedAt)}</div>
</header>
<main class="sheet-grid">
<section>
<h2>装配爆炸图</h2>
<div class="snapshot-wrap">
<div class="snapshot-brand">${brandLogo}<span>${brandName}</span></div>
${snapshot}
</div>
</section>
<section>
<h2>实体正投影三视图与尺寸</h2>
<div class="views">
${orthographicViewSvg('正视图', projectionBodies, 'x', 'z')}
${orthographicViewSvg('俯视图', projectionBodies, 'x', 'y')}
${orthographicViewSvg('右视图', projectionBodies, 'y', 'z')}
</div>
</section>
<section class="bom">
<h2>配件明细表(BOM)</h2>
<table>
<thead>
<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>
<div>制图:</div><div>审核:</div><div>批准:</div>
</footer>
<script>window.addEventListener('load', () => setTimeout(() => window.print(), 250));</script>
</body>
</html>`
}