fix: render HiCAD exploded fallback and reposition trigger
This commit is contained in:
parent
cc113155dd
commit
b5b5398b30
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
167
vendor/zoo-modeling-app/manufacturing.ts
vendored
167
vendor/zoo-modeling-app/manufacturing.ts
vendored
@ -593,6 +593,167 @@ function escapeHtml(value: string) {
|
||||
.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`
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user