fix: render HiCAD exploded fallback and reposition trigger

This commit is contained in:
134 2026-07-27 22:31:43 +08:00
parent 09502c18f7
commit 4b42b8831e
3 changed files with 220 additions and 3 deletions

View File

@ -168,6 +168,9 @@ test.describe('HiCAD manufacturing extension', () => {
),
'utf8'
)
expect(launcher).toContain("document.querySelectorAll('canvas')")
expect(launcher).toContain('left: Math.round(canvas.rect.left + 16)')
expect(launcher).toContain('className="fixed z-20')
expect(launcher).toContain("lazy(() =>")
expect(launcher).toContain('ManufacturingBoundary')
@ -280,6 +283,9 @@ test.describe('HiCAD manufacturing extension', () => {
expect(html).toContain('横向中心孔距 320 mm')
expect(html).toContain('纵向中心孔距 200 mm')
expect(html.match(/class="part-detail"/g)).toHaveLength(4)
expect(html).toContain('data-generated-exploded-view="true"')
expect(html.match(/class="explode-part"/g)).toHaveLength(10)
expect(html).not.toContain('暂无爆炸图快照')
await page.setViewportSize({ width: 1600, height: 900 })
await page.setContent(html)

View File

@ -2,6 +2,7 @@ import {
Component,
lazy,
Suspense,
useEffect,
useState,
type ErrorInfo,
type ReactNode,
@ -63,6 +64,52 @@ export function HicadManufacturingLauncher({
projectName: string
}) {
const [active, setActive] = useState(false)
const [triggerPosition, setTriggerPosition] = useState({
left: 16,
bottom: 48,
})
useEffect(() => {
const updateTriggerPosition = () => {
const canvas = [...document.querySelectorAll('canvas')]
.map((element) => ({
element,
rect: element.getBoundingClientRect(),
}))
.filter(({ rect }) => rect.width > 280 && rect.height > 220)
.sort(
(left, right) =>
right.rect.width * right.rect.height -
left.rect.width * left.rect.height
)[0]
if (!canvas) return
setTriggerPosition({
left: Math.round(canvas.rect.left + 16),
bottom: Math.round(
Math.max(window.innerHeight - canvas.rect.bottom + 16, 48)
),
})
}
const observer = new ResizeObserver(updateTriggerPosition)
const observeCanvases = () =>
document.querySelectorAll('canvas').forEach((canvas) => observer.observe(canvas))
const mutationObserver = new MutationObserver(() => {
observeCanvases()
updateTriggerPosition()
})
observeCanvases()
mutationObserver.observe(document.body, { childList: true, subtree: true })
window.addEventListener('resize', updateTriggerPosition)
updateTriggerPosition()
return () => {
observer.disconnect()
mutationObserver.disconnect()
window.removeEventListener('resize', updateTriggerPosition)
}
}, [])
return (
<>
@ -70,7 +117,8 @@ export function HicadManufacturingLauncher({
type="button"
data-testid="hicad-manufacturing-trigger"
onClick={() => setActive(true)}
className="absolute right-4 top-14 z-20 h-9 border border-chalkboard-30 bg-chalkboard-10 px-3 text-sm font-medium shadow-sm hover:bg-chalkboard-20 dark:border-chalkboard-70 dark:bg-chalkboard-90 dark:hover:bg-chalkboard-80"
style={triggerPosition}
className="fixed z-20 h-9 border border-chalkboard-30 bg-chalkboard-10 px-3 text-sm font-medium shadow-sm hover:bg-chalkboard-20 dark:border-chalkboard-70 dark:bg-chalkboard-90 dark:hover:bg-chalkboard-80"
>
工程出图
</button>

View File

@ -593,6 +593,167 @@ function escapeHtml(value: string) {
.replaceAll("'", '&#039;')
}
function createExplodedAssemblySvg(
bodies: BodyMeasurement[],
bomRows: BomRow[]
) {
const parts = filterManufacturingBodies(bodies)
const offsets = new Map(
buildExplodeOffsets(parts, 0.42).map(({ id, offset }) => [id, offset])
)
const itemNumberByBodyId = new Map(
bomRows.flatMap((row) =>
row.bodyIds.map((bodyId) => [bodyId, row.itemNumber] as const)
)
)
const boxes = parts.map((body, index) => {
const offset = offsets.get(body.id) ?? { x: 0, y: 0, z: 0 }
const center = {
x: body.center.x + offset.x,
y: body.center.y + offset.y,
z: body.center.z + offset.z,
}
const half = {
x: Math.max(Math.abs(body.dimensions.x) / 2, 0.5),
y: Math.max(Math.abs(body.dimensions.y) / 2, 0.5),
z: Math.max(Math.abs(body.dimensions.z) / 2, 0.5),
}
const corner = (x: number, y: number, z: number) => ({
x: center.x + half.x * x,
y: center.y + half.y * y,
z: center.z + half.z * z,
})
return {
body,
center,
itemNumber: itemNumberByBodyId.get(body.id) ?? index + 1,
corners: {
a: corner(-1, -1, -1),
b: corner(1, -1, -1),
c: corner(1, 1, -1),
e: corner(-1, -1, 1),
f: corner(1, -1, 1),
g: corner(1, 1, 1),
h: corner(-1, 1, 1),
},
}
})
const project = (point: Point3d) => ({
x: (point.x - point.y) * 0.866,
y: (point.x + point.y) * 0.42 - point.z,
})
const projectedCorners = boxes.flatMap(({ corners }) =>
Object.values(corners).map(project)
)
const minX = Math.min(...projectedCorners.map((point) => point.x), 0)
const maxX = Math.max(...projectedCorners.map((point) => point.x), 1)
const minY = Math.min(...projectedCorners.map((point) => point.y), 0)
const maxY = Math.max(...projectedCorners.map((point) => point.y), 1)
const width = 760
const height = 310
const drawingLeft = 24
const drawingTop = 18
const drawingWidth = 535
const drawingHeight = 274
const scale = Math.min(
drawingWidth / Math.max(maxX - minX, EPSILON),
drawingHeight / Math.max(maxY - minY, EPSILON)
)
const screenPoint = (point: Point3d) => {
const projected = project(point)
return {
x: drawingLeft + (projected.x - minX) * scale,
y: drawingTop + (projected.y - minY) * scale,
}
}
const points = (values: Point3d[]) =>
values
.map(screenPoint)
.map((point) => `${roundDimension(point.x)},${roundDimension(point.y)}`)
.join(' ')
const palettes = [
['#d6c0a0', '#b79b77', '#947856'],
['#d1d5db', '#9ca3af', '#6b7280'],
['#bfdbfe', '#93c5fd', '#60a5fa'],
['#ddd6fe', '#c4b5fd', '#a78bfa'],
['#bbf7d0', '#86efac', '#4ade80'],
]
const partMarkup = [...boxes]
.sort((left, right) => project(left.center).y - project(right.center).y)
.map(({ body, center, corners, itemNumber }) => {
const palette = palettes[(itemNumber - 1) % palettes.length]
const calloutStart = screenPoint(center)
const calloutEnd = {
x: Math.min(calloutStart.x + 20, drawingLeft + drawingWidth - 12),
y: Math.max(calloutStart.y - 18, drawingTop + 12),
}
return `
<g class="explode-part" data-part-name="${escapeHtml(
localizePartName(body.name)
)}">
<polygon points="${points([
corners.a,
corners.b,
corners.f,
corners.e,
])}" fill="${palette[1]}" stroke="#374151"/>
<polygon points="${points([
corners.b,
corners.c,
corners.g,
corners.f,
])}" fill="${palette[2]}" stroke="#374151"/>
<polygon points="${points([
corners.e,
corners.f,
corners.g,
corners.h,
])}" fill="${palette[0]}" stroke="#374151"/>
<line x1="${roundDimension(calloutStart.x)}" y1="${roundDimension(
calloutStart.y
)}" x2="${roundDimension(calloutEnd.x)}" y2="${roundDimension(
calloutEnd.y
)}" stroke="#111827"/>
<circle cx="${roundDimension(calloutEnd.x)}" cy="${roundDimension(
calloutEnd.y
)}" r="9" fill="#111827"/>
<text x="${roundDimension(calloutEnd.x)}" y="${roundDimension(
calloutEnd.y + 3.4
)}" text-anchor="middle" fill="white" font-size="9">${itemNumber}</text>
</g>`
})
.join('')
const visibleRows = bomRows.slice(0, 9)
const legend = visibleRows
.map(
(row, index) => `
<g transform="translate(0 ${index * 24})">
<circle cx="10" cy="0" r="9" fill="#111827"/>
<text x="10" y="3.5" text-anchor="middle" fill="white" font-size="9">${row.itemNumber}</text>
<text x="27" y="3.5" font-size="11">${escapeHtml(row.name)} × ${row.quantity}</text>
</g>`
)
.join('')
return `
<svg class="generated-exploded-view" data-generated-exploded-view="true"
viewBox="0 0 ${width} ${height}" role="img" aria-label="自动生成装配爆炸图">
<rect width="${width}" height="${height}" fill="white"/>
${partMarkup}
<g class="exploded-legend" transform="translate(575 42)">
<text x="0" y="-22" font-size="12" font-weight="700">零件索引</text>
${legend}
${
bomRows.length > visibleRows.length
? `<text x="0" y="${visibleRows.length * 24 + 4}" font-size="10">另有 ${
bomRows.length - visibleRows.length
} 类零件</text>`
: ''
}
</g>
</svg>`
}
function formatDimension(value: number) {
return `${roundDimension(value)} mm`
}
@ -919,7 +1080,7 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
const snapshot = input.snapshotDataUrl
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
: '<div class="snapshot-empty">暂无爆炸图快照</div>'
: createExplodedAssemblySvg(projectionBodies, input.bomRows)
const brandName = escapeHtml(input.brandName || '由壹点AI')
const brandLogo = input.brandLogoUrl
? `<img class="brand-logo" src="${escapeHtml(input.brandLogoUrl)}" alt="${brandName}"/>`
@ -950,7 +1111,9 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
.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; }
.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; }