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 = { 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).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).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 = { 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).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).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() for (const body of bodies) { const key = correctionGroupKey(body) groups.set(key, [...(groups.get(key) ?? []), body]) } const candidates = new Map() 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((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() 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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } 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 ` ${itemNumber} ` }) .join('') const visibleRows = bomRows.slice(0, 9) const legend = visibleRows .map( (row, index) => ` ${row.itemNumber} ${escapeHtml(row.name)} × ${row.quantity} ` ) .join('') return ` ${partMarkup} 零件索引 ${legend} ${ bomRows.length > visibleRows.length ? `另有 ${ bomRows.length - visibleRows.length } 类零件` : '' } ` } 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 `` } 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 `
${projectedBodies} ${horizontalText} ${verticalText} ${escapeHtml(label)}
` } 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 ` ` }) .join('') const profileMarkup = isCircularSection ? ` ` : ` ` const verticalDimensionMarkup = isCircularSection ? '' : ` ${formatDimension( vertical )}` return ` 侧面图 ${profileMarkup} ${hiddenHoleMarkup} ${ isCircularSection ? 'Ø' : '' }${formatDimension(horizontal)} ${verticalDimensionMarkup} ` } 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) => ` H${hole.index}` ) .join('') const holeNotes = holePositions.length > 0 ? holePositions .map( (hole) => `
  • H${hole.index}:Ø${roundDimension( hole.diameter )} ${ hole.type === 'through-hole' ? '通孔' : `盲孔,深 ${roundDimension(hole.depth ?? 0)} mm` },距左 ${hole.fromLeft},距下 ${hole.fromBottom}
  • ` ) .join('') : '
  • 该零件未识别到圆孔特征
  • ' const pitchNotes = [ horizontalPitch === undefined ? '' : `横向中心孔距 ${horizontalPitch} mm`, verticalPitch === undefined ? '' : `纵向中心孔距 ${verticalPitch} mm`, ] .filter(Boolean) .join(';') return `
    ${row.itemNumber}. ${escapeHtml(row.name)} 数量 ${row.quantity} · ${ isCircularSection ? '直径 Ø' : '厚度 ' }${formatDimension(depth)}
    正面图 ${holeMarkup} ${formatDimension(horizontal)} ${ isCircularSection ? 'Ø' : '' }${formatDimension(vertical)} ${sideDimensionSvg(row)}
    孔位与加工基准
      ${holeNotes}
    ${ pitchNotes ? `

    孔距:${escapeHtml(pitchNotes)}

    ` : '' }

    基准:左下角为孔位坐标基准,尺寸单位 mm。

    ` } 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) => ` ${row.itemNumber} ${escapeHtml(row.name)} ${row.quantity} ${formatDimension(row.dimensions.x)} × ${formatDimension(row.dimensions.y)} × ${formatDimension(row.dimensions.z)} ${escapeHtml(row.material)} ${escapeHtml(row.process)} ${row.holes.length > 0 ? `${row.holes.length} × Ø${escapeHtml( uniqueRounded(row.holes.map((hole) => hole.diameter)).join(' / Ø') )}` : '-'} ` ) .join('') const partDetails = input.bomRows.map(machiningDetailSvg).join('') const snapshot = input.snapshotDataUrl ? `爆炸图` : createExplodedAssemblySvg(projectionBodies, input.bomRows) const brandName = escapeHtml(input.brandName || '由壹点AI') const brandLogo = input.brandLogoUrl ? `` : '' return ` ${escapeHtml(input.projectName)} - 生产工艺图
    ${brandLogo} ${brandName}

    ${escapeHtml(input.projectName)} · 生产工艺图

    总体尺寸:${formatDimension(dimensions.x)} × ${formatDimension(dimensions.y)} × ${formatDimension(dimensions.z)}

    单位:mm
    生成时间:${escapeHtml(input.generatedAt)}

    装配爆炸图

    ${brandLogo}${brandName}
    ${snapshot}

    实体正投影三视图与尺寸

    ${orthographicViewSvg('正视图', projectionBodies, 'x', 'z')} ${orthographicViewSvg('俯视图', projectionBodies, 'x', 'y')} ${orthographicViewSvg('右视图', projectionBodies, 'y', 'z')}

    配件明细表(BOM)

    ${rows}
    序号零件名称数量外形尺寸材料生产工艺孔位

    零件加工详图、孔径与孔位尺寸

    ${partDetails}
    生产说明:
    ${escapeHtml(input.notes || '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。')}
    制图:
    审核:
    批准:
    ` }