diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts index 50f30e080..184ea3bf5 100644 --- a/tests/routes/hicad-manufacturing-extension.test.ts +++ b/tests/routes/hicad-manufacturing-extension.test.ts @@ -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) diff --git a/vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template b/vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template index 0cc43429c..2c6dc0ba0 100644 --- a/vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template +++ b/vendor/zoo-modeling-app/HicadManufacturingLauncher.tsx.template @@ -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" > 工程出图 diff --git a/vendor/zoo-modeling-app/manufacturing.ts b/vendor/zoo-modeling-app/manufacturing.ts index 4fbd35482..7947f5a6a 100644 --- a/vendor/zoo-modeling-app/manufacturing.ts +++ b/vendor/zoo-modeling-app/manufacturing.ts @@ -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 ` + + + + + + + ${itemNumber} + ` + }) + .join('') + const visibleRows = bomRows.slice(0, 9) + const legend = visibleRows + .map( + (row, index) => ` + + + ${row.itemNumber} + ${escapeHtml(row.name)} × ${row.quantity} + ` + ) + .join('') + + return ` + + + ${partMarkup} + + 零件索引 + ${legend} + ${ + bomRows.length > visibleRows.length + ? `另有 ${ + bomRows.length - visibleRows.length + } 类零件` + : '' + } + + ` +} + function formatDimension(value: number) { return `${roundDimension(value)} mm` } @@ -919,7 +1080,7 @@ export function createEngineeringSheetHtml(input: EngineeringSheetInput) { const snapshot = input.snapshotDataUrl ? `爆炸图` - : '
暂无爆炸图快照
' + : createExplodedAssemblySvg(projectionBodies, input.bomRows) const brandName = escapeHtml(input.brandName || '由壹点AI') const brandLogo = input.brandLogoUrl ? `` @@ -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; }