2105 lines
68 KiB
TypeScript
2105 lines
68 KiB
TypeScript
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() || '未命名零件'
|
||
const withoutInstanceSuffix =
|
||
cleaned
|
||
.replace(
|
||
/(?:[_-]?(?:copy|part|source|solid|instance))[_-]?\d*$/i,
|
||
''
|
||
)
|
||
.replace(/[_-]?\d+$/i, '')
|
||
.trim() || cleaned
|
||
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 = {
|
||
axes: Array<keyof Point3d>
|
||
scale: number
|
||
}
|
||
|
||
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|top|seat|shelf)$/.test(partName) &&
|
||
values[0] / values[1] < 0.35
|
||
const isRoundRod =
|
||
/(leg|foot|stud|dowel|pin)$/.test(partName) &&
|
||
values[1] / values[0] < 1.25 &&
|
||
values[2] / values[1] > 1.4
|
||
|
||
if (isPanel) {
|
||
return {
|
||
axes: [axes[1], axes[2]],
|
||
scale: Math.sqrt(representativeVolume / boundingVolume),
|
||
}
|
||
}
|
||
if (isRoundRod) {
|
||
return {
|
||
axes: [axes[0], axes[1]],
|
||
scale: Math.sqrt(
|
||
representativeVolume / ((Math.PI / 4) * boundingVolume)
|
||
),
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
export function reconcileManufacturingHoles(
|
||
bodies: BodyMeasurement[],
|
||
lengthParameters: ManufacturingLengthParameter[]
|
||
) {
|
||
const holeParameters = lengthParameters
|
||
.filter(
|
||
(parameter) =>
|
||
parameter.name.trim() &&
|
||
Number.isFinite(parameter.valueMm) &&
|
||
Math.abs(parameter.valueMm) > EPSILON
|
||
)
|
||
.map(classifyHoleParameter)
|
||
.filter(
|
||
(parameter): parameter is ClassifiedHoleParameter =>
|
||
parameter !== undefined
|
||
)
|
||
if (holeParameters.length === 0) return bodies
|
||
|
||
const panelBodies = bodies.filter(isPanelBody)
|
||
const genericTarget = [...panelBodies].sort(
|
||
(left, right) => panelFaceArea(right) - panelFaceArea(left)
|
||
)[0]
|
||
|
||
return bodies.map((body) => {
|
||
if (!isPanelBody(body)) return body
|
||
const allowGeneric = body.id === genericTarget?.id
|
||
const diameterParameter = bestHoleParameter(
|
||
holeParameters,
|
||
'diameter',
|
||
body,
|
||
allowGeneric
|
||
)
|
||
const radiusParameter = bestHoleParameter(
|
||
holeParameters,
|
||
'radius',
|
||
body,
|
||
allowGeneric
|
||
)
|
||
const depthParameter = bestHoleParameter(
|
||
holeParameters,
|
||
'depth',
|
||
body,
|
||
allowGeneric
|
||
)
|
||
const diameter =
|
||
diameterParameter?.valueMm ??
|
||
(radiusParameter ? radiusParameter.valueMm * 2 : undefined)
|
||
const axes = panelAxes(body.dimensions)
|
||
const thickness = Math.abs(body.dimensions[axes.depth])
|
||
const depth = Math.min(depthParameter?.valueMm ?? thickness, thickness)
|
||
const type: HoleFeature['type'] =
|
||
depth < thickness - Math.max(0.1, thickness * 0.02)
|
||
? 'blind-hole'
|
||
: 'through-hole'
|
||
|
||
if ((body.holes ?? []).length > 0) {
|
||
return {
|
||
...body,
|
||
holes: (body.holes ?? []).map((hole) => ({
|
||
...hole,
|
||
diameter: roundDimension(diameter ?? hole.diameter),
|
||
depth: roundDimension(depthParameter ? depth : hole.depth ?? thickness),
|
||
type: depthParameter ? type : hole.type,
|
||
})),
|
||
}
|
||
}
|
||
if (diameter === undefined) return body
|
||
|
||
const genericInset = bestHoleParameter(
|
||
holeParameters,
|
||
'inset',
|
||
body,
|
||
allowGeneric
|
||
)?.valueMm
|
||
const insetX =
|
||
bestHoleParameter(holeParameters, 'insetX', body, allowGeneric)?.valueMm ??
|
||
genericInset
|
||
const insetY =
|
||
bestHoleParameter(holeParameters, 'insetY', body, allowGeneric)?.valueMm ??
|
||
genericInset
|
||
const spacingX = bestHoleParameter(
|
||
holeParameters,
|
||
'spacingX',
|
||
body,
|
||
allowGeneric
|
||
)?.valueMm
|
||
const spacingY = bestHoleParameter(
|
||
holeParameters,
|
||
'spacingY',
|
||
body,
|
||
allowGeneric
|
||
)?.valueMm
|
||
const offsetX =
|
||
bestHoleParameter(holeParameters, 'offsetX', body, allowGeneric)?.valueMm ??
|
||
(spacingX !== undefined
|
||
? spacingX / 2
|
||
: insetX === undefined
|
||
? undefined
|
||
: Math.abs(body.dimensions[axes.horizontal]) / 2 - insetX)
|
||
const offsetY =
|
||
bestHoleParameter(holeParameters, 'offsetY', body, allowGeneric)?.valueMm ??
|
||
(spacingY !== undefined
|
||
? spacingY / 2
|
||
: insetY === undefined
|
||
? undefined
|
||
: Math.abs(body.dimensions[axes.vertical]) / 2 - insetY)
|
||
if (
|
||
offsetX === undefined ||
|
||
offsetY === undefined ||
|
||
offsetX < 0 ||
|
||
offsetY < 0 ||
|
||
offsetX + diameter / 2 >
|
||
Math.abs(body.dimensions[axes.horizontal]) / 2 + EPSILON ||
|
||
offsetY + diameter / 2 >
|
||
Math.abs(body.dimensions[axes.vertical]) / 2 + EPSILON
|
||
) {
|
||
return body
|
||
}
|
||
|
||
const holes = [-1, 1].flatMap((horizontalDirection) =>
|
||
[-1, 1].map((verticalDirection): HoleFeature => {
|
||
const center = { ...body.center }
|
||
center[axes.horizontal] =
|
||
body.center[axes.horizontal] + horizontalDirection * offsetX
|
||
center[axes.vertical] =
|
||
body.center[axes.vertical] + verticalDirection * offsetY
|
||
return {
|
||
type,
|
||
center,
|
||
diameter: roundDimension(diameter),
|
||
axis: axes.depth,
|
||
depth: roundDimension(depth),
|
||
}
|
||
})
|
||
)
|
||
return { ...body, holes }
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Zoo occasionally reports a transformed scene bounding box while volume stays
|
||
* in the source part scale. Only correct dimensions when multiple distinct part
|
||
* families independently confirm the same scale, avoiding guesses from one
|
||
* hollow or filleted part.
|
||
*/
|
||
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 && candidate.scale >= 0.72 && candidate.scale <= 0.92) {
|
||
candidates.set(key, candidate)
|
||
}
|
||
}
|
||
|
||
let consensusScale: number | undefined
|
||
if (candidates.size >= 2) {
|
||
const proposedScale = median(
|
||
[...candidates.values()].map((candidate) => candidate.scale)
|
||
)
|
||
const supported = [...candidates.values()].filter(
|
||
(candidate) => Math.abs(candidate.scale - proposedScale) <= 0.055
|
||
)
|
||
if (supported.length >= 2) {
|
||
consensusScale = proposedScale
|
||
}
|
||
}
|
||
|
||
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 (
|
||
consensusScale === undefined ||
|
||
!candidate ||
|
||
Math.abs(candidate.scale - consensusScale) > 0.055
|
||
) {
|
||
return {
|
||
...body,
|
||
dimensionSource: body.dimensionSource ?? 'engine',
|
||
}
|
||
}
|
||
|
||
const corrected = { ...body.dimensions }
|
||
for (const axis of candidate.axes) {
|
||
corrected[axis] *= consensusScale
|
||
}
|
||
const normalized = {
|
||
x: snapCorrectedDimension(corrected.x),
|
||
y: snapCorrectedDimension(corrected.y),
|
||
z: snapCorrectedDimension(corrected.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('&', '&')
|
||
.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 `
|
||
<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>`
|
||
}
|