482 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

export type Point3d = {
x: number
y: number
z: number
}
export type 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
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>`
}