482 lines
15 KiB
TypeScript
482 lines
15 KiB
TypeScript
export type Point3d = {
|
||
x: number
|
||
y: number
|
||
z: number
|
||
}
|
||
|
||
export type BodyMeasurement = {
|
||
id: string
|
||
name: string
|
||
center: Point3d
|
||
dimensions: Point3d
|
||
volume?: number
|
||
surfaceArea?: 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[]
|
||
}
|
||
|
||
export type EngineeringSheetInput = {
|
||
projectName: string
|
||
generatedAt: string
|
||
overallDimensions: Point3d
|
||
brandName?: string
|
||
brandLogoUrl?: string
|
||
snapshotDataUrl?: string
|
||
bomRows: BomRow[]
|
||
notes?: string
|
||
}
|
||
|
||
const EPSILON = 1e-6
|
||
|
||
function roundDimension(value: number) {
|
||
return Math.round(value * 100) / 100
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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 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 withoutCopySuffix =
|
||
cleaned.replace(/(?:[_-]?(?:copy|part))?[_-]?\d+$/i, '').trim() || cleaned
|
||
const locationLegMatch = withoutCopySuffix.match(
|
||
/^(?:front|back|rear)(?:left|right)(leg|foot)$/i
|
||
)
|
||
return locationLegMatch?.[1] ?? withoutCopySuffix
|
||
}
|
||
|
||
function dimensionsKey(dimensions: Point3d) {
|
||
return [dimensions.x, dimensions.y, dimensions.z]
|
||
.map((value) => roundDimension(value).toFixed(2))
|
||
.join('x')
|
||
}
|
||
|
||
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 name = normalizePartName(body.name)
|
||
const key = `${name.toLowerCase()}::${dimensionsKey(body.dimensions)}`
|
||
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],
|
||
})
|
||
}
|
||
|
||
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 formatDimension(value: number) {
|
||
return `${roundDimension(value)} mm`
|
||
}
|
||
|
||
function dimensionViewSvg(
|
||
label: string,
|
||
horizontal: number,
|
||
vertical: number,
|
||
width = 310,
|
||
height = 210
|
||
) {
|
||
const objectX = 72
|
||
const objectY = 40
|
||
const objectWidth = 180
|
||
const objectHeight = 112
|
||
const horizontalText = escapeHtml(formatDimension(horizontal))
|
||
const verticalText = escapeHtml(formatDimension(vertical))
|
||
|
||
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>
|
||
<rect x="${objectX}" y="${objectY}" width="${objectWidth}" height="${objectHeight}"
|
||
fill="none" stroke="#111827" stroke-width="2"/>
|
||
<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>`
|
||
}
|
||
|
||
export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
||
const { overallDimensions: dimensions } = input
|
||
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>
|
||
</tr>`
|
||
)
|
||
.join('')
|
||
|
||
const snapshot = input.snapshotDataUrl
|
||
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
|
||
: '<div class="snapshot-empty">暂无爆炸图快照</div>'
|
||
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: flex; align-items: end; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 8px; }
|
||
.brand { display: flex; align-items: center; gap: 10px; min-width: 170px; }
|
||
.brand-logo { width: 38px; height: 38px; object-fit: contain; }
|
||
.brand-name { font-size: 18px; font-weight: 700; white-space: nowrap; }
|
||
.title { flex: 1; }
|
||
h1 { margin: 0; font-size: 24px; }
|
||
h2 { margin: 0 0 8px; font-size: 16px; }
|
||
p { margin: 4px 0; }
|
||
.meta { color: #4b5563; font-size: 12px; text-align: right; }
|
||
.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; }
|
||
.snapshot-empty { height: 310px; display: grid; place-items: center; border: 1px dashed #9ca3af; color: #6b7280; }
|
||
.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; }
|
||
.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">
|
||
${dimensionViewSvg('正视图', dimensions.x, dimensions.z)}
|
||
${dimensionViewSvg('俯视图', dimensions.x, dimensions.y)}
|
||
${dimensionViewSvg('右视图', dimensions.y, dimensions.z)}
|
||
</div>
|
||
</section>
|
||
<section class="bom">
|
||
<h2>配件明细表(BOM)</h2>
|
||
<table>
|
||
<thead>
|
||
<tr><th>序号</th><th>零件名称</th><th>数量</th><th>外形尺寸</th><th>材料</th><th>生产工艺</th></tr>
|
||
</thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</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>`
|
||
}
|