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'
|
'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("lazy(() =>")
|
||||||
expect(launcher).toContain('ManufacturingBoundary')
|
expect(launcher).toContain('ManufacturingBoundary')
|
||||||
|
|
||||||
@ -280,6 +283,9 @@ test.describe('HiCAD manufacturing extension', () => {
|
|||||||
expect(html).toContain('横向中心孔距 320 mm')
|
expect(html).toContain('横向中心孔距 320 mm')
|
||||||
expect(html).toContain('纵向中心孔距 200 mm')
|
expect(html).toContain('纵向中心孔距 200 mm')
|
||||||
expect(html.match(/class="part-detail"/g)).toHaveLength(4)
|
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.setViewportSize({ width: 1600, height: 900 })
|
||||||
await page.setContent(html)
|
await page.setContent(html)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
lazy,
|
lazy,
|
||||||
Suspense,
|
Suspense,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
type ErrorInfo,
|
type ErrorInfo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@ -63,6 +64,52 @@ export function HicadManufacturingLauncher({
|
|||||||
projectName: string
|
projectName: string
|
||||||
}) {
|
}) {
|
||||||
const [active, setActive] = useState(false)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -70,7 +117,8 @@ export function HicadManufacturingLauncher({
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="hicad-manufacturing-trigger"
|
data-testid="hicad-manufacturing-trigger"
|
||||||
onClick={() => setActive(true)}
|
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>
|
</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("'", ''')
|
.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) {
|
function formatDimension(value: number) {
|
||||||
return `${roundDimension(value)} mm`
|
return `${roundDimension(value)} mm`
|
||||||
}
|
}
|
||||||
@ -919,7 +1080,7 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) {
|
|||||||
|
|
||||||
const snapshot = input.snapshotDataUrl
|
const snapshot = input.snapshotDataUrl
|
||||||
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
|
? `<img class="snapshot" src="${escapeHtml(input.snapshotDataUrl)}" alt="爆炸图"/>`
|
||||||
: '<div class="snapshot-empty">暂无爆炸图快照</div>'
|
: createExplodedAssemblySvg(projectionBodies, input.bomRows)
|
||||||
const brandName = escapeHtml(input.brandName || '由壹点AI')
|
const brandName = escapeHtml(input.brandName || '由壹点AI')
|
||||||
const brandLogo = input.brandLogoUrl
|
const brandLogo = input.brandLogoUrl
|
||||||
? `<img class="brand-logo" src="${escapeHtml(input.brandLogoUrl)}" alt="${brandName}"/>`
|
? `<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 { 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-brand img { width: 20px; height: 20px; object-fit: contain; }
|
||||||
.snapshot { width: 100%; height: 310px; object-fit: contain; border: 1px solid #9ca3af; }
|
.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; }
|
.views { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||||
.dimension-view { margin: 0; border: 1px solid #d1d5db; }
|
.dimension-view { margin: 0; border: 1px solid #d1d5db; }
|
||||||
.dimension-view svg { width: 100%; display: block; font-size: 12px; }
|
.dimension-view svg { width: 100%; display: block; font-size: 12px; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user