From 233a800d4bc54d5090b0741826799bb1c5628a02 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:28:00 -0400 Subject: [PATCH 01/16] Add more viewer features --- .changeset/stock-direction-wireframe.md | 5 + examples/react-viewer/README.md | 12 + examples/react-viewer/src/main.tsx | 254 ++++++++++++------ examples/react-viewer/src/style.css | 118 +++++++- examples/react-viewer/src/viewer-toolbar.tsx | 116 ++++++++ examples/react-viewer/tests/toolbar.spec.ts | 146 ++++++++++ examples/react-viewer/tests/viewer.spec.ts | 10 + packages/viewer/README.md | 116 +++++++- packages/viewer/src/content-box.ts | 6 +- packages/viewer/src/index.ts | 5 + packages/viewer/src/part-mesh.tsx | 9 +- packages/viewer/src/render/camera.ts | 23 +- .../viewer/src/render/direction-highlights.ts | 32 +++ packages/viewer/src/render/part.ts | 28 +- packages/viewer/src/render/section.ts | 10 +- packages/viewer/src/render/stock.ts | 76 ++++++ packages/viewer/src/stock.tsx | 64 +++++ .../viewer/tests/direction-highlights.test.ts | 53 ++++ packages/viewer/tests/display.test.ts | 35 +++ packages/viewer/tests/stock.test.ts | 82 ++++++ 20 files changed, 1081 insertions(+), 119 deletions(-) create mode 100644 .changeset/stock-direction-wireframe.md create mode 100644 examples/react-viewer/src/viewer-toolbar.tsx create mode 100644 examples/react-viewer/tests/toolbar.spec.ts create mode 100644 packages/viewer/src/render/direction-highlights.ts create mode 100644 packages/viewer/src/render/stock.ts create mode 100644 packages/viewer/src/stock.tsx create mode 100644 packages/viewer/tests/direction-highlights.test.ts create mode 100644 packages/viewer/tests/display.test.ts create mode 100644 packages/viewer/tests/stock.test.ts diff --git a/.changeset/stock-direction-wireframe.md b/.changeset/stock-direction-wireframe.md new file mode 100644 index 0000000..c77a6e1 --- /dev/null +++ b/.changeset/stock-direction-wireframe.md @@ -0,0 +1,5 @@ +--- +'@toolpath/viewer': minor +--- + +Add translucent stock meshes and allowance-based box stock, a semantic-edge wireframe display, and deterministic machining-direction face highlights. Fit includes stock while part-relative tools and overlays remain sized to the finished part. diff --git a/examples/react-viewer/README.md b/examples/react-viewer/README.md index 719aa7a..a9467e4 100644 --- a/examples/react-viewer/README.md +++ b/examples/react-viewer/README.md @@ -7,6 +7,17 @@ requires no Toolpath API key or Engine request. The extra parts exist for the me have holes, chamfers, a pocket and a bore to snap to, and each page states what its dimensions should measure. +The bottom toolbar offers stock, axes, grid, direction coloring, wireframe, Section, Measure, Fit, +Reset, and Top view. Stock is a box with an adjustable allowance (initially 3 mm per side); +its X × Y × Z dimensions appear in the sidebar. Use Fit after showing or resizing stock to +frame the whole blank. This allowance is a demonstration setting, not a recommended cutting allowance. + +Direction mode colors the model and shows matching arrows and a clickable legend. Choosing a +direction scopes face picks; All clears that scope. Wireframe shows CAD face boundaries with +painted/hovered faces still visible. Section and Measure use the contextual panel above the +toolbar and switch cleanly between each other. The example owns these controls and uses only +the package's public API. + ```bash pnpm install --frozen-lockfile pnpm --filter @toolpath/example-react-viewer dev @@ -22,6 +33,7 @@ defaults and this example's are not the same: | `/` | A perspective camera. Picking, the section tool, panning, the cube. | | `/?projection=orthographic` | The projection `@toolpath/viewer` itself defaults to. | | `/?orbitTarget=on` | `showOrbitTarget` — two circles at the point the view turns about. | +| `/?stock=on` | Stock visible and framed on the opening view. | The pin on the default page is deliberate: its click points were scanned by hand off the rendered canvas, and a camera change moves every one of them. Each page diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index 1ad39d0..a747a33 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -4,6 +4,11 @@ import { useFrame, useThree } from '@react-three/fiber' import * as THREE from 'three' import { Axes, + BoxStock, + boxStockBounds, + directionHighlights, + directionLabel, + directionColor, Grid, DirectionArrows, ViewCube, @@ -21,6 +26,7 @@ import { type ViewerHandle, } from '@toolpath/viewer' import { MODELS, modelFromQuery } from './models' +import { ViewerToolbar } from './viewer-toolbar' import './style.css' /** @@ -137,6 +143,20 @@ const App = () => { const [measureMode, setMeasureMode] = useState('distance') const [measured, setMeasured] = useState([]) const [direction, setDirection] = useState(null) + const [showStock, setShowStock] = useState(params.get('stock') === 'on') + const [showAxes, setShowAxes] = useState(true) + const [showGrid, setShowGrid] = useState(true) + const [showDirections, setShowDirections] = useState(false) + const [wireframe, setWireframe] = useState(false) + const [allowance, setAllowance] = useState(3) + const stockSize = useMemo( + () => boxStockBounds(part.geometry, allowance).getSize(new THREE.Vector3()), + [allowance, part.geometry], + ) + const highlights = useMemo( + () => (showDirections ? directionHighlights(part.model, direction) : []), + [direction, part.model, showDirections], + ) const [pose, setPose] = useState(AT_START) // Called from a frame, so it runs whether or not anything changed. Holding @@ -166,6 +186,8 @@ const App = () => { setMeasured([]) setSelected([]) setHovered([]) + heldSelection.current = [] + setDirection(null) }} > {MODELS.map((entry) => ( @@ -176,6 +198,36 @@ const App = () => {

{part.hint}

+

+ Stock:{' '} + {stockSize + .toArray() + .map((value) => value.toFixed(2)) + .join(' × ')}{' '} + mm (X × Y × Z) +

+ +

Demo box stock; adjust the allowance for your setup.

+

Left-drag to orbit, middle/right-drag to pan, scroll to zoom, and click a face to select it. Press Section, then click a face to cut through it or one of the @@ -220,87 +272,87 @@ const App = () => {

-
- - - - - - {sectioning && cut ? ( - <> - - - - ) : null} - {sectioning && !cut ? ( - Click a face or a plane · Esc clears + viewerRef.current?.fit()} + onReset={() => viewerRef.current?.reset()} + onTop={() => viewerRef.current?.setView('top')} + onStock={() => setShowStock((on) => !on)} + onAxes={() => setShowAxes((on) => !on)} + onGrid={() => setShowGrid((on) => !on)} + onDirections={() => { + setShowDirections((on) => !on) + setDirection(null) + setSelected([]) + heldSelection.current = [] + setWireframe(false) + }} + onWireframe={() => { + setWireframe((on) => !on) + setShowDirections(false) + setDirection(null) + }} + onSection={() => { + if (sectioning) { + viewerRef.current?.setSection(null) + setSelected(heldSelection.current) + } else { + if (!measuring) heldSelection.current = selected + setSelected([]) + setMeasuring(false) + setMeasured([]) + } + setSectioning((on) => !on) + }} + onMeasure={() => { + if (measuring) { + setMeasured([]) + setSelected(heldSelection.current) + } else { + if (!sectioning) heldSelection.current = selected + setSelected([]) + viewerRef.current?.setSection(null) + setSectioning(false) + } + setMeasuring((on) => !on) + }} + > + {sectioning ? ( +
+ {cut ? ( + <> + + + + ) : ( + Click a face or a plane · Esc clears + )} +
) : null} - {measuring ? ( - <> +
+ ) : null} + {showDirections && !sectioning && !measuring ? ( +
+ + {part.model.candidateDirections.map((axis, index) => ( + + ))} +
) : null} -
+ {/* Perspective by default here, and the pin is the point rather than the value. @@ -357,6 +440,9 @@ const App = () => { model={part.model} geometry={part.geometry} selection={selected} + display={wireframe ? 'wireframe' : 'solid'} + regionHighlights={highlights} + activeDirection={showDirections ? direction : null} onSectionChange={(state) => { setCut(state.enabled ? state : null) if (state.enabled) setOffset(state.offset) @@ -364,15 +450,17 @@ const App = () => { onHover={(pick: PartPick | null) => setHovered(pick ? [...pick.owners] : [])} onPick={(pick: PartPick) => setSelected([...pick.ranked])} /> + {showStock ? : null} setDirection((held) => (held === index ? null : index))} /> {sectioning ? : null} {measuring ? : null} - - + {showGrid ? : null} + {showAxes ? : null}
diff --git a/examples/react-viewer/src/style.css b/examples/react-viewer/src/style.css index a09b7db..d85d3ae 100644 --- a/examples/react-viewer/src/style.css +++ b/examples/react-viewer/src/style.css @@ -63,18 +63,37 @@ strong { border-radius: 1rem; background: radial-gradient(circle at 40% 30%, #283347, #171a24 65%); } -.viewer-toolbar { +.viewer-toolbar-stack { position: absolute; - top: 1rem; - left: 1rem; - right: 6rem; - z-index: 1; + bottom: 1.25rem; + left: 0; + width: 100%; + padding: 0 0.75rem; + z-index: 2; + pointer-events: none; display: flex; - flex-wrap: wrap; + flex-direction: column; align-items: center; - gap: 0.5rem; + gap: 0.6rem; } -.viewer-toolbar button { +.viewer-toolbar, +.viewer-tool-options { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 0.25rem; + max-width: 100%; + padding: 0.4rem; + border: 1px solid #52607880; + border-radius: 0.6rem; + background: #202737df; + backdrop-filter: blur(12px); + box-shadow: 0 3px 14px #0003; +} +.viewer-toolbar-stack button, +.detail-button { border: 1px solid #526078; border-radius: 0.4rem; background: #202737; @@ -82,17 +101,92 @@ strong { cursor: pointer; padding: 0.45rem 0.65rem; } +.viewer-toolbar button { + position: relative; + display: grid; + place-items: center; + width: 2.2rem; + height: 2.2rem; + padding: 0.4rem; + border-color: transparent; + background: transparent; +} +.viewer-toolbar svg { + width: 1.3rem; + height: 1.3rem; +} +.toolbar-divider { + width: 1px; + height: 1.4rem; + margin: 0 0.2rem; + background: #526078; +} +.toolbar-tooltip { + display: none; + position: absolute; + bottom: calc(100% + 0.75rem); + left: 50%; + transform: translateX(-50%); + white-space: nowrap; + background: #101522; + padding: 0.4rem 0.6rem; + border-radius: 0.3rem; + pointer-events: none; + font-size: 0.75rem; + z-index: 3; +} +.viewer-toolbar button:hover .toolbar-tooltip, +.viewer-toolbar button:focus-visible .toolbar-tooltip { + display: block; +} +.viewer-tool-options { + gap: 0.5rem; + padding: 0.6rem; +} +.viewer-tool-options label { + display: flex; + align-items: center; + gap: 0.4rem; +} +.direction-dot { + display: inline-block; + width: 0.65rem; + height: 0.65rem; + border-radius: 50%; + margin-right: 0.4rem; +} +.stock-allowance { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + font-size: 0.85rem; +} +.stock-allowance input { + width: 4.5rem; + padding: 0.35rem; + color: inherit; + background: #202737; + border: 1px solid #526078; + border-radius: 0.3rem; +} +.small-note { + font-size: 0.8rem; +} /* * One focus ring for every control, in the accent blue the eyebrow and a * pressed button already use, and only for keyboard focus: the browser's * default ring is drawn on click too, and on a dark toolbar it reads as a * stuck highlight on whatever was last pressed. */ -.viewer-toolbar button:focus, +.viewer-toolbar-stack button:focus, .model-picker select:focus { outline: none; } -.viewer-toolbar button:focus-visible, +.viewer-toolbar-stack button:focus-visible, +.stock-allowance input:focus-visible, +.detail-button:focus-visible, +.viewer-tool-options input:focus-visible, .model-picker select:focus-visible { outline: 2px solid #83a9ff; outline-offset: 2px; @@ -121,10 +215,10 @@ strong { .model-picker select:hover { background-color: #303b51; } -.viewer-toolbar button:hover { +.viewer-toolbar-stack button:hover { background: #303b51; } -.viewer-toolbar button[aria-pressed='true'] { +.viewer-toolbar-stack button[aria-pressed='true'] { border-color: #83a9ff; background: #2b3f66; } diff --git a/examples/react-viewer/src/viewer-toolbar.tsx b/examples/react-viewer/src/viewer-toolbar.tsx new file mode 100644 index 0000000..277de5f --- /dev/null +++ b/examples/react-viewer/src/viewer-toolbar.tsx @@ -0,0 +1,116 @@ +import type { ReactNode } from 'react' + +const paths = { + fit: 'M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5M8 8h8v8H8z', + reset: 'M3 10a9 9 0 1 1 2 8M3 4v6h6', + top: 'M4 7l8-4 8 4-8 4zM4 7v10l8 4 8-4V7M12 11v10', + stock: 'M3 6l9-4 9 4v12l-9 4-9-4zM3 6l9 4 9-4M12 10v12M7 8v8l5 2 5-2V8', + axes: 'M5 19V3m0 16h16M5 19l10-10M2 6l3-3 3 3m10 10 3 3-3 3M11 9h4v4', + grid: 'M3 8l9-5 9 5-9 5zM3 8v8l9 5 9-5V8M12 13v8M7.5 5.5v8M16.5 5.5v8', + directions: 'M7 3v18m-4-4 4 4 4-4M17 21V3m-4 4 4-4 4 4', + wireframe: 'M4 7l8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 11v10M4 17l8-4 8 4M12 3v10', + section: 'M3 17L17 3l4 4L7 21zM4 6l2-2m3 3 2-2m2 6 2-2m2 6 2-2', + measure: 'M3 16L16 3l5 5L8 21zM8 11l3 3m1-7 3 3m-11 5 3 3', +} as const + +interface ButtonProps { + icon: keyof typeof paths + label: string + pressed?: boolean + onClick: () => void +} + +const ToolbarButton = ({ icon, label, pressed, onClick }: ButtonProps) => ( + +) + +interface ViewerToolbarProps { + stock: boolean + axes: boolean + grid: boolean + directions: boolean + wireframe: boolean + sectioning: boolean + measuring: boolean + onFit: () => void + onReset: () => void + onTop: () => void + onStock: () => void + onAxes: () => void + onGrid: () => void + onDirections: () => void + onWireframe: () => void + onSection: () => void + onMeasure: () => void + children?: ReactNode +} + +export const ViewerToolbar = (props: ViewerToolbarProps) => ( +
+ {props.children} +
+ + + + + + + + + + + + +
+
+) diff --git a/examples/react-viewer/tests/toolbar.spec.ts b/examples/react-viewer/tests/toolbar.spec.ts new file mode 100644 index 0000000..ade6d81 --- /dev/null +++ b/examples/react-viewer/tests/toolbar.spec.ts @@ -0,0 +1,146 @@ +import { expect, test } from '@playwright/test' +import { on, openViewer, readCamera } from './canvas.js' + +for (const projection of ['perspective', 'orthographic'] as const) { + test(`stock, axes and wireframe preserve part interaction (${projection})`, async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()) + }) + const { canvas, box } = await openViewer(page, `projection=${projection}`) + const controls = page.getByRole('group', { name: 'Viewer controls', exact: true }) + const toolbarBox = await controls.boundingBox() + expect(toolbarBox!.y).toBeGreaterThan(box.y + box.height * 0.75) + const original = await canvas.screenshot() + await page.getByRole('button', { name: 'Hide axis', exact: true }).click() + await expect(page.getByRole('button', { name: 'Show axis', exact: true })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + await expect.poll(async () => Buffer.compare(original, await canvas.screenshot())).not.toBe(0) + + const withoutAxes = await canvas.screenshot() + await page.getByRole('button', { name: 'Hide grid', exact: true }).click() + await expect(page.getByRole('button', { name: 'Show grid', exact: true })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + await expect + .poll(async () => Buffer.compare(withoutAxes, await canvas.screenshot())) + .not.toBe(0) + + const beforeStock = await canvas.screenshot() + const before = await readCamera(page) + await page.getByRole('button', { name: 'Show stock', exact: true }).click() + await expect(page.getByRole('button', { name: 'Hide stock', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect + .poll(async () => Buffer.compare(beforeStock, await canvas.screenshot())) + .not.toBe(0) + expect((await readCamera(page)).distance).toBeCloseTo(before.distance, 5) + await expect(page.locator('p', { hasText: 'Stock:' })).toContainText('31.40 × 31.40 × 31.40') + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await expect(page.locator('p', { hasText: 'Selected:' })).toContainText( + projection === 'perspective' ? 'back-face' : 'front-face', + ) + + await page.getByRole('button', { name: 'Fit', exact: true }).click() + await expect + .poll(async () => (await readCamera(page)).distance) + .toBeGreaterThan(before.distance) + const fittedStock = await readCamera(page) + await page.getByRole('spinbutton', { name: 'Allowance per side (mm)' }).fill('6') + await expect(page.locator('p', { hasText: 'Stock:' })).toContainText('37.40 × 37.40 × 37.40') + await page.getByRole('button', { name: 'Fit', exact: true }).click() + await expect + .poll(async () => (await readCamera(page)).distance) + .toBeGreaterThan(fittedStock.distance) + await page.getByRole('button', { name: 'Hide stock', exact: true }).click() + await page.getByRole('button', { name: 'Reset', exact: true }).click() + await expect.poll(async () => (await readCamera(page)).distance).toBeCloseTo(before.distance, 5) + + // Clear the selection before comparing the unpainted solid and wireframe. + await canvas.click({ position: on(box, { x: 0.05, y: 0.05 }) }) + await page.mouse.move(10, 10) + const solid = await canvas.screenshot() + await page.getByRole('button', { name: 'Wireframe', exact: true }).click() + await expect(page.getByRole('button', { name: 'Wireframe', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect.poll(async () => Buffer.compare(solid, await canvas.screenshot())).not.toBe(0) + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await expect(page.locator('p', { hasText: 'Selected:' })).not.toContainText('none') + await page.getByRole('button', { name: 'Section', exact: true }).click() + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await expect(page.locator('p', { hasText: 'Cut:' })).toContainText('Part surface') + await page.getByRole('button', { name: 'Measure', exact: true }).click() + await expect(page.getByRole('button', { name: 'Section', exact: true })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + await expect(page.locator('p', { hasText: 'Cut:' })).toContainText('off') + expect(errors).toEqual([]) + }) + + test(`direction colors scope picks and reset with the model (${projection})`, async ({ + page, + }) => { + const { canvas, box } = await openViewer(page, `projection=${projection}`) + const before = await canvas.screenshot() + const highlights = page.getByRole('button', { name: 'Highlight faces by direction' }) + await highlights.click() + await expect(highlights).toHaveAttribute('aria-pressed', 'true') + await expect.poll(async () => Buffer.compare(before, await canvas.screenshot())).not.toBe(0) + // +X cannot own the face at the centre in either opening projection. + await page.getByRole('button', { name: '+X', exact: true }).click() + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await expect(page.locator('p', { hasText: 'Selected:' })).toContainText('none') + await page + .getByRole('button', { name: projection === 'perspective' ? '−Z' : '+Z', exact: true }) + .click() + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await expect(page.locator('p', { hasText: 'Selected:' })).toContainText( + projection === 'perspective' ? 'back-face' : 'front-face', + ) + await page.getByRole('combobox').selectOption('plate') + await expect(page.locator('p', { hasText: 'Direction:' })).toContainText('all') + await expect(page.locator('p', { hasText: 'Selected:' })).toContainText('none') + await page.getByRole('button', { name: 'Wireframe', exact: true }).click() + await expect(highlights).toHaveAttribute('aria-pressed', 'false') + await expect(page.getByRole('group', { name: 'Machining directions' })).toHaveCount(0) + await highlights.click() + await expect(page.getByRole('button', { name: 'Wireframe', exact: true })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + }) + + test(`stock present on mount does not move the section midpoint (${projection})`, async ({ + page, + }) => { + const { canvas, box } = await openViewer(page, `projection=${projection}&stock=on`) + await page.getByRole('button', { name: 'Section', exact: true }).click() + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await page.getByRole('slider', { name: 'Cut depth' }).fill('0.5') + await expect(page.locator('p', { hasText: 'Cut:' })).toContainText('12.70 mm in') + }) +} + +test('the toolbar remains reachable on a narrow viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await openViewer(page) + const controls = page.getByRole('group', { name: 'Viewer controls', exact: true }) + await controls.scrollIntoViewIfNeeded() + const box = await controls.boundingBox() + expect(box!.x).toBeGreaterThanOrEqual(0) + expect(box!.x + box!.width).toBeLessThanOrEqual(390) + await page.getByRole('button', { name: 'Show stock', exact: true }).click() + await expect(page.getByRole('button', { name: 'Hide stock', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) +}) diff --git a/examples/react-viewer/tests/viewer.spec.ts b/examples/react-viewer/tests/viewer.spec.ts index 26f185f..9630d37 100644 --- a/examples/react-viewer/tests/viewer.spec.ts +++ b/examples/react-viewer/tests/viewer.spec.ts @@ -68,6 +68,11 @@ test('the click points hit the faces the rest of this file is written about', as // The arrow is on top of the part, so it has to take the click itself. If it // has moved off the arrow the selection changes and the direction does not, // which is exactly the pair of symptoms Phase 6 produced. + const beforeDirections = await canvas.screenshot() + await page.getByRole('button', { name: 'Highlight faces by direction' }).click() + await expect + .poll(async () => Buffer.compare(beforeDirections, await canvas.screenshot())) + .not.toBe(0) const before = await selected.textContent() await page.mouse.click(at(box, ARROW).x, at(box, ARROW).y) await expect(direction).toContainText('0') @@ -132,6 +137,11 @@ test('selects a feature and responds to CAD camera navigation', async ({ page }) // go. The arrows sit outside the part, so this reaches past its corner. const direction = page.locator('p', { hasText: 'Direction:' }) await expect(direction).toContainText('all') + const beforeDirections = await canvas.screenshot() + await page.getByRole('button', { name: 'Highlight faces by direction' }).click() + await expect + .poll(async () => Buffer.compare(beforeDirections, await canvas.screenshot())) + .not.toBe(0) const arrow = at(box, ARROW) await page.mouse.click(arrow.x, arrow.y) await expect(direction).not.toContainText('all') diff --git a/packages/viewer/README.md b/packages/viewer/README.md index f7a91a7..7a31ca3 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -76,6 +76,7 @@ What each wrapper is for: ├─ validates the report, loads the mesh, then renders │ └─ draws the part and handles hover, click, colours, section cuts ├─ arrows for the directions the part can be machined from + ├─ translucent stock, included when fitting the camera ├─ optional: click a face or a plane to cut the part open ├─ optional: click two points for a distance, three for an angle ├─ reference geometry, sized to the part @@ -120,6 +121,100 @@ machining direction points most toward the camera wins. ## Components +### Stock and display controls + +Use `` for an axis-aligned blank around a part, or `` +for an actual stock mesh, including cylindrical or irregular blanks. Stock and part coordinates +must use the same millimetre, Z-up frame. Both components include stock in Fit and Reset while +keeping section tools, measurements, the grid, and direction arrows sized to the finished part. +Stock does not intercept clicks or get clipped by the part's section plane. + +```tsx +import { useMemo, useRef, useState } from 'react' +import { + Axes, + BoxStock, + DirectionArrows, + PartMesh, + Viewer, + directionHighlights, +} from '@toolpath/viewer' +import type { PartModel, ViewerHandle } from '@toolpath/viewer' +import type { BufferGeometry } from 'three' + +export function StockPreview({ model, geometry }: { model: PartModel; geometry: BufferGeometry }) { + const viewer = useRef(null) + const [stock, setStock] = useState(false) + const [axes, setAxes] = useState(true) + const [directions, setDirections] = useState(false) + const [direction, setDirection] = useState(null) + const [wireframe, setWireframe] = useState(false) + const colors = useMemo( + () => (directions ? directionHighlights(model, direction) : []), + [model, direction, directions], + ) + + return ( + <> + + + {stock && } + {axes && } + setDirection((held) => (held === index ? null : index))} + /> + + + + + + + + ) +} +``` + +`BoxStock.allowance` is padding **per side**, either a number or `{ x, y, z }`; it defaults to +zero. `offset` translates the stock from the part's bounding-box centre. Both use millimetres. +`boxStockBounds(geometry, allowance, offset)` returns the same `Box3` for displaying dimensions. +Negative/non-finite allowances, non-finite coordinates, and empty or non-positive stock dimensions +throw `RangeError`. The 3 mm allowance above is an example, not an automatic stock recommendation. + +`Stock` accepts `color`, `opacity` (default `0.2`), `edgeColor`, `edgeOpacity`, and `showEdges`. +`BoxStock` accepts the same appearance props. Caller-provided geometry is never disposed; the +components dispose their own materials and outlines. Conditionally mount stock to toggle it. +Toggling stock preserves the camera; Fit/Reset then frames the visible stock together with the part. + +`directionHighlights(model, activeDirection?)` returns region colors in the same palette as +`DirectionArrows`. For a face with several owners, the most specific feature wins, followed by +candidate-direction order and feature tag. Unmatched directions are left unpainted. Hover and +selection still paint over this wash. Scoping by direction filters ownership; it does not hide +the rest of the model or claim that an unpainted face cannot be manufactured. + +Wireframe keeps the existing semantic edges, including rear edges, and keeps faces available for +picking, measuring, and section placement. Highlighted faces and section caps remain visible. +`showEdges` controls solid-mode outlines; wireframe always shows them. The example's bottom toolbar +makes wireframe and direction coloring mutually exclusive and includes a direction legend and +contextual Section/Measure controls. The toolbar is example UI; the package exports the rendering +primitives so applications can supply their own controls. + ### `` The canvas that holds everything. Import it from `@toolpath/viewer`. @@ -176,16 +271,17 @@ Draws the part and handles clicks. You only use it directly when you **Colours** -| Prop | Type | What it does | -| ------------------- | ---------------------- | -------------------------------------------------------------- | -| `selection` | `string[]` | Feature tags to highlight as selected (orange). | -| `highlights` | `FeatureHighlight[]` | Your own colour per feature, e.g. difficulty or setup. | -| `regionHighlights` | `RegionHighlight[]` | Your own colour per face. | -| `candidates` | `string[]` | Other possible matches, faintly tinted by machining direction. | -| `pickedRegions` | `number[]` | Faces to mark as just clicked. | -| `hoveredFeatureIds` | `string[]` | Features to show as hovered, e.g. when hovering a list row. | -| `showEdges` | `boolean` (`true`) | Draw outlines between faces. | -| `theme` | `Partial` | Override the part's colours. | +| Prop | Type | What it does | +| ------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `selection` | `string[]` | Feature tags to highlight as selected (orange). | +| `highlights` | `FeatureHighlight[]` | Your own colour per feature, e.g. difficulty or setup. | +| `regionHighlights` | `RegionHighlight[]` | Your own colour per face. | +| `candidates` | `string[]` | Other possible matches, faintly tinted by machining direction. | +| `pickedRegions` | `number[]` | Faces to mark as just clicked. | +| `hoveredFeatureIds` | `string[]` | Features to show as hovered, e.g. when hovering a list row. | +| `showEdges` | `boolean` (`true`) | Draw outlines between faces. | +| `display` | `'solid' \| 'wireframe'` (`'solid'`) | Wireframe draws face boundaries without triangle diagonals. Painted and hovered faces remain visible. | +| `theme` | `Partial` | Override the part's colours. | **Interaction** diff --git a/packages/viewer/src/content-box.ts b/packages/viewer/src/content-box.ts index 7fcf21d..9e2a3dc 100644 --- a/packages/viewer/src/content-box.ts +++ b/packages/viewer/src/content-box.ts @@ -1,7 +1,7 @@ import { useFrame, useThree } from '@react-three/fiber' import { useRef, useState } from 'react' import { Box3 } from 'three' -import { contentBounds } from './render/camera.js' +import { partBounds } from './render/camera.js' /** * The bounds of the part, for the overlays that have to be sized against it. @@ -11,7 +11,7 @@ import { contentBounds } from './render/camera.js' * viewer's opening frame waits. Measured once: an overlay that re-fitted itself * while the part was being orbited would be a grid that breathes. * - * Scene furniture is excluded, so the grid and the axes do not size each other. + * Scene furniture and stock are excluded, so tools stay sized to the finished part. */ export function useContentBox(): Box3 { const scene = useThree((state) => state.scene) @@ -21,7 +21,7 @@ export function useContentBox(): Box3 { useFrame(() => { if (measured.current) return const next = new Box3() - contentBounds(scene, next) + partBounds(scene, next) if (next.isEmpty()) return measured.current = true setBox(next) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 22c90bb..8e7ee28 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -5,6 +5,11 @@ export { EnginePart, normalizePartReport, smoothRegionNormals } from './engine/index.js' export { regionAdjacency } from './render/adjacency.js' export { PartMesh } from './part-mesh.js' +export { Stock, BoxStock } from './stock.js' +export type { StockProps, BoxStockProps } from './stock.js' +export { boxStockBounds } from './render/stock.js' +export { directionHighlights } from './render/direction-highlights.js' +export type { PartDisplay } from './render/part.js' export { Axes, Grid, ViewCube } from './primitives.js' export { DirectionArrows } from './direction-arrows.js' export { SectionView, resolveSectionPlane } from './section-view.js' diff --git a/packages/viewer/src/part-mesh.tsx b/packages/viewer/src/part-mesh.tsx index e307dc0..746a431 100644 --- a/packages/viewer/src/part-mesh.tsx +++ b/packages/viewer/src/part-mesh.tsx @@ -22,7 +22,7 @@ import { sectionOptionsFromState, } from './render/section.js' import { useTapGuard } from './tap.js' -import { createPart } from './render/part.js' +import { createPart, type PartDisplay } from './render/part.js' import { regionAdjacency } from './render/adjacency.js' import { type PartPick, buildPick, viewDirection } from './render/picking.js' import { trackDoubleTaps } from './render/tap.js' @@ -120,6 +120,8 @@ export interface PartMeshProps { onPick?: (pick: PartPick) => void theme?: Partial showEdges?: boolean + /** Wireframe shows region boundaries and any hovered/painted faces, with no triangle diagonals. */ + display?: PartDisplay } /** @@ -150,6 +152,7 @@ export const PartMesh = ({ onPick, theme, showEdges = true, + display = 'solid', }: PartMeshProps) => { const { camera, controls, invalidate } = useThree() const viewerControls = useViewerControls() @@ -231,9 +234,9 @@ export const PartMesh = ({ }, [part, repaint, resolved]) useLayoutEffect(() => { - part.edges.visible = showEdges + part.setDisplay(display, showEdges) invalidate() - }, [invalidate, part, showEdges]) + }, [display, invalidate, part, showEdges]) useLayoutEffect(() => { part.setClippingPlanes(cut ? [cut.plane] : null) diff --git a/packages/viewer/src/render/camera.ts b/packages/viewer/src/render/camera.ts index f71e5d2..1a9da54 100644 --- a/packages/viewer/src/render/camera.ts +++ b/packages/viewer/src/render/camera.ts @@ -18,6 +18,9 @@ export const DEFAULT_FIT_MARGIN = 1.2 /** Marks scene furniture — grid, axes — that the camera should not frame. */ export const EXCLUDE_FROM_FRAME = 'viewerExcludeFromFrame' +/** Stock is framed with the part, but is not a surface for tools or overlays. */ +export const STOCK_OBJECT = 'viewerStock' + /** * What the camera frames: a bounding *sphere*, not a box. * @@ -102,11 +105,20 @@ export function boundsFromBox(box: Box3): SceneBounds { * would be a speck. */ export function contentBounds(root: Object3D, into: Box3): SceneBounds { + return measureBounds(root, into, false) +} + +/** Bounds used by part-relative overlays, independent of stock visibility. */ +export function partBounds(root: Object3D, into: Box3): SceneBounds { + return measureBounds(root, into, true) +} + +function measureBounds(root: Object3D, into: Box3, partOnly: boolean): SceneBounds { into.makeEmpty() root.updateWorldMatrix(true, true) root.traverse((object) => { - if (excludedFromFrame(object, root)) return + if (partOnly ? excludedFromPart(object, root) : excludedFromFrame(object, root)) return if ('isMesh' in object || 'isLine' in object || 'isPoints' in object) { into.expandByObject(object) } @@ -115,6 +127,15 @@ export function contentBounds(root: Object3D, into: Box3): SceneBounds { return boundsFromBox(into) } +export function excludedFromPart(object: Object3D, root: Object3D): boolean { + let current: Object3D | null = object + while (current && current !== root) { + if (current.userData[STOCK_OBJECT]) return true + current = current.parent + } + return excludedFromFrame(object, root) +} + /** * Whether `object`, or anything between it and `root`, carries * {@link EXCLUDE_FROM_FRAME}. The flag is set on an overlay's outermost group diff --git a/packages/viewer/src/render/direction-highlights.ts b/packages/viewer/src/render/direction-highlights.ts new file mode 100644 index 0000000..6b51d30 --- /dev/null +++ b/packages/viewer/src/render/direction-highlights.ts @@ -0,0 +1,32 @@ +import { directionIndexOf } from '../model/directions.js' +import type { PartModel } from '../model/types.js' +import type { RegionHighlight } from './paint.js' +import { featureTypeRank } from './selection.js' +import { directionColor } from './theme.js' + +/** + * A stable direction wash, optionally scoped to one candidate direction. + * Shared faces take the most specific owner, then candidate order and tag. + * Unknown directions remain unpainted; no invented machining direction. + */ +export function directionHighlights( + model: PartModel, + activeDirection: number | null = null, +): readonly RegionHighlight[] { + const features = model.features + .map((feature) => ({ feature, index: directionIndexOf(model, feature.machiningDirection) })) + .filter(({ index }) => index >= 0 && (activeDirection === null || index === activeDirection)) + .sort( + (a, b) => + featureTypeRank(a.feature.featureType) - featureTypeRank(b.feature.featureType) || + a.index - b.index || + (a.feature.tag < b.feature.tag ? -1 : a.feature.tag > b.feature.tag ? 1 : 0), + ) + const colors = new Map() + for (const { feature, index } of features) { + for (const region of model.regionIndex.regionsForFeature(feature.tag)) { + if (!colors.has(region)) colors.set(region, { region, color: directionColor(index) }) + } + } + return [...colors.values()].sort((a, b) => a.region - b.region) +} diff --git a/packages/viewer/src/render/part.ts b/packages/viewer/src/render/part.ts index 9cb72da..8e11ce3 100644 --- a/packages/viewer/src/render/part.ts +++ b/packages/viewer/src/render/part.ts @@ -24,6 +24,8 @@ export const REGION_ATTRIBUTE = 'aRegion' /** How much of a painted region's color also lights it from within. */ const EMISSIVE_MIX = 0.4 +export type PartDisplay = 'solid' | 'wireframe' + /** * A part on screen: one mesh, one draw call, one material. * @@ -61,6 +63,8 @@ export interface PartObject { */ setClippingPlanes(planes: readonly Plane[] | null): void setTheme(theme: ViewerTheme): void + /** Semantic edges only in wireframe; painted faces remain visible for interaction. */ + setDisplay(display: PartDisplay, showEdges?: boolean): void dispose(): void } @@ -174,8 +178,12 @@ export function createPart( polygonOffsetUnits: 1, }) + const wireframe = { value: false } + let currentTheme = theme + material.onBeforeCompile = (shader) => { shader.uniforms['uRegionState'] = { value: stateTexture } + shader.uniforms['uWireframe'] = wireframe shader.vertexShader = shader.vertexShader .replace( @@ -195,6 +203,7 @@ export function createPart( '#include ', `#include uniform sampler2D uRegionState; + uniform bool uWireframe; varying float vRegion; vec4 regionState;`, ) @@ -206,6 +215,7 @@ export function createPart( `#include regionState = texelFetch(uRegionState, ivec2(int(vRegion + 0.5), 0), 0); diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a); + if (uWireframe) diffuseColor.a *= regionState.a; `, ) .replace( @@ -321,10 +331,24 @@ export function createPart( }, setTheme(next) { + currentTheme = next material.color.setHex(next.part) material.emissive.setHex(next.partEmissive) - edgeMaterial.color.setHex(next.edge) - edgeMaterial.opacity = next.edgeOpacity + edgeMaterial.color.setHex(wireframe.value ? next.part : next.edge) + edgeMaterial.opacity = wireframe.value ? 1 : next.edgeOpacity + }, + + setDisplay(display, showEdges = true) { + const enabled = display === 'wireframe' + if (wireframe.value !== enabled) { + wireframe.value = enabled + material.transparent = enabled + material.depthWrite = !enabled + material.needsUpdate = true + } + edges.visible = enabled || showEdges + edgeMaterial.color.setHex(enabled ? currentTheme.part : currentTheme.edge) + edgeMaterial.opacity = enabled ? 1 : currentTheme.edgeOpacity }, dispose() { diff --git a/packages/viewer/src/render/section.ts b/packages/viewer/src/render/section.ts index 6695a43..371dd9a 100644 --- a/packages/viewer/src/render/section.ts +++ b/packages/viewer/src/render/section.ts @@ -1,6 +1,6 @@ import { type Box3, type Intersection, type Object3D, Plane, type Raycaster, Vector3 } from 'three' import type { Vec3 } from '../model/types.js' -import { excludedFromFrame } from './camera.js' +import { excludedFromPart } from './camera.js' /** * Render order. The stencil pass must precede the cap, and the part must draw @@ -185,9 +185,9 @@ export interface SurfaceHit { * * "The part" is whatever in the scene is a visible mesh outside an overlay — * every overlay here marks its outermost group with `EXCLUDE_FROM_FRAME`, the - * same flag that keeps it out of the camera's framing, and the ones that are - * not clickable turn their own raycast off besides. What is left is the - * geometry the consumer put in. + * same flag that keeps it out of the camera's framing. Stock has its own flag: + * it belongs in Fit, but not in section or measurement picks. Non-clickable + * overlays also turn their own raycast off. * * A surface a section cut has clipped away is skipped too. three's raycaster * knows nothing about clipping planes, so without this a ray through the open @@ -199,7 +199,7 @@ export function hitUnderRay(raycaster: Raycaster, root: Object3D): Intersection if (!('isMesh' in hit.object) || !hit.face) continue // three's raycaster does not skip hidden objects; R3F's event layer does // that itself, and this ray is not R3F's. - if (!hit.object.visible || excludedFromFrame(hit.object, root)) continue + if (!hit.object.visible || excludedFromPart(hit.object, root)) continue if (clippedAway(hit)) continue return hit } diff --git a/packages/viewer/src/render/stock.ts b/packages/viewer/src/render/stock.ts new file mode 100644 index 0000000..25f9f5c --- /dev/null +++ b/packages/viewer/src/render/stock.ts @@ -0,0 +1,76 @@ +import { + Box3, + type BufferGeometry, + EdgesGeometry, + Group, + LineBasicMaterial, + LineSegments, + Mesh, + MeshLambertMaterial, + Vector3, +} from 'three' +import type { Vec3 } from '../model/types.js' +import { STOCK_OBJECT } from './camera.js' + +/** Per-side allowance and centre offset in millimetres, in the part's coordinates. */ +export function boxStockBounds( + geometry: BufferGeometry, + allowance: number | Vec3 = 0, + offset: Vec3 = { x: 0, y: 0, z: 0 }, +): Box3 { + const padding = + typeof allowance === 'number' + ? new Vector3(allowance, allowance, allowance) + : new Vector3(allowance.x, allowance.y, allowance.z) + if (padding.toArray().some((value) => !Number.isFinite(value) || value < 0)) { + throw new RangeError('Stock allowance must be finite and non-negative.') + } + if (![offset.x, offset.y, offset.z].every(Number.isFinite)) { + throw new RangeError('Stock offset must be finite.') + } + const position = geometry.getAttribute('position') + if (!position || position.count === 0) + throw new RangeError('Stock needs non-empty part geometry.') + const box = new Box3() + const point = new Vector3() + for (let index = 0; index < position.count; index += 1) { + point.fromBufferAttribute(position, index) + if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || !Number.isFinite(point.z)) { + throw new RangeError('Stock needs finite part coordinates.') + } + box.expandByPoint(point) + } + box.expandByVector(padding).translate(new Vector3(offset.x, offset.y, offset.z)) + const size = box.getSize(new Vector3()) + if (size.toArray().some((value) => !Number.isFinite(value) || value <= 0)) { + throw new RangeError('Stock dimensions must be finite and positive.') + } + return box +} + +/** Owns the stock materials and outline, never the caller's geometry. */ +export function createStock(geometry: BufferGeometry) { + const material = new MeshLambertMaterial({ transparent: true, depthWrite: false }) + const edgeMaterial = new LineBasicMaterial({ transparent: true, depthWrite: false }) + const mesh = new Mesh(geometry, material) + const edgeGeometry = new EdgesGeometry(geometry, 15) + const edges = new LineSegments(edgeGeometry, edgeMaterial) + const object = new Group() + object.userData[STOCK_OBJECT] = true + mesh.renderOrder = 5 + edges.renderOrder = 6 + mesh.raycast = () => {} + edges.raycast = () => {} + object.add(mesh, edges) + return { + object, + material, + edgeMaterial, + edges, + dispose() { + material.dispose() + edgeMaterial.dispose() + edgeGeometry.dispose() + }, + } +} diff --git a/packages/viewer/src/stock.tsx b/packages/viewer/src/stock.tsx new file mode 100644 index 0000000..9e0ae1a --- /dev/null +++ b/packages/viewer/src/stock.tsx @@ -0,0 +1,64 @@ +import { useThree } from '@react-three/fiber' +import { useEffect, useLayoutEffect, useMemo } from 'react' +import { type BufferGeometry, BoxGeometry, Vector3 } from 'three' +import type { Vec3 } from './model/types.js' +import { boxStockBounds, createStock } from './render/stock.js' + +export interface StockProps { + /** Caller-owned stock mesh in the same millimetre, Z-up coordinates as the part. */ + geometry: BufferGeometry + color?: number + opacity?: number + edgeColor?: number + edgeOpacity?: number + showEdges?: boolean +} + +/** Translucent stock, included in Fit but ignored by picking, sections and measurements. */ +export const Stock = ({ + geometry, + color = 0xb9cbe2, + opacity = 0.2, + edgeColor = 0xa8bdd8, + edgeOpacity = 0.75, + showEdges = true, +}: StockProps) => { + const invalidate = useThree((state) => state.invalidate) + const stock = useMemo(() => createStock(geometry), [geometry]) + useEffect(() => () => stock.dispose(), [stock]) + useLayoutEffect(() => { + stock.material.color.setHex(color) + stock.material.opacity = opacity + stock.edgeMaterial.color.setHex(edgeColor) + stock.edgeMaterial.opacity = edgeOpacity + stock.edges.visible = showEdges + invalidate() + }, [color, edgeColor, edgeOpacity, invalidate, opacity, showEdges, stock]) + return +} + +export interface BoxStockProps extends Omit { + partGeometry: BufferGeometry + /** Padding on each side, in millimetres. Defaults to zero. */ + allowance?: number | Vec3 + /** Translation from the part's bounding-box centre, in millimetres. */ + offset?: Vec3 +} + +/** An axis-aligned blank around the part. Use Stock for an arbitrary stock mesh. */ +export const BoxStock = ({ partGeometry, allowance = 0, offset, ...props }: BoxStockProps) => { + const x = typeof allowance === 'number' ? allowance : allowance.x + const y = typeof allowance === 'number' ? allowance : allowance.y + const z = typeof allowance === 'number' ? allowance : allowance.z + const ox = offset?.x ?? 0 + const oy = offset?.y ?? 0 + const oz = offset?.z ?? 0 + const geometry = useMemo(() => { + const box = boxStockBounds(partGeometry, { x, y, z }, { x: ox, y: oy, z: oz }) + const size = box.getSize(new Vector3()) + const center = box.getCenter(new Vector3()) + return new BoxGeometry(size.x, size.y, size.z).translate(center.x, center.y, center.z) + }, [ox, oy, oz, partGeometry, x, y, z]) + useEffect(() => () => geometry.dispose(), [geometry]) + return +} diff --git a/packages/viewer/tests/direction-highlights.test.ts b/packages/viewer/tests/direction-highlights.test.ts new file mode 100644 index 0000000..8fbde63 --- /dev/null +++ b/packages/viewer/tests/direction-highlights.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { directionHighlights } from '../src/render/direction-highlights.js' +import { directionColor } from '../src/render/theme.js' +import { cubeModel } from './fixtures.js' + +describe('directionHighlights', () => { + it('paints each shared region once and is independent of feature array order', () => { + const model = cubeModel() + const colors = directionHighlights(model) + expect(colors.length).toBeGreaterThan(0) + expect(new Set(colors.map((entry) => entry.region)).size).toBe(colors.length) + expect(directionHighlights({ ...model, features: [...model.features].reverse() })).toEqual( + colors, + ) + }) + + it('scopes the wash to reachable regions and uses the arrow palette', () => { + const model = cubeModel() + const colors = directionHighlights(model, 0) + expect(colors.length).toBeGreaterThan(0) + expect(colors.every((entry) => entry.color === directionColor(0))).toBe(true) + const direction = model.candidateDirections[0]! + const reachable = new Set( + model.features + .filter( + (feature) => + feature.machiningDirection.x === direction.x && + feature.machiningDirection.y === direction.y && + feature.machiningDirection.z === direction.z, + ) + .flatMap((feature) => feature.regionIdxs), + ) + expect(new Set(colors.map((entry) => entry.region))).toEqual(reachable) + expect(directionHighlights(model, 100)).toEqual([]) + }) + + it('does not invent a color for an unmatched machining direction', () => { + expect(directionHighlights({ ...cubeModel(), candidateDirections: [] })).toEqual([]) + }) + + it('prefers a specific feature to a profile sharing the same face', () => { + const model = cubeModel() + const face = model.features.find( + (feature) => feature.featureType === 'face' && feature.machiningDirection.z === 1, + )! + const index = model.candidateDirections.findIndex((direction) => direction.z === 1) + for (const region of face.regionIdxs) { + expect(directionHighlights(model).find((entry) => entry.region === region)?.color).toBe( + directionColor(index), + ) + } + }) +}) diff --git a/packages/viewer/tests/display.test.ts b/packages/viewer/tests/display.test.ts new file mode 100644 index 0000000..97f15ae --- /dev/null +++ b/packages/viewer/tests/display.test.ts @@ -0,0 +1,35 @@ +import { MeshLambertMaterial, Plane, Raycaster, Vector3 } from 'three' +import { describe, expect, it } from 'vitest' +import { parsePartGeometry } from '../src/engine/geometry.js' +import { createPart } from '../src/render/part.js' +import { hitUnderRay } from '../src/render/section.js' +import { DEFAULT_THEME } from '../src/render/theme.js' +import { cubeModel, loadMeshFixture } from './fixtures.js' + +describe('wireframe display', () => { + it('keeps semantic edges and pickable faces without rebuilding geometry', async () => { + const model = cubeModel() + const geometry = await parsePartGeometry(loadMeshFixture('local-0.3.0-cube'), model.mesh) + const part = createPart(model, geometry, DEFAULT_THEME) + const edges = part.edges.geometry + part.setDisplay('wireframe', false) + expect(part.edges.visible).toBe(true) + expect(edges.getAttribute('position').count / 2).toBe(12) + expect(part.mesh.geometry).toBe(geometry) + expect(part.mesh.visible).toBe(true) + const material = part.mesh.material as MeshLambertMaterial + expect(material.transparent).toBe(true) + expect(material.depthWrite).toBe(false) + part.object.updateMatrixWorld(true) + const ray = new Raycaster(new Vector3(0, 0, 100), new Vector3(0, 0, -1)) + expect(hitUnderRay(ray, part.object)?.object).toBe(part.mesh) + part.setClippingPlanes([new Plane(new Vector3(0, 0, -1), -1000)]) + expect(hitUnderRay(ray, part.object)).toBeNull() + part.setDisplay('solid', false) + expect(part.edges.visible).toBe(false) + expect(material.transparent).toBe(false) + expect(material.depthWrite).toBe(true) + expect(part.edges.geometry).toBe(edges) + part.dispose() + }) +}) diff --git a/packages/viewer/tests/stock.test.ts b/packages/viewer/tests/stock.test.ts new file mode 100644 index 0000000..bf9514f --- /dev/null +++ b/packages/viewer/tests/stock.test.ts @@ -0,0 +1,82 @@ +import { + Box3, + BoxGeometry, + BufferGeometry, + Float32BufferAttribute, + Group, + Mesh, + Raycaster, + Vector3, +} from 'three' +import { describe, expect, it, vi } from 'vitest' +import { contentBounds, partBounds } from '../src/render/camera.js' +import { hitUnderRay } from '../src/render/section.js' +import { boxStockBounds, createStock } from '../src/render/stock.js' + +describe('stock dimensions', () => { + it('adds allowance on both sides and offsets from an off-origin part centre', () => { + const geometry = new BoxGeometry(20, 30, 10).translate(40, -10, 5) + const positions = geometry.getAttribute('position').array.slice() + const stock = boxStockBounds(geometry, { x: 2, y: 3, z: 4 }, { x: 1, y: -2, z: 6 }) + expect(stock.getSize(new Vector3()).toArray()).toEqual([24, 36, 18]) + expect(stock.getCenter(new Vector3()).toArray()).toEqual([41, -12, 11]) + expect(geometry.getAttribute('position').array).toEqual(positions) + expect(geometry.boundingBox).toBeNull() + }) + + it('supports uniform allowance and a tight-fitting blank', () => { + const geometry = new BoxGeometry(20, 30, 10) + expect(boxStockBounds(geometry, 3).getSize(new Vector3()).toArray()).toEqual([26, 36, 16]) + expect(boxStockBounds(geometry).getSize(new Vector3()).toArray()).toEqual([20, 30, 10]) + }) + + it('rejects invalid inputs before they can produce NaN geometry', () => { + const geometry = new BoxGeometry(20, 30, 10) + for (const value of [-1, NaN, Infinity]) { + expect(() => boxStockBounds(geometry, value)).toThrow(RangeError) + } + expect(() => boxStockBounds(geometry, 0, { x: NaN, y: 0, z: 0 })).toThrow(RangeError) + expect(() => boxStockBounds(new BufferGeometry())).toThrow(RangeError) + const point = new BufferGeometry().setAttribute( + 'position', + new Float32BufferAttribute([0, 0, 0], 3), + ) + expect(() => boxStockBounds(point)).toThrow(RangeError) + expect(boxStockBounds(point, 1).getSize(new Vector3()).toArray()).toEqual([2, 2, 2]) + }) +}) + +describe('stock scene integration', () => { + it('frames stock without changing part-relative bounds or intercepting tool rays', () => { + const root = new Group() + const part = new Mesh(new BoxGeometry(10, 10, 10)) + const stock = createStock(new BoxGeometry(30, 40, 50)) + root.add(part, stock.object) + const framed = new Box3() + const finished = new Box3() + contentBounds(root, framed) + partBounds(root, finished) + expect(framed.getSize(new Vector3()).toArray()).toEqual([30, 40, 50]) + expect(finished.getSize(new Vector3()).toArray()).toEqual([10, 10, 10]) + const ray = new Raycaster(new Vector3(0, 0, 100), new Vector3(0, 0, -1)) + expect(hitUnderRay(ray, root)?.object).toBe(part) + expect(hitUnderRay(ray, root)?.point.z).toBe(5) + root.remove(stock.object) + expect(contentBounds(root, new Box3()).radius).toBe(partBounds(root, new Box3()).radius) + stock.dispose() + }) + + it('disposes owned GPU resources while leaving caller geometry reusable', () => { + const geometry = new BoxGeometry(10, 10, 10) + const stock = createStock(geometry) + const inputDisposed = vi.spyOn(geometry, 'dispose') + const edgesDisposed = vi.spyOn(stock.edges.geometry, 'dispose') + const materialDisposed = vi.spyOn(stock.material, 'dispose') + const edgeMaterialDisposed = vi.spyOn(stock.edgeMaterial, 'dispose') + stock.dispose() + expect(inputDisposed).not.toHaveBeenCalled() + expect(edgesDisposed).toHaveBeenCalledOnce() + expect(materialDisposed).toHaveBeenCalledOnce() + expect(edgeMaterialDisposed).toHaveBeenCalledOnce() + }) +}) From ec853b83e36f8d506b87965fdef8517d430bb6ee Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:30:50 -0400 Subject: [PATCH 02/16] Add xray feature focus --- .changeset/viewer-xray-focus.md | 5 ++ examples/react-viewer/src/main.tsx | 4 + examples/react-viewer/src/viewer-toolbar.tsx | 9 +++ examples/react-viewer/tests/toolbar.spec.ts | 18 +++++ packages/viewer/README.md | 12 ++- packages/viewer/src/index.ts | 1 + packages/viewer/src/part-mesh.tsx | 13 ++++ packages/viewer/src/render/focus.ts | 27 +++++++ packages/viewer/src/render/part.ts | 54 +++++++++++++- packages/viewer/tests/focus.test.ts | 77 ++++++++++++++++++++ 10 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 .changeset/viewer-xray-focus.md create mode 100644 packages/viewer/src/render/focus.ts create mode 100644 packages/viewer/tests/focus.test.ts diff --git a/.changeset/viewer-xray-focus.md b/.changeset/viewer-xray-focus.md new file mode 100644 index 0000000..20a8ba6 --- /dev/null +++ b/.changeset/viewer-xray-focus.md @@ -0,0 +1,5 @@ +--- +'@toolpath/viewer': minor +--- + +Add opt-in selection-driven X-ray focus rendering for part meshes. diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index a747a33..6c03f78 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -147,6 +147,7 @@ const App = () => { const [showAxes, setShowAxes] = useState(true) const [showGrid, setShowGrid] = useState(true) const [showDirections, setShowDirections] = useState(false) + const [focus, setFocus] = useState(false) const [wireframe, setWireframe] = useState(false) const [allowance, setAllowance] = useState(3) const stockSize = useMemo( @@ -277,6 +278,7 @@ const App = () => { axes={showAxes} grid={showGrid} directions={showDirections} + focus={focus} wireframe={wireframe} sectioning={sectioning} measuring={measuring} @@ -293,6 +295,7 @@ const App = () => { heldSelection.current = [] setWireframe(false) }} + onFocus={() => setFocus((enabled) => !enabled)} onWireframe={() => { setWireframe((on) => !on) setShowDirections(false) @@ -440,6 +443,7 @@ const App = () => { model={part.model} geometry={part.geometry} selection={selected} + focus={focus ? {} : undefined} display={wireframe ? 'wireframe' : 'solid'} regionHighlights={highlights} activeDirection={showDirections ? direction : null} diff --git a/examples/react-viewer/src/viewer-toolbar.tsx b/examples/react-viewer/src/viewer-toolbar.tsx index 277de5f..96be43e 100644 --- a/examples/react-viewer/src/viewer-toolbar.tsx +++ b/examples/react-viewer/src/viewer-toolbar.tsx @@ -8,6 +8,7 @@ const paths = { axes: 'M5 19V3m0 16h16M5 19l10-10M2 6l3-3 3 3m10 10 3 3-3 3M11 9h4v4', grid: 'M3 8l9-5 9 5-9 5zM3 8v8l9 5 9-5V8M12 13v8M7.5 5.5v8M16.5 5.5v8', directions: 'M7 3v18m-4-4 4 4 4-4M17 21V3m-4 4 4-4 4 4', + focus: 'M4 4h16v16H4zM8 8h8v8H8zM2 12h4m12 0h4M12 2v4m0 12v4', wireframe: 'M4 7l8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 11v10M4 17l8-4 8 4M12 3v10', section: 'M3 17L17 3l4 4L7 21zM4 6l2-2m3 3 2-2m2 6 2-2m2 6 2-2', measure: 'M3 16L16 3l5 5L8 21zM8 11l3 3m1-7 3 3m-11 5 3 3', @@ -44,6 +45,7 @@ interface ViewerToolbarProps { axes: boolean grid: boolean directions: boolean + focus: boolean wireframe: boolean sectioning: boolean measuring: boolean @@ -54,6 +56,7 @@ interface ViewerToolbarProps { onAxes: () => void onGrid: () => void onDirections: () => void + onFocus: () => void onWireframe: () => void onSection: () => void onMeasure: () => void @@ -89,6 +92,12 @@ export const ViewerToolbar = (props: ViewerToolbarProps) => ( pressed={props.directions} onClick={props.onDirections} /> + { + const { canvas, box } = await openViewer(page, `projection=${projection}`) + await canvas.click({ position: on(box, { x: 0.5, y: 0.5 }) }) + await expect(page.locator('p', { hasText: 'Selected:' })).not.toContainText('none') + const selected = await canvas.screenshot() + const focus = page.getByRole('button', { name: 'Focus selection', exact: true }) + + await focus.click() + + await expect(page.getByRole('button', { name: 'Show full part', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect.poll(async () => Buffer.compare(selected, await canvas.screenshot())).not.toBe(0) + await page.getByRole('button', { name: 'Show full part', exact: true }).click() + await expect(focus).toHaveAttribute('aria-pressed', 'false') + }) + test(`stock present on mount does not move the section midpoint (${projection})`, async ({ page, }) => { diff --git a/packages/viewer/README.md b/packages/viewer/README.md index 7a31ca3..f27af1c 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -96,6 +96,16 @@ What each wrapper is for: 4. **You own the state.** The viewer tells you what was clicked (`onPick`), and you tell it what to highlight (`selection`, `highlights`, …). It never changes your selection by itself. +### Focus the current selection + +Pass `focus` to make selected features solid while the remainder of the part becomes translucent. +The default outside opacity is 15%; set `opacity` for a different X-ray strength. With no selected +features, the part stays fully opaque. + +```tsx + +``` + Parts are in **millimetres** with **Z pointing up**. The camera uses the same convention. ### A click usually matches several features @@ -999,7 +1009,7 @@ use. They're listed in `dist/index.d.ts`. `ViewerProps`, `ViewerHandle`, `ViewerView`, `Projection`, `ControlScheme`, `EnginePartProps`, `PartMeshProps`, `PartPick`, `PickModifiers`, `PartModel`, `PartModelFeature`, `PartModelRegion`, -`FeatureTag`, `FeatureType`, `Vec3`, `FeatureHighlight`, `RegionHighlight`, `SectionOptions`, +`FeatureTag`, `FeatureType`, `Vec3`, `FeatureHighlight`, `RegionHighlight`, `FocusOptions`, `SectionOptions`, `SectionState`, `SectionPlacement`, `SectionToolProps`, `SectionStore`, `MeasureToolProps`, `MeasureMode`, `Measurement`, `DistanceMeasurement`, `AngleMeasurement`, `Snap`, `SnapKind`, `ViewerTheme`, `ViewName`, `DirectionArrowsProps`, `NamedDirection`, `GridProps`, `AxesProps`, diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8e7ee28..c428d0a 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -205,6 +205,7 @@ export type { SurfaceOf } from './model/surfaces.js' export type { RankingContext } from './render/selection.js' export type { PartObject, RegionPaint } from './render/part.js' export type { FeatureHighlight, HighlightLayers, RegionHighlight } from './render/paint.js' +export type { FocusOptions } from './render/focus.js' export type { ViewerTheme } from './render/theme.js' export type { PartMeshProps } from './part-mesh.js' export type { AxesProps, GridProps, ViewCubeProps } from './primitives.js' diff --git a/packages/viewer/src/part-mesh.tsx b/packages/viewer/src/part-mesh.tsx index 746a431..b5b5365 100644 --- a/packages/viewer/src/part-mesh.tsx +++ b/packages/viewer/src/part-mesh.tsx @@ -12,6 +12,7 @@ import { type BufferGeometry, Vector3 } from 'three' import type { FeatureTag, PartModel } from './model/types.js' import type { FeatureHighlight, RegionHighlight } from './render/paint.js' import { applyHighlightLayers } from './render/paint.js' +import type { FocusOptions } from './render/focus.js' import { DISABLED_SECTION, type SectionOptions, @@ -46,6 +47,11 @@ export interface PartMeshProps { * one-to-many and no scoping rule fixes that. */ selection?: readonly FeatureTag[] + /** + * Makes selected feature regions solid and the rest of the part translucent. + * Omit it for the normal solid view. The application still owns selection. + */ + focus?: FocusOptions /** * Every feature a click could have meant, painted faintly in each one's own * direction colour, under the selection. @@ -138,6 +144,7 @@ export const PartMesh = ({ model, geometry, selection = [], + focus, candidates = [], highlights = [], regionHighlights = [], @@ -233,6 +240,12 @@ export const PartMesh = ({ repaint() }, [part, repaint, resolved]) + const focusKey = `${focus === undefined ? '' : (focus.opacity ?? '')}|${selection.join(' ')}` + useLayoutEffect(() => { + part.setFocus(selection, focus) + invalidate() + }, [focusKey, focus, invalidate, part, selection]) + useLayoutEffect(() => { part.setDisplay(display, showEdges) invalidate() diff --git a/packages/viewer/src/render/focus.ts b/packages/viewer/src/render/focus.ts new file mode 100644 index 0000000..70590ff --- /dev/null +++ b/packages/viewer/src/render/focus.ts @@ -0,0 +1,27 @@ +import type { FeatureTag, PartModel } from '../model/types.js' + +/** The transparency used for geometry outside a focused feature by default. */ +export const DEFAULT_FOCUS_OPACITY = 0.15 + +/** Enables selection-driven X-ray rendering on a part. */ +export interface FocusOptions { + /** Opacity for regions outside the current selection, from 0 through 1. */ + readonly opacity?: number +} + +/** Clamps an application-supplied X-ray opacity to the range a material accepts. */ +export function focusOpacity(options: FocusOptions | undefined): number { + return Math.min(Math.max(options?.opacity ?? DEFAULT_FOCUS_OPACITY, 0), 1) +} + +/** The union of regions owned by the selected features. */ +export function focusedRegions( + model: PartModel, + selection: readonly FeatureTag[], +): ReadonlySet { + const regions = new Set() + for (const tag of selection) { + for (const region of model.regionIndex.regionsForFeature(tag)) regions.add(region) + } + return regions +} diff --git a/packages/viewer/src/render/part.ts b/packages/viewer/src/render/part.ts index 8e11ce3..cf7a68e 100644 --- a/packages/viewer/src/render/part.ts +++ b/packages/viewer/src/render/part.ts @@ -10,12 +10,14 @@ import { Mesh, MeshLambertMaterial, type Plane, + RedFormat, RGBAFormat, UnsignedByteType, Vector3, } from 'three' import type { FeatureTag, PartModel } from '../model/types.js' import { regionEdgesGeometry } from './edges.js' +import { focusOpacity, focusedRegions, type FocusOptions } from './focus.js' import type { ViewerTheme } from './theme.js' /** The vertex attribute carrying each vertex's column in the state texture. */ @@ -54,6 +56,10 @@ export interface PartObject { /** Paints every region the feature explicitly owns. */ paintFeature(tag: FeatureTag, color: number, weight: number): void clearPaint(): void + /** Makes selected features solid and the rest of the part translucent. */ + setFocus(selection: readonly FeatureTag[], focus?: FocusOptions): void + /** The current opacity of one region, or `null` if it does not exist. */ + regionOpacity(region: number): number | null /** A feature's bounds in part space, for framing. `null` if it has none. */ boxForFeature(tag: FeatureTag): Box3 | null /** @@ -167,6 +173,9 @@ export function createPart( const state = new Uint8Array(width * 4) const stateTexture = new DataTexture(state, width, 1, RGBAFormat, UnsignedByteType) stateTexture.needsUpdate = true + const opacity = new Uint8Array(width).fill(255) + const opacityTexture = new DataTexture(opacity, width, 1, RedFormat, UnsignedByteType) + opacityTexture.needsUpdate = true const material = new MeshLambertMaterial({ color: theme.part, @@ -179,10 +188,20 @@ export function createPart( }) const wireframe = { value: false } + let focused = false let currentTheme = theme + const syncTransparency = () => { + const transparent = wireframe.value || focused + if (material.transparent === transparent && material.depthWrite === !transparent) return + material.transparent = transparent + material.depthWrite = !transparent + material.needsUpdate = true + } + material.onBeforeCompile = (shader) => { shader.uniforms['uRegionState'] = { value: stateTexture } + shader.uniforms['uRegionOpacity'] = { value: opacityTexture } shader.uniforms['uWireframe'] = wireframe shader.vertexShader = shader.vertexShader @@ -203,6 +222,7 @@ export function createPart( '#include ', `#include uniform sampler2D uRegionState; + uniform sampler2D uRegionOpacity; uniform bool uWireframe; varying float vRegion; vec4 regionState;`, @@ -215,6 +235,7 @@ export function createPart( `#include regionState = texelFetch(uRegionState, ivec2(int(vRegion + 0.5), 0), 0); diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a); + diffuseColor.a *= texelFetch(uRegionOpacity, ivec2(int(vRegion + 0.5), 0), 0).r; if (uWireframe) diffuseColor.a *= regionState.a; `, ) @@ -302,6 +323,34 @@ export function createPart( stateTexture.needsUpdate = true }, + setFocus(selection, focus) { + const regions = focus === undefined ? null : focusedRegions(model, selection) + const outside = focusOpacity(focus) + let changed = false + let hasTransparency = false + + for (const [region, column] of texels) { + const value = + regions === null || regions.size === 0 || regions.has(region) + ? 255 + : Math.round(outside * 255) + if (opacity[column] !== value) { + opacity[column] = value + changed = true + } + hasTransparency ||= value < 255 + } + + focused = hasTransparency + syncTransparency() + if (changed) opacityTexture.needsUpdate = true + }, + + regionOpacity(region) { + const column = texels.get(region) + return column === undefined ? null : (opacity[column] ?? 0) / 255 + }, + boxForFeature(tag) { const regions = model.regionIndex.regionsForFeature(tag) if (regions.length === 0) return null @@ -342,9 +391,7 @@ export function createPart( const enabled = display === 'wireframe' if (wireframe.value !== enabled) { wireframe.value = enabled - material.transparent = enabled - material.depthWrite = !enabled - material.needsUpdate = true + syncTransparency() } edges.visible = enabled || showEdges edgeMaterial.color.setHex(enabled ? currentTheme.part : currentTheme.edge) @@ -362,6 +409,7 @@ export function createPart( edgeGeometry.dispose() edgeMaterial.dispose() stateTexture.dispose() + opacityTexture.dispose() }, } } diff --git a/packages/viewer/tests/focus.test.ts b/packages/viewer/tests/focus.test.ts new file mode 100644 index 0000000..cdde203 --- /dev/null +++ b/packages/viewer/tests/focus.test.ts @@ -0,0 +1,77 @@ +import { MeshLambertMaterial } from 'three' +import { describe, expect, it } from 'vitest' +import { parsePartGeometry } from '../src/engine/geometry.js' +import { DEFAULT_FOCUS_OPACITY } from '../src/render/focus.js' +import { createPart } from '../src/render/part.js' +import { DEFAULT_THEME } from '../src/render/theme.js' +import { cubeModel, loadMeshFixture } from './fixtures.js' + +async function loadCube() { + const model = cubeModel() + const geometry = await parsePartGeometry(loadMeshFixture('local-0.3.0-cube'), model.mesh) + return { model, part: createPart(model, geometry, DEFAULT_THEME) } +} + +function faceOn(model: ReturnType, z: 1 | -1) { + const face = model.features.find( + (feature) => feature.featureType === 'face' && feature.machiningDirection.z === z, + ) + if (!face) throw new Error('The cube fixture should have a face on each of ±Z.') + return { tag: face.tag, region: model.regionIndex.regionsForFeature(face.tag)[0]! } +} + +describe('selection-driven focus', () => { + it('keeps selected regions solid and fades every other region', async () => { + const { model, part } = await loadCube() + const top = faceOn(model, 1) + const bottom = faceOn(model, -1) + + part.setFocus([top.tag], {}) + + expect(part.regionOpacity(top.region)).toBe(1) + expect(part.regionOpacity(bottom.region)).toBeCloseTo(DEFAULT_FOCUS_OPACITY, 2) + const material = part.mesh.material as MeshLambertMaterial + expect(material.transparent).toBe(true) + expect(material.depthWrite).toBe(false) + }) + + it('returns to a fully opaque part when there is no selected feature', async () => { + const { model, part } = await loadCube() + const top = faceOn(model, 1) + const bottom = faceOn(model, -1) + + part.setFocus([top.tag], { opacity: 0.4 }) + part.setFocus([], { opacity: 0.4 }) + + expect(part.regionOpacity(top.region)).toBe(1) + expect(part.regionOpacity(bottom.region)).toBe(1) + const material = part.mesh.material as MeshLambertMaterial + expect(material.transparent).toBe(false) + expect(material.depthWrite).toBe(true) + }) + + it('clamps a caller-supplied X-ray opacity', async () => { + const { model, part } = await loadCube() + const top = faceOn(model, 1) + const bottom = faceOn(model, -1) + + part.setFocus([top.tag], { opacity: -10 }) + expect(part.regionOpacity(bottom.region)).toBe(0) + + part.setFocus([top.tag], { opacity: 10 }) + expect(part.regionOpacity(bottom.region)).toBe(1) + }) + + it('keeps X-ray transparency when wireframe is turned back off', async () => { + const { model, part } = await loadCube() + const top = faceOn(model, 1) + const material = part.mesh.material as MeshLambertMaterial + + part.setFocus([top.tag], {}) + part.setDisplay('wireframe') + part.setDisplay('solid') + + expect(material.transparent).toBe(true) + expect(material.depthWrite).toBe(false) + }) +}) From 649db229edb3d16522296a4cb193d065a3caebb1 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:50:50 -0400 Subject: [PATCH 03/16] Add feature hover --- .changeset/viewer-hover-card.md | 6 +++ examples/react-viewer/src/main.tsx | 55 +++++++++++++++++++- examples/react-viewer/src/style.css | 47 +++++++++++++++++ examples/react-viewer/src/viewer-toolbar.tsx | 9 ++++ examples/react-viewer/tests/viewer.spec.ts | 31 +++++++++++ packages/viewer/README.md | 51 +++++++++++------- packages/viewer/src/hover-card.tsx | 42 +++++++++++++++ packages/viewer/src/index.ts | 4 +- packages/viewer/src/part-mesh.tsx | 25 +++++++-- packages/viewer/src/render/picking.ts | 10 ++++ packages/viewer/tests/picking.test.ts | 6 +++ 11 files changed, 262 insertions(+), 24 deletions(-) create mode 100644 .changeset/viewer-hover-card.md create mode 100644 packages/viewer/src/hover-card.tsx diff --git a/.changeset/viewer-hover-card.md b/.changeset/viewer-hover-card.md new file mode 100644 index 0000000..a07cf77 --- /dev/null +++ b/.changeset/viewer-hover-card.md @@ -0,0 +1,6 @@ +--- +'@toolpath/viewer': minor +--- + +Add a cursor-following hover-card primitive, browser pointer coordinates on part picks, and a +hover toggle that preserves face picking. diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index 6c03f78..f7006f7 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -10,6 +10,7 @@ import { directionLabel, directionColor, Grid, + HoverCard, DirectionArrows, ViewCube, MeasureTool, @@ -19,6 +20,7 @@ import { measurementLabel, type MeasureMode, type Measurement, + type PartModel, type PartPick, type Projection, type SectionOptions, @@ -117,10 +119,51 @@ const CameraReadout = ({ onChange }: { onChange: (state: CameraState) => void }) */ const DETAIL = new THREE.Box3(new THREE.Vector3(-1, -1, 11.7), new THREE.Vector3(1, 1, 13.7)) +const featureLabel = (featureType: string) => + featureType + .split('_') + .map((word) => word[0]?.toUpperCase() + word.slice(1)) + .join(' ') + +/** What every normalized part report can say without a DFM datasheet. */ +const HoverDetails = ({ pick, model }: { pick: PartPick; model: PartModel }) => { + const feature = pick.best ? model.features.find(({ tag }) => tag === pick.best) : undefined + const region = model.regions.find(({ idx }) => idx === pick.region) + const direction = feature + ? model.candidateDirections.findIndex( + (candidate) => + candidate.x === feature.machiningDirection.x && + candidate.y === feature.machiningDirection.y && + candidate.z === feature.machiningDirection.z, + ) + : -1 + + return ( + <> +

Feature

+ {feature ? featureLabel(feature.featureType) : 'Shared surface'} +
+
+
Surface area
+
{region ? `${region.area.toFixed(1)} mm²` : 'Unknown'}
+
+
+
Machining direction
+
+ {direction >= 0 ? directionLabel(model.candidateDirections[direction]) : 'Unknown'} +
+
+
+ + ) +} + const App = () => { const [part, setPart] = useState(startingModel) const viewerRef = useRef(null) const [hovered, setHovered] = useState([]) + const [hoverPick, setHoverPick] = useState(null) + const [featureHover, setFeatureHover] = useState(true) const [selected, setSelected] = useState([]) // The selection put down on entering section mode, to pick up again on the // way out. A ref rather than state: nothing renders from it. @@ -187,6 +230,7 @@ const App = () => { setMeasured([]) setSelected([]) setHovered([]) + setHoverPick(null) heldSelection.current = [] setDirection(null) }} @@ -273,11 +317,15 @@ const App = () => {

+ + {(pick) => } + { heldSelection.current = [] setWireframe(false) }} + onHover={() => setFeatureHover((enabled) => !enabled)} onFocus={() => setFocus((enabled) => !enabled)} onWireframe={() => { setWireframe((on) => !on) @@ -446,12 +495,16 @@ const App = () => { focus={focus ? {} : undefined} display={wireframe ? 'wireframe' : 'solid'} regionHighlights={highlights} + hover={featureHover} activeDirection={showDirections ? direction : null} onSectionChange={(state) => { setCut(state.enabled ? state : null) if (state.enabled) setOffset(state.offset) }} - onHover={(pick: PartPick | null) => setHovered(pick ? [...pick.owners] : [])} + onHover={(pick: PartPick | null) => { + setHovered(pick ? [...pick.owners] : []) + setHoverPick(pick) + }} onPick={(pick: PartPick) => setSelected([...pick.ranked])} /> {showStock ? : null} diff --git a/examples/react-viewer/src/style.css b/examples/react-viewer/src/style.css index d85d3ae..15e23a3 100644 --- a/examples/react-viewer/src/style.css +++ b/examples/react-viewer/src/style.css @@ -63,6 +63,53 @@ strong { border-radius: 1rem; background: radial-gradient(circle at 40% 30%, #283347, #171a24 65%); } +.viewer-hover-card { + display: grid; + gap: 0.45rem; + min-width: 13.5rem; + max-width: 18rem; + padding: 0.7rem 0.8rem; + border: 1px solid #526078; + border-radius: 0.4rem; + background: #101522ee; + color: #b9c0d1; + pointer-events: none; + box-shadow: 0 3px 14px #0005; + font-size: 0.8rem; +} +.viewer-hover-card strong { + font-size: 0.95rem; +} +.viewer-hover-eyebrow { + margin: 0; + color: #83a9ff; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.viewer-hover-card dl { + display: grid; + gap: 0.3rem; + margin: 0.1rem 0 0; +} +.viewer-hover-card dl div { + display: flex; + justify-content: space-between; + gap: 1rem; +} +.viewer-hover-card dt, +.viewer-hover-card dd { + margin: 0; +} +.viewer-hover-card dt { + color: #9ba4b8; +} +.viewer-hover-card dd { + color: #e8eaf0; + font-weight: 600; + text-align: right; +} .viewer-toolbar-stack { position: absolute; bottom: 1.25rem; diff --git a/examples/react-viewer/src/viewer-toolbar.tsx b/examples/react-viewer/src/viewer-toolbar.tsx index 96be43e..b648720 100644 --- a/examples/react-viewer/src/viewer-toolbar.tsx +++ b/examples/react-viewer/src/viewer-toolbar.tsx @@ -8,6 +8,7 @@ const paths = { axes: 'M5 19V3m0 16h16M5 19l10-10M2 6l3-3 3 3m10 10 3 3-3 3M11 9h4v4', grid: 'M3 8l9-5 9 5-9 5zM3 8v8l9 5 9-5V8M12 13v8M7.5 5.5v8M16.5 5.5v8', directions: 'M7 3v18m-4-4 4 4 4-4M17 21V3m-4 4 4-4 4 4', + hover: 'M4 4h16v12H9l-5 4zm4 5h8m-8 3h5', focus: 'M4 4h16v16H4zM8 8h8v8H8zM2 12h4m12 0h4M12 2v4m0 12v4', wireframe: 'M4 7l8-4 8 4v10l-8 4-8-4zM4 7l8 4 8-4M12 11v10M4 17l8-4 8 4M12 3v10', section: 'M3 17L17 3l4 4L7 21zM4 6l2-2m3 3 2-2m2 6 2-2m2 6 2-2', @@ -45,6 +46,7 @@ interface ViewerToolbarProps { axes: boolean grid: boolean directions: boolean + hover: boolean focus: boolean wireframe: boolean sectioning: boolean @@ -56,6 +58,7 @@ interface ViewerToolbarProps { onAxes: () => void onGrid: () => void onDirections: () => void + onHover: () => void onFocus: () => void onWireframe: () => void onSection: () => void @@ -92,6 +95,12 @@ export const ViewerToolbar = (props: ViewerToolbarProps) => ( pressed={props.directions} onClick={props.onDirections} /> + { + const { canvas, box } = await openViewer(page) + + await canvas.hover({ position: on(box, CENTRE) }) + await expect(page.getByRole('tooltip')).toContainText('Feature') + await expect(page.getByRole('tooltip')).toContainText('Surface area') + await page.mouse.move(box.x + 5, box.y + 5) + await expect(page.getByRole('tooltip')).toHaveCount(0) +}) + +test('feature hover can be toggled without disabling face picks', async ({ page }) => { + const { canvas, box } = await openViewer(page) + const toggle = page.getByRole('button', { name: 'Disable feature hover' }) + const selected = page.locator('p', { hasText: 'Selected:' }) + + await toggle.click() + await expect(page.getByRole('button', { name: 'Enable feature hover' })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + await canvas.hover({ position: on(box, CENTRE) }) + await expect(page.getByRole('tooltip')).toHaveCount(0) + + await canvas.click({ position: on(box, CENTRE) }) + await expect(selected).toContainText('back-face') + + await page.getByRole('button', { name: 'Enable feature hover' }).click() + await canvas.hover({ position: on(box, CENTRE) }) + await expect(page.getByRole('tooltip')).toContainText('Feature') +}) + test('selects a feature and responds to CAD camera navigation', async ({ page }) => { const { canvas, box } = await openViewer(page) diff --git a/packages/viewer/README.md b/packages/viewer/README.md index f27af1c..95c1eaa 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -114,21 +114,33 @@ The same face is usually owned by **5–8 features at once**, even on a plain cu `face` when cut from one direction, a `wall` when cut from another, and part of every `profile` around it. So a click gives you every match, and you decide which to use: -| Field | What it contains | -| ---------------- | ------------------------------------------------------------------- | -| `pick.best` | The most likely feature, or `null` | -| `pick.ranked` | Every matching feature, most likely first | -| `pick.owners` | Every matching feature, in report order | -| `pick.region` | The index of the face that was clicked | -| `pick.point` | Where the click hit, as `[x, y, z]` | -| `pick.normal` | The direction the clicked surface faces, as `[x, y, z]` | -| `pick.modifiers` | `{ alt, ctrl, meta, shift, secondary }`: keys held, and right-click | -| `pick.doubled` | `true` if this click was the second half of a double-click | +| Field | What it contains | +| ---------------- | --------------------------------------------------------------------- | +| `pick.best` | The most likely feature, or `null` | +| `pick.ranked` | Every matching feature, most likely first | +| `pick.owners` | Every matching feature, in report order | +| `pick.region` | The index of the face that was clicked | +| `pick.point` | Where the click hit, as `[x, y, z]` | +| `pick.normal` | The direction the clicked surface faces, as `[x, y, z]` | +| `pick.pointer` | Browser coordinates for an application-owned hover card, when present | +| `pick.modifiers` | `{ alt, ctrl, meta, shift, secondary }`: keys held, and right-click | +| `pick.doubled` | `true` if this click was the second half of a double-click | The ranking puts specific features first: holes, then pockets and bosses, then chamfers and fillets, then walls and faces, then profiles. Among features of the same kind, the one whose machining direction points most toward the camera wins. +`onHover` receives the same `PartPick` shape. Use the optional `pointer` location yourself, or +wrap application content in ``; it follows the cursor while that +region remains hovered. The viewer intentionally leaves card content and actions to the application. +Pass `hover={false}` to stop both the face feedback and `onHover` callbacks while preserving clicks; +that makes an application toolbar's feature-hover toggle unambiguous. + +The normalized model contains feature identity, type, directions, face shape, and analytic area. +An Engine DFM card can join `pick.best` to the part's detailed feature data and render its own +depth, clearance-diameter, or L/D rows inside `HoverCard`; setup labels remain application or plan +context rather than a fact of the part surface. + ## Components ### Stock and display controls @@ -295,15 +307,16 @@ Draws the part and handles clicks. You only use it directly when you **Interaction** -| Prop | Type | What it does | -| ----------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------- | -| `onPick` | `(pick: PartPick) => void` | Left- or right-click on the part. | -| `onHover` | `(pick: PartPick \| null) => void` | The pointer moved onto a different face, or off the part (`null`). | -| `activeDirection` | `number \| null` | Only match features machined from this direction (an index into `candidateDirections`). | -| `focusFeature` | `string \| null` | Zoom to this feature. The camera moves each time the value changes. | -| `section` | `SectionOptions` | Cut the part open. Omit it to follow the viewer's own cut. See [Section view](#section-view). | -| `onSectionChange` | `(state: SectionState) => void` | Called when the cut moves or goes away. With `section`, passing it also shows the drag handle. | -| `onAdjacency` | `(map) => void` | Called once per mesh with which faces touch which. | +| Prop | Type | What it does | +| ----------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `onPick` | `(pick: PartPick) => void` | Left- or right-click on the part. | +| `onHover` | `(pick: PartPick \| null) => void` | The pointer moved onto a different face, or off the part (`null`). | +| `hover` | `boolean` (`true`) | Paint and report faces under the pointer. `false` leaves click picking on and clears any hover. | +| `activeDirection` | `number \| null` | Only match features machined from this direction (an index into `candidateDirections`). | +| `focusFeature` | `string \| null` | Zoom to this feature. The camera moves each time the value changes. | +| `section` | `SectionOptions` | Cut the part open. Omit it to follow the viewer's own cut. See [Section view](#section-view). | +| `onSectionChange` | `(state: SectionState) => void` | Called when the cut moves or goes away. With `section`, passing it also shows the drag handle. | +| `onAdjacency` | `(map) => void` | Called once per mesh with which faces touch which. | Hovering over the part is handled for you. You only need `onHover` if you want to show the hovered feature elsewhere in your UI. diff --git a/packages/viewer/src/hover-card.tsx b/packages/viewer/src/hover-card.tsx new file mode 100644 index 0000000..9c00b37 --- /dev/null +++ b/packages/viewer/src/hover-card.tsx @@ -0,0 +1,42 @@ +import { type CSSProperties, type ReactNode, useEffect, useState } from 'react' +import type { PartPick, PointerLocation } from './render/picking.js' + +export interface HoverCardProps { + /** The current `onHover` pick, or `null` after the pointer leaves the part. */ + pick: PartPick | null + /** Card content belongs to the application that understands the feature data. */ + children: (pick: PartPick) => ReactNode + className?: string + offset?: number +} + +/** A cursor-following shell for an application-owned part hover card. */ +export const HoverCard = ({ pick, children, className, offset = 16 }: HoverCardProps) => { + const [pointer, setPointer] = useState(pick?.pointer) + + useEffect(() => { + setPointer(pick?.pointer) + if (pick === null) return + + const follow = (event: PointerEvent) => + setPointer({ clientX: event.clientX, clientY: event.clientY }) + window.addEventListener('pointermove', follow) + return () => window.removeEventListener('pointermove', follow) + }, [pick]) + + if (pick === null || pointer === undefined) return null + + const style: CSSProperties = { + position: 'fixed', + left: pointer.clientX + offset, + top: pointer.clientY + offset, + pointerEvents: 'none', + zIndex: 4, + } + + return ( +
+ {children(pick)} +
+ ) +} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index c428d0a..4f364d8 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -5,6 +5,7 @@ export { EnginePart, normalizePartReport, smoothRegionNormals } from './engine/index.js' export { regionAdjacency } from './render/adjacency.js' export { PartMesh } from './part-mesh.js' +export { HoverCard } from './hover-card.js' export { Stock, BoxStock } from './stock.js' export type { StockProps, BoxStockProps } from './stock.js' export { boxStockBounds } from './render/stock.js' @@ -184,7 +185,7 @@ export { ORBIT_TARGET_RING_WIDTH, orbitTargetOpacity, } from './render/target.js' -export type { BuildPickInput, PartPick, PickModifiers } from './render/picking.js' +export type { BuildPickInput, PartPick, PickModifiers, PointerLocation } from './render/picking.js' export type { ViewerControls, ViewerHandle, ViewerView } from './types.js' export type { FeatureTag, @@ -208,6 +209,7 @@ export type { FeatureHighlight, HighlightLayers, RegionHighlight } from './rende export type { FocusOptions } from './render/focus.js' export type { ViewerTheme } from './render/theme.js' export type { PartMeshProps } from './part-mesh.js' +export type { HoverCardProps } from './hover-card.js' export type { AxesProps, GridProps, ViewCubeProps } from './primitives.js' export type { CubeZone, ViewKind, ViewName } from './render/view-cube.js' export type { DirectionArrowsProps, NamedDirection } from './direction-arrows.js' diff --git a/packages/viewer/src/part-mesh.tsx b/packages/viewer/src/part-mesh.tsx index b5b5365..82a3870 100644 --- a/packages/viewer/src/part-mesh.tsx +++ b/packages/viewer/src/part-mesh.tsx @@ -73,6 +73,11 @@ export interface PartMeshProps { * and needs no prop. */ hoveredFeatureIds?: readonly FeatureTag[] + /** + * Whether moving across the part paints and reports a hovered face. Turn it + * off for an application-level "feature hover" control; picks still work. + */ + hover?: boolean /** * Scopes a pick to one machining direction, as an index into the model's * `candidateDirections`. A face that direction cannot reach then picks to @@ -150,6 +155,7 @@ export const PartMesh = ({ regionHighlights = [], pickedRegions = [], hoveredFeatureIds = [], + hover = true, activeDirection = null, section, onSectionChange, @@ -170,6 +176,10 @@ export const PartMesh = ({ currentTheme.current = resolved const part = useMemo(() => createPart(model, geometry, currentTheme.current), [geometry, model]) const hoverRegion = useRef(null) + // Read from effects that must clear a card after its callback changes, while + // keeping the pointer handlers stable enough to avoid a React round-trip. + const onHoverRef = useRef(onHover) + onHoverRef.current = onHover const box = useContentBox() // Controlled when `section` is given, whatever its value; the viewer's own // cut is only consulted when the consumer has said nothing. @@ -315,6 +325,7 @@ export const PartMesh = ({ triangleIndex, point: [event.point.x, event.point.y, event.point.z], normal: [normal.x, normal.y, normal.z], + pointer: { clientX: source.clientX, clientY: source.clientY }, activeDirection, doubled, viewDirection: viewDirection(camera, target), @@ -339,10 +350,18 @@ export const PartMesh = ({ onHover?.(next) } + // A hover toggle must make the current feedback go away immediately — not + // leave a painted face and an application card around until the pointer next + // crosses a region boundary. + useLayoutEffect(() => { + if (hover || hoverRegion.current === null) return + hoverRegion.current = null + repaint() + onHoverRef.current?.(null) + }, [hover, repaint]) + // A tool taking the pointer takes the hover with it, or the face under the // pointer at that moment would stay painted until the pointer left the part. - const onHoverRef = useRef(onHover) - onHoverRef.current = onHover useLayoutEffect(() => { if (!engaged || hoverRegion.current === null) return hoverRegion.current = null @@ -375,7 +394,7 @@ export const PartMesh = ({ pressedWhileEngaged.current = engaged }} onPointerMove={(event: ThreeEvent) => { - if (!engaged) emitHover(pickFor(event)) + if (!engaged && hover) emitHover(pickFor(event)) }} onPointerOut={() => { emitHover(null) diff --git a/packages/viewer/src/render/picking.ts b/packages/viewer/src/render/picking.ts index 3c83ec7..3e3e4d5 100644 --- a/packages/viewer/src/render/picking.ts +++ b/packages/viewer/src/render/picking.ts @@ -26,6 +26,12 @@ export const NO_MODIFIERS: PickModifiers = { secondary: false, } +/** A browser-viewport point, for placing an application-owned hover card. */ +export interface PointerLocation { + readonly clientX: number + readonly clientY: number +} + /** * A pointer event on the part, resolved to the face it landed on and the * features that own it. @@ -47,6 +53,8 @@ export interface PartPick { readonly point: readonly [number, number, number] /** The surface's outward normal in world space — the plane under the cursor. */ readonly normal: readonly [number, number, number] + /** Browser coordinates when the pick came from ``. */ + readonly pointer?: PointerLocation readonly modifiers: PickModifiers /** * Whether this click completed a double click on the part. @@ -83,6 +91,7 @@ export interface BuildPickInput { readonly triangleIndex: number readonly point: readonly [number, number, number] readonly normal: readonly [number, number, number] + readonly pointer?: PointerLocation readonly modifiers?: PickModifiers /** * The machining direction the pick is scoped to, as an index into @@ -115,6 +124,7 @@ export function buildPick(input: BuildPickInput): PartPick { triangleIndex: input.triangleIndex, point: input.point, normal: input.normal, + ...(input.pointer === undefined ? {} : { pointer: input.pointer }), modifiers: input.modifiers ?? NO_MODIFIERS, doubled: input.doubled ?? false, } diff --git a/packages/viewer/tests/picking.test.ts b/packages/viewer/tests/picking.test.ts index 9a7a505..606d43b 100644 --- a/packages/viewer/tests/picking.test.ts +++ b/packages/viewer/tests/picking.test.ts @@ -45,6 +45,12 @@ describe('buildPick', () => { expect(new Set(pick.ranked)).toEqual(new Set(pick.owners)) }) + it('preserves a browser pointer location when a caller supplies one', () => { + const pick = pickOn(cubeModel(), 3, { pointer: { clientX: 120, clientY: 48 } }) + + expect(pick.pointer).toEqual({ clientX: 120, clientY: 48 }) + }) + it('resolves a face to the reading that faces the camera', () => { const model = cubeModel() From ce60e1458806d4dfb4cce9f1cc158628ac54d4d3 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:22:34 -0400 Subject: [PATCH 04/16] Improve section view --- .changeset/viewer-section-gizmo.md | 5 ++ examples/react-viewer/src/main.tsx | 4 + examples/react-viewer/src/style.css | 5 ++ examples/react-viewer/src/viewer-toolbar.tsx | 2 +- examples/react-viewer/tests/viewer.spec.ts | 2 + packages/viewer/README.md | 7 +- packages/viewer/src/index.ts | 2 + packages/viewer/src/part-mesh.tsx | 6 +- packages/viewer/src/render/section.ts | 59 ++++++++++++- packages/viewer/src/section-view.tsx | 93 ++++++++++++++------ packages/viewer/tests/section.test.ts | 45 ++++++++++ 11 files changed, 200 insertions(+), 30 deletions(-) create mode 100644 .changeset/viewer-section-gizmo.md diff --git a/.changeset/viewer-section-gizmo.md b/.changeset/viewer-section-gizmo.md new file mode 100644 index 0000000..aeab878 --- /dev/null +++ b/.changeset/viewer-section-gizmo.md @@ -0,0 +1,5 @@ +--- +'@toolpath/viewer': minor +--- + +Add a visible section-plane gizmo with a two-way drag handle and physical cut measurement. diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index f7006f7..091a8c5 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -18,6 +18,7 @@ import { SectionTool, Viewer, measurementLabel, + sectionMeasurement, type MeasureMode, type Measurement, type PartModel, @@ -393,6 +394,9 @@ const App = () => { viewerRef.current?.setSection(sweepTo(cut, next)) }} /> + + {sectionMeasurement(cut)} + ) -interface ViewerToolbarProps { +export interface BananaButtonProps { + /** Whether the banana for scale is currently shown. */ + shown: boolean + onClick: () => void +} + +/** The viewer toolbar control for showing a banana beside a 3D part. */ +export const BananaButton = ({ shown, onClick }: BananaButtonProps) => ( + +) + +export interface ViewerToolbarProps { stock: boolean axes: boolean grid: boolean - banana: boolean + banana?: boolean directions: boolean hover: boolean focus: boolean @@ -60,7 +50,7 @@ interface ViewerToolbarProps { onStock: () => void onAxes: () => void onGrid: () => void - onBanana: () => void + onBanana?: () => void onDirections: () => void onHover: () => void onFocus: () => void @@ -70,6 +60,7 @@ interface ViewerToolbarProps { children?: ReactNode } +/** The standard toolbar for the viewer's camera, display, and analysis controls. */ export const ViewerToolbar = (props: ViewerToolbarProps) => (
{props.children} @@ -92,12 +83,9 @@ export const ViewerToolbar = (props: ViewerToolbarProps) => ( pressed={props.grid} onClick={props.onGrid} /> - + {props.onBanana ? ( + + ) : null} Date: Mon, 21 Sep 2026 11:46:23 -0400 Subject: [PATCH 12/16] Add controls --- .changeset/viewer-cad-controls.md | 5 + examples/react-viewer/src/main.tsx | 54 +++++- examples/react-viewer/src/style.css | 34 ++-- examples/react-viewer/tests/canvas.ts | 12 +- examples/react-viewer/tests/controls.spec.ts | 55 ++++++ examples/react-viewer/tests/viewer.spec.ts | 36 ++-- packages/viewer/README.md | 39 ++-- packages/viewer/src/index.ts | 4 +- packages/viewer/src/render/control-schemes.ts | 141 +++++++++++++++ packages/viewer/src/render/controls.ts | 130 ++++++------- packages/viewer/tests/control-schemes.test.ts | 85 +++++++++ packages/viewer/tests/controls.test.ts | 171 ++++++++++++++++++ 12 files changed, 641 insertions(+), 125 deletions(-) create mode 100644 .changeset/viewer-cad-controls.md create mode 100644 examples/react-viewer/tests/controls.spec.ts create mode 100644 packages/viewer/src/render/control-schemes.ts create mode 100644 packages/viewer/tests/control-schemes.test.ts create mode 100644 packages/viewer/tests/controls.test.ts diff --git a/.changeset/viewer-cad-controls.md b/.changeset/viewer-cad-controls.md new file mode 100644 index 0000000..0290ba8 --- /dev/null +++ b/.changeset/viewer-cad-controls.md @@ -0,0 +1,5 @@ +--- +'@toolpath/viewer': minor +--- + +Add CAD navigation presets for Alias, Fusion, Inventor, Onshape, PowerMill, SolidWorks, and Tinkercad. diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index 05fc83a..7fbb35c 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -19,6 +19,7 @@ import { PartMesh, SectionTool, Viewer, + CONTROL_SCHEME_OPTIONS, measurementLabel, sectionMeasurement, type MeasureMode, @@ -26,6 +27,7 @@ import { type PartModel, type PartPick, type Projection, + type ControlScheme, type SectionOptions, type SectionState, type StockPosition, @@ -51,6 +53,12 @@ const params = new URLSearchParams(window.location.search) const projection: Projection = params.get('projection') === 'orthographic' ? 'orthographic' : 'perspective' const showOrbitTarget = params.get('orbitTarget') === 'on' +const requestedControls = params.get('controls') +const initialControls: ControlScheme = CONTROL_SCHEME_OPTIONS.some( + (option) => option.value === requestedControls, +) + ? (requestedControls as ControlScheme) + : 'toolpath' /** * `?model=` opens one of the parts in `./models.ts` — a plate with holes, * a chamfered block, a pocket, a stepped boss — for the measure tool to work @@ -66,16 +74,21 @@ interface CameraState { distance: number /** The orbit target — the point the view turns and zooms about. */ target: readonly [number, number, number] + /** Camera position, used only by the example's navigation smoke tests. */ + position: readonly [number, number, number] } -const AT_START: CameraState = { zoom: 1, distance: 0, target: [0, 0, 0] } +const AT_START: CameraState = { zoom: 1, distance: 0, target: [0, 0, 0], position: [0, 0, 0] } const sameCamera = (a: CameraState, b: CameraState) => a.zoom === b.zoom && a.distance === b.distance && a.target[0] === b.target[0] && a.target[1] === b.target[1] && - a.target[2] === b.target[2] + a.target[2] === b.target[2] && + a.position[0] === b.position[0] && + a.position[1] === b.position[1] && + a.position[2] === b.position[2] /** * The camera's own numbers, put on the page. @@ -105,6 +118,7 @@ const CameraReadout = ({ onChange }: { onChange: (state: CameraState) => void }) zoom: camera.zoom, distance: camera.position.distanceTo(target), target: [target.x, target.y, target.z], + position: [camera.position.x, camera.position.y, camera.position.z], }) }) @@ -163,6 +177,9 @@ const HoverDetails = ({ pick, model }: { pick: PartPick; model: PartModel }) => const App = () => { const [part, setPart] = useState(startingModel) + // Query params select the opening preset for browser coverage; the picker + // below changes the live controls without remounting the viewer. + const [controls, setControls] = useState(initialControls) const viewerRef = useRef(null) const [hovered, setHovered] = useState([]) const [hoverPick, setHoverPick] = useState(null) @@ -228,6 +245,7 @@ const App = () => {

@toolpath/viewer

{part.name}

+

{part.hint}

Stock:{' '} @@ -346,13 +377,13 @@ const App = () => { Frame detail

- Left-drag to orbit, middle/right-drag to pan, scroll to zoom, and click a face to select - it. Press Section, then click a face to cut through it or one of the - three planes behind the part to cut along an axis; drag the arrow to move the cut, and - press Escape to clear it. Press Measure, then click two points for a - distance or three for an angle — the pointer snaps to corners, edges and their midpoints, - and Shift holds the next point to an axis. Delete removes the last measurement and Escape - drops one in progress. + Left-drag to orbit, right-drag to pan, scroll to zoom, and click a face to select it. + Press Section, then click a face to cut through it or one of the three + planes behind the part to cut along an axis; drag the arrow to move the cut, and press + Escape to clear it. Press Measure, then click two points for a distance + or three for an angle — the pointer snaps to corners, edges and their midpoints, and Shift + holds the next point to an axis. Delete removes the last measurement and Escape drops one + in progress.

Hovered: {hovered.join(', ') || 'none'} @@ -372,6 +403,9 @@ const App = () => {

Projection: {projection}

+

+ Controls: {controls} +

{/* The attributes are what the browser suite reads; the sentence is what a person reads. Both come off the same frame, and the attributes carry @@ -383,6 +417,7 @@ const App = () => { data-zoom={pose.zoom} data-distance={pose.distance} data-target={pose.target.join(' ')} + data-position={pose.position.join(' ')} > Camera: zoom {pose.zoom.toFixed(2)}, distance {pose.distance.toFixed(1)}{' '} mm, target {pose.target.map((axis) => axis.toFixed(1)).join(', ')} @@ -562,6 +597,7 @@ const App = () => { key={part.id} ref={viewerRef} projection={projection} + controls={controls} showOrbitTarget={showOrbitTarget} onPointerMissed={() => setSelected([])} > diff --git a/examples/react-viewer/src/style.css b/examples/react-viewer/src/style.css index 8dee1fa..8ceed98 100644 --- a/examples/react-viewer/src/style.css +++ b/examples/react-viewer/src/style.css @@ -54,6 +54,9 @@ p { color: #b9c0d1; line-height: 1.55; } +section > p { + margin: 0 0 1rem; +} strong { color: #eef0f6; } @@ -133,19 +136,24 @@ strong { margin-right: 0.4rem; } .stock-allowance { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem); gap: 0.5rem; align-items: center; + width: 100%; + margin: 0 0 0.5rem; font-size: 0.85rem; } -.stock-allowance input { - width: 4.5rem; +.stock-allowance input, +.stock-allowance select { + width: 100%; + min-width: 0; padding: 0.35rem; color: inherit; background: #202737; border: 1px solid #526078; border-radius: 0.3rem; + font: inherit; } .small-note { font-size: 0.8rem; @@ -156,10 +164,12 @@ strong { * default ring is drawn on click too, and on a dark toolbar it reads as a * stuck highlight on whatever was last pressed. */ -.model-picker select:focus { +.model-picker select:focus, +.stock-allowance select:focus { outline: none; } .stock-allowance input:focus-visible, +.stock-allowance select:focus-visible, .detail-button:focus-visible, .viewer-tool-options input:focus-visible, .model-picker select:focus-visible { @@ -167,15 +177,16 @@ strong { outline-offset: 2px; } .model-picker { - display: inline-flex; - align-items: center; - gap: 0.5rem; - margin: 0 0 1rem; + display: grid; + gap: 0.35rem; + width: 100%; + margin: 0 0 0.75rem; color: #b9c0d1; font-size: 0.85rem; } /* The caret is Phosphor's `CaretUpDown`, regular weight, inlined so the example adds no dependency. */ -.model-picker select { +.model-picker select, +.stock-allowance select { appearance: none; border: 1px solid #526078; border-radius: 0.4rem; @@ -187,7 +198,8 @@ strong { font: inherit; padding: 0.3rem 1.7rem 0.3rem 0.6rem; } -.model-picker select:hover { +.model-picker select:hover, +.stock-allowance select:hover { background-color: #303b51; } @media (max-width: 760px) { diff --git a/examples/react-viewer/tests/canvas.ts b/examples/react-viewer/tests/canvas.ts index 6e531fb..d3d7719 100644 --- a/examples/react-viewer/tests/canvas.ts +++ b/examples/react-viewer/tests/canvas.ts @@ -85,6 +85,7 @@ export interface CameraPose { zoom: number distance: number target: readonly [number, number, number] + position: readonly [number, number, number] } /** @@ -100,11 +101,18 @@ export const readCamera = async (page: Page): Promise => { const zoom = await readout.getAttribute('data-zoom') const distance = await readout.getAttribute('data-distance') const target = await readout.getAttribute('data-target') - if (zoom === null || distance === null || target === null) { + const position = await readout.getAttribute('data-position') + if (zoom === null || distance === null || target === null || position === null) { throw new Error('The example is not reporting its camera') } const [x, y, z] = target.split(' ').map(Number) - return { zoom: Number(zoom), distance: Number(distance), target: [x, y, z] } + const [positionX, positionY, positionZ] = position.split(' ').map(Number) + return { + zoom: Number(zoom), + distance: Number(distance), + target: [x, y, z], + position: [positionX, positionY, positionZ], + } } /** diff --git a/examples/react-viewer/tests/controls.spec.ts b/examples/react-viewer/tests/controls.spec.ts new file mode 100644 index 0000000..2b561e3 --- /dev/null +++ b/examples/react-viewer/tests/controls.spec.ts @@ -0,0 +1,55 @@ +import { expect, test, type Page } from '@playwright/test' +import { at, openViewer, readCamera } from './canvas.js' + +const centre = { x: 0.5, y: 0.5 } + +const drag = async ( + page: Page, + start: { x: number; y: number }, + end: { x: number; y: number }, + button: 'left' | 'middle' | 'right', +) => { + await page.mouse.move(start.x, start.y) + await page.mouse.down({ button }) + await page.mouse.move(end.x, end.y, { steps: 8 }) + await page.mouse.up({ button }) + await page.waitForTimeout(100) +} + +test('Toolpath and Onshape assign orbit and pan to their documented mouse buttons', async ({ + page, +}) => { + const toolpath = await openViewer(page, 'controls=toolpath') + await expect(page.getByText('Controls: toolpath')).toBeVisible() + const toolpathBefore = await readCamera(page) + await drag(page, at(toolpath.box, centre), at(toolpath.box, { x: 0.62, y: 0.5 }), 'left') + const toolpathAfter = await readCamera(page) + expect(toolpathAfter.position).not.toEqual(toolpathBefore.position) + expect(toolpathBefore.target).toEqual(toolpathAfter.target) + + await page.getByLabel('3D controls').selectOption('onshape') + await expect(page.getByText('Controls: onshape')).toBeVisible() + const onshapeBefore = await readCamera(page) + await drag(page, at(toolpath.box, centre), at(toolpath.box, { x: 0.62, y: 0.5 }), 'middle') + const onshapeAfter = await readCamera(page) + expect(onshapeAfter.target).not.toEqual(onshapeBefore.target) +}) + +test('Fusion and SolidWorks apply their modifier-aware middle-button controls', async ({ + page, +}) => { + const fusion = await openViewer(page, 'controls=fusion') + const fusionBefore = await readCamera(page) + await drag(page, at(fusion.box, centre), at(fusion.box, { x: 0.62, y: 0.5 }), 'middle') + const fusionAfter = await readCamera(page) + expect(fusionAfter.target).not.toEqual(fusionBefore.target) + + await page.getByLabel('3D controls').selectOption('solidworks') + await expect(page.getByText('Controls: solidworks')).toBeVisible() + const solidworksBefore = await readCamera(page) + await page.keyboard.down('Control') + await drag(page, at(fusion.box, centre), at(fusion.box, { x: 0.62, y: 0.5 }), 'middle') + await page.keyboard.up('Control') + const solidworksAfter = await readCamera(page) + expect(solidworksAfter.target).not.toEqual(solidworksBefore.target) +}) diff --git a/examples/react-viewer/tests/viewer.spec.ts b/examples/react-viewer/tests/viewer.spec.ts index bec9274..934451c 100644 --- a/examples/react-viewer/tests/viewer.spec.ts +++ b/examples/react-viewer/tests/viewer.spec.ts @@ -384,7 +384,7 @@ test('waits for a cut to be chosen before measuring beside the section tool', as await expect(measured).toContainText('off') }) -test('pans with either pan button, from wherever the drag starts', async ({ page }) => { +test('Toolpath pans with the right button, from wherever the drag starts', async ({ page }) => { const { canvas, box } = await openViewer(page) // The

, not the inside it: `getByText('Selected:')` matches the @@ -397,32 +397,28 @@ test('pans with either pan button, from wherever the drag starts', async ({ page // as the viewport gets, and clear of the toolbar in the top-left. A pan that // needs the pointer over the part is a pan that stops working on exactly the // view somebody was trying to fix. - const panFromCorner = async (button: 'right' | 'middle') => { + const panFromCorner = async () => { const from = { x: box.x + 40, y: box.y + box.height - 40 } await page.mouse.move(from.x, from.y) - await page.mouse.down({ button }) + await page.mouse.down({ button: 'right' }) for (let step = 1; step <= 10; step += 1) { await page.mouse.move(from.x + step * (box.width * 0.15), from.y) } - await page.mouse.up({ button }) + await page.mouse.up({ button: 'right' }) await page.waitForTimeout(300) } - for (const button of ['right', 'middle'] as const) { - await page.getByRole('button', { name: 'Fit' }).click() - await page.waitForTimeout(300) - await canvas.click({ position: centre }) - await expect(selected).not.toContainText('none') + await canvas.click({ position: centre }) + await expect(selected).not.toContainText('none') - await panFromCorner(button) + await panFromCorner() - // The part has left the middle of the view, which a pan does and an orbit - // does not: an orbit turns the part about that point and leaves it there. - // Clicking where it was now hits nothing, which is what puts the selection - // down. - await canvas.click({ position: centre }) - await expect(selected).toContainText('none') - } + // The part has left the middle of the view, which a pan does and an orbit + // does not: an orbit turns the part about that point and leaves it there. + // Clicking where it was now hits nothing, which is what puts the selection + // down. + await canvas.click({ position: centre }) + await expect(selected).toContainText('none') }) test('finishing a drag over a face is not a request to select it', async ({ page }) => { @@ -507,8 +503,10 @@ test('panning over empty space keeps the selection', async ({ page }) => { * The left button has had this guard on the mesh all along; this is the middle * one getting it too. */ -test('two middle-button pans released in the same place do not re-frame', async ({ page }) => { - const { canvas, box } = await openViewer(page) +test('two Onshape middle-button pans released in the same place do not re-frame', async ({ + page, +}) => { + const { canvas, box } = await openViewer(page, 'controls=onshape') const selected = page.locator('p', { hasText: 'Selected:' }) const centre = on(box, CENTRE) diff --git a/packages/viewer/README.md b/packages/viewer/README.md index fd41c0f..dd4d799 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -266,7 +266,7 @@ The canvas that holds everything. Import it from `@toolpath/viewer`. | Prop | Default | What it does | | ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- | | `projection` | `'orthographic'` | `'orthographic'` keeps parallel lines parallel. `'perspective'` gives depth, which helps with deep pockets. | -| `controls` | `'toolpath'` | Mouse mapping. See [Mouse controls](#mouse-controls). | +| `controls` | `'toolpath'` | CAD navigation preset. See [CAD controls](#cad-controls). | | `zoomTo` | `'cursor'` | Zoom toward the pointer, or toward `'centre'` (often easier on a trackpad). | | `freeOrbit` | `true` | Let the view keep rotating past straight-up and straight-down. | | `retargetOnDoubleClick` | `true` | Double-click the part to rotate around that point. | @@ -281,20 +281,35 @@ Changing `projection` rebuilds the canvas and returns the camera to its starting The canvas is transparent. To set a background colour, style the wrapper or its parent. -#### Mouse controls +#### CAD controls -| Action | `controls="toolpath"` (default) | `controls="fusion"` (like Fusion 360) | -| --------------------- | ------------------------------- | ------------------------------------------ | -| Rotate | Left-drag | Shift + middle-drag, or Shift + scroll | -| Pan | Right-drag or middle-drag | Middle-drag, or scroll / two-finger scroll | -| Zoom | Scroll wheel | Trackpad pinch | -| Rotate around a point | Double-click the part | Double-click the part | -| Fit whole part | Double middle-click | Double middle-click | +`controls` changes only the navigation gestures. It does not choose a projection: the viewer stays +orthographic by default, and `projection="perspective"` remains an independent opt-in. -In `fusion` mode, left-drag doesn't move the camera, and the scroll wheel pans instead of zooming. -On touch screens, one finger rotates and two fingers pinch and pan. +| Value | Label | Rotate | Pan | Zoom | +| -------------- | ---------- | ------------------------------------- | ----------------------------------------- | ----------------------------------- | +| `'toolpath'` | Toolpath | Left-drag | Right-drag | Scroll wheel | +| `'fusion'` | Fusion | Shift + middle-drag or Shift + scroll | Middle-drag, scroll, or two-finger scroll | Trackpad pinch | +| `'alias'` | Alias | Left-drag | Middle-drag | Scroll wheel | +| `'inventor'` | Inventor | Shift + middle-drag or Shift + scroll | Middle-drag, scroll, or two-finger scroll | Trackpad pinch | +| `'solidworks'` | SolidWorks | Middle-drag | Ctrl + middle-drag | Shift + middle-drag or scroll wheel | +| `'tinkercad'` | Tinkercad | Right-drag | Shift + right-drag | Scroll wheel | +| `'powermill'` | PowerMill | Middle-drag | Shift + middle-drag | Scroll wheel | +| `'onshape'` | Onshape | Right-drag | Middle-drag | Scroll wheel | -Dragging never selects anything. A click only counts if the pointer barely moved. +Fusion and Inventor treat a two-finger scroll as pan, Shift + two-finger scroll as orbit, and a +pinch (a wheel event with Ctrl set by the browser) as zoom. Other schemes use one-finger rotate, +two-finger pinch-and-pan, and three-finger pan on touch screens. + +All schemes support double-clicking the part to rotate around that point and double-clicking the +middle mouse button to fit the whole part. Dragging never selects anything; a click only counts if +the pointer barely moved. + +The package exports `CONTROL_SCHEME_OPTIONS`, `ControlScheme`, and `ControlSchemeOption` for a host +application's settings UI. It deliberately stores no preferences: pass the selected `controls`, +`freeOrbit`, and `zoomTo` values to `` from your own state or persistence layer. + +`'toolpath'`, `freeOrbit={true}`, and `zoomTo="cursor"` are the defaults. ### `` diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 3d08663..40591cf 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -141,6 +141,7 @@ export { targetBoundary, } from './render/camera.js' export { ExtendedCameraControls } from './render/controls.js' +export { CONTROL_SCHEME_OPTIONS } from './render/control-schemes.js' export { useRetarget, useSectionStore, useViewerControls, Viewer } from './viewer.js' export { PartReportFormatError, UnsupportedKernelVersionError } from './model/errors.js' export { buildRegionIndex } from './model/region-index.js' @@ -258,5 +259,6 @@ export type { ViewerCamera, ViewportSize, } from './render/camera.js' -export type { ControlScheme, ExtendedCameraControlsOptions } from './render/controls.js' +export type { ExtendedCameraControlsOptions } from './render/controls.js' +export type { ControlScheme, ControlSchemeOption } from './render/control-schemes.js' export type { Retarget, ViewerProps } from './viewer.js' diff --git a/packages/viewer/src/render/control-schemes.ts b/packages/viewer/src/render/control-schemes.ts new file mode 100644 index 0000000..cc60e30 --- /dev/null +++ b/packages/viewer/src/render/control-schemes.ts @@ -0,0 +1,141 @@ +/** A named CAD navigation preset a consumer may pass to ``. */ +export const CONTROL_SCHEME_OPTIONS = [ + { value: 'toolpath', label: 'Toolpath' }, + { value: 'fusion', label: 'Fusion' }, + { value: 'alias', label: 'Alias' }, + { value: 'inventor', label: 'Inventor' }, + { value: 'solidworks', label: 'SolidWorks' }, + { value: 'tinkercad', label: 'Tinkercad' }, + { value: 'powermill', label: 'PowerMill' }, + { value: 'onshape', label: 'Onshape' }, +] as const + +/** The public values accepted by `` and `CadCameraControls`. */ +export type ControlScheme = (typeof CONTROL_SCHEME_OPTIONS)[number]['value'] + +export type ControlSchemeOption = (typeof CONTROL_SCHEME_OPTIONS)[number] + +type PointerAction = 'none' | 'rotate' | 'truck' | 'zoom' + +type WheelAction = 'none' | 'zoom' | 'dolly' + +export interface ControlSchemeMapping { + readonly mouse: { + readonly left: PointerAction + readonly middle: PointerAction + readonly right: PointerAction + readonly wheel: WheelAction + } + readonly touches: { + readonly one: 'rotate' + readonly two: 'rotate' | 'truck' | 'dolly-truck' + readonly three: 'truck' + } + /** Fusion and Inventor distinguish a trackpad scroll from a pinch wheel event. */ + readonly usesCadWheel: boolean + /** These presets track pointer and trackpad movement with no damping. */ + readonly immediate: boolean +} + +interface ControlModifiers { + readonly shift: boolean + readonly ctrl: boolean +} + +export interface ControlSchemeEnvironment { + readonly orthographic: boolean + readonly modifiers: ControlModifiers +} + +const DEFAULT_TOUCHES: ControlSchemeMapping['touches'] = { + one: 'rotate', + two: 'dolly-truck', + three: 'truck', +} + +const wheelFor = (orthographic: boolean): WheelAction => (orthographic ? 'zoom' : 'dolly') + +const mapping = ( + environment: ControlSchemeEnvironment, + mouse: Partial, + options: Pick, + touches: ControlSchemeMapping['touches'] = DEFAULT_TOUCHES, +): ControlSchemeMapping => ({ + mouse: { + left: 'none', + middle: 'none', + right: 'none', + wheel: wheelFor(environment.orthographic), + ...mouse, + }, + touches, + ...options, +}) + +/** + * Resolves the pointer, touch, and wheel actions for a named CAD preset. + * + * This is deliberately data-only: `ExtendedCameraControls` owns the DOM + * listeners and translates these names to camera-controls actions, while this + * function is the single, testable record of Toolpath's navigation parity. + */ +export const resolveControlScheme = ( + scheme: ControlScheme, + environment: ControlSchemeEnvironment, +): ControlSchemeMapping => { + const { shift, ctrl } = environment.modifiers + + switch (scheme) { + case 'fusion': + case 'inventor': { + const rotating = shift + return mapping( + environment, + { middle: rotating ? 'rotate' : 'truck', wheel: 'none' }, + { usesCadWheel: true, immediate: true }, + { + one: 'rotate', + two: rotating ? 'rotate' : 'truck', + three: 'truck', + }, + ) + } + case 'solidworks': + // This order matches the legacy viewer: Shift wins when both are held. + return mapping( + environment, + { middle: shift ? 'zoom' : ctrl ? 'truck' : 'rotate' }, + { usesCadWheel: false, immediate: false }, + ) + case 'alias': + return mapping( + environment, + { left: 'rotate', middle: 'truck' }, + { usesCadWheel: false, immediate: false }, + ) + case 'tinkercad': + return mapping( + environment, + { right: shift ? 'truck' : 'rotate' }, + { usesCadWheel: false, immediate: false }, + ) + case 'powermill': + return mapping( + environment, + { middle: shift ? 'truck' : 'rotate' }, + { usesCadWheel: false, immediate: false }, + ) + case 'onshape': + return mapping( + environment, + { middle: 'truck', right: 'rotate' }, + { usesCadWheel: false, immediate: false }, + ) + case 'toolpath': + return mapping( + environment, + { left: 'rotate', right: 'truck' }, + { usesCadWheel: false, immediate: false }, + ) + } +} diff --git a/packages/viewer/src/render/controls.ts b/packages/viewer/src/render/controls.ts index 96ead68..a19b814 100644 --- a/packages/viewer/src/render/controls.ts +++ b/packages/viewer/src/render/controls.ts @@ -14,6 +14,8 @@ import { import { adaptedUp } from './camera.js' import type { CameraLimits, ViewerCamera } from './camera.js' +import { resolveControlScheme } from './control-schemes.js' +import type { ControlScheme, ControlSchemeMapping } from './control-schemes.js' /** * `camera-controls` needs the three classes it constructs injected once, and @@ -37,16 +39,7 @@ CameraControls.install({ }, }) -/** - * Mouse and trackpad presets. - * - * - `toolpath` — left-drag orbits, right- and middle-drag pan. The product - * default. - * - `fusion` — middle-drag and two-finger scroll pan, shift makes them orbit, - * pinch zooms. Matches Fusion 360, which is what most of our users have open - * in the other window. - */ -export type ControlScheme = 'toolpath' | 'fusion' +export type { ControlScheme } from './control-schemes.js' export type ExtendedCameraControlsOptions = { /** @@ -106,8 +99,8 @@ const DOLLY_SPEED = 1.15 const REST_THRESHOLD = 0.005 /** - * `CameraControls` with free orbit, camera-relative up, and the Fusion wheel - * scheme. + * `CameraControls` with free orbit, camera-relative up, and CAD navigation + * schemes. * * Two departures from the legacy implementation, both deliberate: * @@ -128,6 +121,7 @@ export class ExtendedCameraControls extends CameraControls { #attached = false #autoUpEnabled = false #shiftPressed = false + #ctrlPressed = false #wheelHandler: ((event: WheelEvent) => void) | null = null // Scratch objects — `#onPointerMove` and `#adaptUpVector` run at pointer and @@ -200,7 +194,7 @@ export class ExtendedCameraControls extends CameraControls { view?.addEventListener('keydown', this.#onModifierChange) view?.addEventListener('keyup', this.#onModifierChange) // A window that loses focus never delivers the matching keyup, which would - // otherwise leave the Fusion scheme stuck in its shift variant. + // otherwise leave a modifier-aware scheme stuck in its shifted variant. view?.addEventListener('blur', this.#onWindowBlur) if (this.#freeOrbit) { @@ -224,7 +218,7 @@ export class ExtendedCameraControls extends CameraControls { view?.removeEventListener('blur', this.#onWindowBlur) this.#disableAutoUp() - this.#disableFusionWheel() + this.#disableCadWheel() } override dispose(): void { @@ -239,52 +233,45 @@ export class ExtendedCameraControls extends CameraControls { */ applyScheme(scheme: ControlScheme): void { this.#scheme = scheme + const mapping = resolveControlScheme(scheme, { + orthographic: this.camera instanceof OrthographicCamera, + modifiers: { shift: this.#shiftPressed, ctrl: this.#ctrlPressed }, + }) - this.mouseButtons.left = CameraControls.ACTION.NONE - this.mouseButtons.middle = CameraControls.ACTION.NONE - this.mouseButtons.right = CameraControls.ACTION.NONE - this.mouseButtons.wheel = CameraControls.ACTION.NONE - - this.touches.one = CameraControls.ACTION.TOUCH_ROTATE - this.touches.two = CameraControls.ACTION.TOUCH_DOLLY_TRUCK - this.touches.three = CameraControls.ACTION.TOUCH_TRUCK - - this.smoothTime = DEFAULT_SMOOTH_TIME - this.draggingSmoothTime = DEFAULT_SMOOTH_TIME - - this.#disableFusionWheel() - - if (scheme === 'fusion') { - // Shift turns the pan gestures into orbit gestures, matching Fusion. - const rotating = this.#shiftPressed - - this.mouseButtons.middle = rotating - ? CameraControls.ACTION.ROTATE - : CameraControls.ACTION.TRUCK - this.touches.two = rotating - ? CameraControls.ACTION.TOUCH_ROTATE - : CameraControls.ACTION.TOUCH_TRUCK - - // Fusion feels wrong with damping; the view has to track the trackpad. - this.smoothTime = 0 - this.draggingSmoothTime = 0 - this.#enableFusionWheel() + this.#applyMapping(mapping) + } - return + #applyMapping(mapping: ControlSchemeMapping): void { + const action = { + none: CameraControls.ACTION.NONE, + rotate: CameraControls.ACTION.ROTATE, + truck: CameraControls.ACTION.TRUCK, + zoom: CameraControls.ACTION.ZOOM, + dolly: CameraControls.ACTION.DOLLY, + } as const + const touchAction = { + rotate: CameraControls.ACTION.TOUCH_ROTATE, + truck: CameraControls.ACTION.TOUCH_TRUCK, + 'dolly-truck': CameraControls.ACTION.TOUCH_DOLLY_TRUCK, + } as const + + this.#disableCadWheel() + + this.mouseButtons.left = action[mapping.mouse.left] + this.mouseButtons.middle = action[mapping.mouse.middle] + this.mouseButtons.right = action[mapping.mouse.right] + this.mouseButtons.wheel = action[mapping.mouse.wheel] + + this.touches.one = touchAction[mapping.touches.one] + this.touches.two = touchAction[mapping.touches.two] + this.touches.three = touchAction[mapping.touches.three] + + this.smoothTime = mapping.immediate ? 0 : DEFAULT_SMOOTH_TIME + this.draggingSmoothTime = mapping.immediate ? 0 : DEFAULT_SMOOTH_TIME + + if (mapping.usesCadWheel) { + this.#enableCadWheel() } - - this.mouseButtons.left = CameraControls.ACTION.ROTATE - this.mouseButtons.right = CameraControls.ACTION.TRUCK - // Middle-drag pans too. It is the pan gesture in SolidWorks, Fusion and - // Onshape, so somebody arriving from any of them reaches for it first — - // and a gesture that does nothing reads as a viewport that has hung. - this.mouseButtons.middle = CameraControls.ACTION.TRUCK - // Dollying an orthographic camera moves it without changing what the - // frustum covers, so the wheel has to scale the frustum instead. - this.mouseButtons.wheel = - this.camera instanceof OrthographicCamera - ? CameraControls.ACTION.ZOOM - : CameraControls.ACTION.DOLLY } setFreeOrbit(freeOrbit: boolean): void { @@ -333,14 +320,18 @@ export class ExtendedCameraControls extends CameraControls { this.removeEventListener('update', this.#adaptUpVector) } - #enableFusionWheel(): void { - this.#wheelHandler = (event: WheelEvent) => this.#onFusionWheel(event) + #enableCadWheel(): void { + if (!this.#attached || this.#wheelHandler) { + return + } + + this.#wheelHandler = (event: WheelEvent) => this.#onCadWheel(event) this.#domElement.addEventListener('wheel', this.#wheelHandler, { passive: false, }) } - #disableFusionWheel(): void { + #disableCadWheel(): void { if (!this.#wheelHandler) { return } @@ -399,7 +390,7 @@ export class ExtendedCameraControls extends CameraControls { this.update(0) } - #onFusionWheel = (event: WheelEvent): void => { + #onCadWheel = (event: WheelEvent): void => { event.preventDefault() if (event.ctrlKey) { @@ -423,23 +414,20 @@ export class ExtendedCameraControls extends CameraControls { } #onModifierChange = (event: KeyboardEvent): void => { - this.#setShiftPressed(event.shiftKey) + this.#setModifiers(event.shiftKey, event.ctrlKey) } #onWindowBlur = (): void => { - this.#setShiftPressed(false) + this.#setModifiers(false, false) } - #setShiftPressed(pressed: boolean): void { - if (this.#shiftPressed === pressed) { + #setModifiers(shift: boolean, ctrl: boolean): void { + if (this.#shiftPressed === shift && this.#ctrlPressed === ctrl) { return } - this.#shiftPressed = pressed - - // Only the Fusion scheme reads the modifier, so nothing else has to churn. - if (this.#scheme === 'fusion') { - this.applyScheme(this.#scheme) - } + this.#shiftPressed = shift + this.#ctrlPressed = ctrl + this.applyScheme(this.#scheme) } } diff --git a/packages/viewer/tests/control-schemes.test.ts b/packages/viewer/tests/control-schemes.test.ts new file mode 100644 index 0000000..22774a5 --- /dev/null +++ b/packages/viewer/tests/control-schemes.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { + CONTROL_SCHEME_OPTIONS, + resolveControlScheme, + type ControlScheme, +} from '../src/render/control-schemes.js' + +const mappingFor = ( + scheme: ControlScheme, + modifiers = { shift: false, ctrl: false }, + orthographic = true, +) => resolveControlScheme(scheme, { modifiers, orthographic }) + +describe('control scheme options', () => { + it('exports the complete legacy CAD preset list with stable public values', () => { + expect(CONTROL_SCHEME_OPTIONS).toEqual([ + { value: 'toolpath', label: 'Toolpath' }, + { value: 'fusion', label: 'Fusion' }, + { value: 'alias', label: 'Alias' }, + { value: 'inventor', label: 'Inventor' }, + { value: 'solidworks', label: 'SolidWorks' }, + { value: 'tinkercad', label: 'Tinkercad' }, + { value: 'powermill', label: 'PowerMill' }, + { value: 'onshape', label: 'Onshape' }, + ]) + }) +}) + +describe('resolveControlScheme', () => { + it.each([ + ['toolpath', { left: 'rotate', middle: 'none', right: 'truck' }], + ['alias', { left: 'rotate', middle: 'truck', right: 'none' }], + ['tinkercad', { left: 'none', middle: 'none', right: 'rotate' }], + ['powermill', { left: 'none', middle: 'rotate', right: 'none' }], + ['onshape', { left: 'none', middle: 'truck', right: 'rotate' }], + ] as const)('%s maps its unmodified mouse buttons', (scheme, mouse) => { + const mapping = mappingFor(scheme) + + expect(mapping.mouse).toMatchObject(mouse) + expect(mapping.mouse.wheel).toBe('zoom') + expect(mapping.touches).toEqual({ one: 'rotate', two: 'dolly-truck', three: 'truck' }) + expect(mapping.usesCadWheel).toBe(false) + expect(mapping.immediate).toBe(false) + }) + + it.each(['fusion', 'inventor'] as const)( + '%s uses the Fusion-style trackpad mapping', + (scheme) => { + const plain = mappingFor(scheme) + const shifted = mappingFor(scheme, { shift: true, ctrl: false }) + + expect(plain.mouse).toMatchObject({ + left: 'none', + middle: 'truck', + right: 'none', + wheel: 'none', + }) + expect(plain.touches).toEqual({ one: 'rotate', two: 'truck', three: 'truck' }) + expect(plain.usesCadWheel).toBe(true) + expect(plain.immediate).toBe(true) + + expect(shifted.mouse.middle).toBe('rotate') + expect(shifted.touches.two).toBe('rotate') + }, + ) + + it('matches SolidWorks middle-button modifiers, with Shift taking precedence', () => { + expect(mappingFor('solidworks').mouse.middle).toBe('rotate') + expect(mappingFor('solidworks', { shift: false, ctrl: true }).mouse.middle).toBe('truck') + expect(mappingFor('solidworks', { shift: true, ctrl: false }).mouse.middle).toBe('zoom') + expect(mappingFor('solidworks', { shift: true, ctrl: true }).mouse.middle).toBe('zoom') + }) + + it('maps Tinkercad and PowerMill Shift gestures to pan', () => { + expect(mappingFor('tinkercad', { shift: true, ctrl: false }).mouse.right).toBe('truck') + expect(mappingFor('powermill', { shift: true, ctrl: false }).mouse.middle).toBe('truck') + }) + + it.each(['toolpath', 'alias', 'solidworks', 'tinkercad', 'powermill', 'onshape'] as const)( + '%s dollies with a perspective camera', + (scheme) => { + expect(mappingFor(scheme, { shift: false, ctrl: false }, false).mouse.wheel).toBe('dolly') + }, + ) +}) diff --git a/packages/viewer/tests/controls.test.ts b/packages/viewer/tests/controls.test.ts new file mode 100644 index 0000000..c01d920 --- /dev/null +++ b/packages/viewer/tests/controls.test.ts @@ -0,0 +1,171 @@ +import CameraControls from 'camera-controls' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { OrthographicCamera } from 'three' +import { ExtendedCameraControls } from '../src/render/controls.js' + +class TestRect { + x: number + y: number + width: number + height: number + left: number + top: number + right: number + bottom: number + + constructor(x = 0, y = 0, width = 1, height = 1) { + this.x = x + this.y = y + this.width = width + this.height = height + this.left = x + this.top = y + this.right = x + width + this.bottom = y + height + } +} + +class TestDocument extends EventTarget { + defaultView = new EventTarget() + pointerLockElement: Element | null = null + + exitPointerLock(): void {} +} + +class TestElement extends EventTarget { + readonly ownerDocument = new TestDocument() + readonly style = { touchAction: '', userSelect: '', webkitUserSelect: '' } + clientWidth = 600 + clientHeight = 400 + + setAttribute(): void {} + removeAttribute(): void {} + getBoundingClientRect(): DOMRect { + return new DOMRect(0, 0, this.clientWidth, this.clientHeight) + } +} + +const keyboardEvent = (shiftKey: boolean, ctrlKey: boolean) => { + const event = new Event('keydown') as KeyboardEvent + Object.defineProperties(event, { + shiftKey: { value: shiftKey }, + ctrlKey: { value: ctrlKey }, + }) + return event +} + +const wheelEvent = ({ ctrlKey = false }: { ctrlKey?: boolean } = {}) => { + const event = new Event('wheel', { cancelable: true }) as WheelEvent + Object.defineProperties(event, { + clientX: { value: 300 }, + clientY: { value: 200 }, + ctrlKey: { value: ctrlKey }, + deltaX: { value: 10 }, + deltaY: { value: 10 }, + deltaMode: { value: 0 }, + }) + return event +} + +const originalDomRect = globalThis.DOMRect + +beforeAll(() => { + Object.assign(globalThis, { DOMRect: TestRect }) +}) + +afterAll(() => { + Object.assign(globalThis, { DOMRect: originalDomRect }) +}) + +const createControls = () => { + const element = new TestElement() + const camera = new OrthographicCamera(-1, 1, 1, -1) + camera.position.set(1, -1, 1) + const controls = new ExtendedCameraControls(camera, element as unknown as HTMLElement) + controls.attach() + return { controls, element } +} + +describe('ExtendedCameraControls preset lifecycle', () => { + it('reapplies SolidWorks modifiers and clears them on window blur', () => { + const { controls, element } = createControls() + controls.applyScheme('solidworks') + + expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.ROTATE) + + element.ownerDocument.defaultView.dispatchEvent(keyboardEvent(false, true)) + expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.TRUCK) + + element.ownerDocument.defaultView.dispatchEvent(keyboardEvent(true, true)) + expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.ZOOM) + + element.ownerDocument.defaultView.dispatchEvent(new Event('blur')) + expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.ROTATE) + + controls.dispose() + }) + + it('removes the custom CAD wheel listener when switching away from Fusion', () => { + const { controls, element } = createControls() + const truck = vi.spyOn(controls, 'truck') + + controls.applyScheme('fusion') + element.dispatchEvent(wheelEvent()) + expect(truck).toHaveBeenCalledOnce() + + controls.applyScheme('toolpath') + truck.mockClear() + element.dispatchEvent(wheelEvent()) + expect(truck).not.toHaveBeenCalled() + + controls.dispose() + }) + + it.each(['fusion', 'inventor'] as const)( + '%s pans, orbits, and pinch-zooms with its custom wheel listener', + (scheme) => { + const { controls, element } = createControls() + const truck = vi.spyOn(controls, 'truck') + const rotate = vi.spyOn(controls, 'rotate') + const zoom = vi.spyOn(controls, 'zoom') + controls.applyScheme(scheme) + + const pan = wheelEvent() + element.dispatchEvent(pan) + expect(pan.defaultPrevented).toBe(true) + expect(truck).toHaveBeenCalledOnce() + expect(rotate).not.toHaveBeenCalled() + expect(zoom).not.toHaveBeenCalled() + + truck.mockClear() + element.ownerDocument.defaultView.dispatchEvent(keyboardEvent(true, false)) + const orbit = wheelEvent() + element.dispatchEvent(orbit) + expect(orbit.defaultPrevented).toBe(true) + expect(truck).not.toHaveBeenCalled() + expect(rotate).toHaveBeenCalledOnce() + expect(zoom).not.toHaveBeenCalled() + + rotate.mockClear() + const pinch = wheelEvent({ ctrlKey: true }) + element.dispatchEvent(pinch) + expect(pinch.defaultPrevented).toBe(true) + expect(truck).not.toHaveBeenCalled() + expect(rotate).not.toHaveBeenCalled() + expect(zoom).toHaveBeenCalledOnce() + + controls.dispose() + }, + ) + + it('does not retain the custom wheel listener after disposal', () => { + const { controls, element } = createControls() + const truck = vi.spyOn(controls, 'truck') + + controls.applyScheme('inventor') + controls.dispose() + element.dispatchEvent(wheelEvent()) + + expect(truck).not.toHaveBeenCalled() + }) +}) From 781178c60b42a2b46eb92962937b0a00916cb7fd Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:33:49 -0400 Subject: [PATCH 13/16] Feature focus depth rendering fix --- .changeset/focus-selection-xray.md | 5 +++++ examples/react-viewer/tests/toolbar.spec.ts | 2 ++ packages/viewer/src/part-mesh.tsx | 4 ++-- packages/viewer/src/render/focus.ts | 8 ++++++++ packages/viewer/src/render/part.ts | 4 ++++ packages/viewer/tests/focus.test.ts | 9 +++++++-- 6 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 .changeset/focus-selection-xray.md diff --git a/.changeset/focus-selection-xray.md b/.changeset/focus-selection-xray.md new file mode 100644 index 0000000..a336b85 --- /dev/null +++ b/.changeset/focus-selection-xray.md @@ -0,0 +1,5 @@ +--- +'@toolpath/viewer': patch +--- + +Keep far-side focused geometry visible through the faded part. diff --git a/examples/react-viewer/tests/toolbar.spec.ts b/examples/react-viewer/tests/toolbar.spec.ts index 67841ec..06c40fc 100644 --- a/examples/react-viewer/tests/toolbar.spec.ts +++ b/examples/react-viewer/tests/toolbar.spec.ts @@ -135,8 +135,10 @@ for (const projection of ['perspective', 'orthographic'] as const) { 'true', ) await expect.poll(async () => Buffer.compare(selected, await canvas.screenshot())).not.toBe(0) + const focused = await canvas.screenshot() await page.getByRole('button', { name: 'Show full part', exact: true }).click() await expect(focus).toHaveAttribute('aria-pressed', 'false') + await expect.poll(async () => Buffer.compare(focused, await canvas.screenshot())).not.toBe(0) }) test(`stock present on mount does not move the section midpoint (${projection})`, async ({ diff --git a/packages/viewer/src/part-mesh.tsx b/packages/viewer/src/part-mesh.tsx index 6404764..ced42d3 100644 --- a/packages/viewer/src/part-mesh.tsx +++ b/packages/viewer/src/part-mesh.tsx @@ -12,7 +12,7 @@ import { type BufferGeometry, Vector3 } from 'three' import type { FeatureTag, PartModel } from './model/types.js' import type { FeatureHighlight, RegionHighlight } from './render/paint.js' import { applyHighlightLayers } from './render/paint.js' -import type { FocusOptions } from './render/focus.js' +import { focusStateKey, type FocusOptions } from './render/focus.js' import { DISABLED_SECTION, type SectionOptions, @@ -255,7 +255,7 @@ export const PartMesh = ({ focusRef.current = focus const selectionRef = useRef(selection) selectionRef.current = selection - const focusKey = `${focus === undefined ? '' : (focus.opacity ?? '')}|${selection.join(' ')}` + const focusKey = focusStateKey(focus, selection) useLayoutEffect(() => { part.setFocus(selectionRef.current, focusRef.current) invalidate() diff --git a/packages/viewer/src/render/focus.ts b/packages/viewer/src/render/focus.ts index 70590ff..abcaf64 100644 --- a/packages/viewer/src/render/focus.ts +++ b/packages/viewer/src/render/focus.ts @@ -14,6 +14,14 @@ export function focusOpacity(options: FocusOptions | undefined): number { return Math.min(Math.max(options?.opacity ?? DEFAULT_FOCUS_OPACITY, 0), 1) } +/** A stable effect key that distinguishes disabled focus from its default mode. */ +export function focusStateKey( + options: FocusOptions | undefined, + selection: readonly FeatureTag[], +): string { + return `${options === undefined ? 'off' : `on:${options.opacity ?? ''}`}|${selection.join(' ')}` +} + /** The union of regions owned by the selected features. */ export function focusedRegions( model: PartModel, diff --git a/packages/viewer/src/render/part.ts b/packages/viewer/src/render/part.ts index 42c9b8b..faf11c9 100644 --- a/packages/viewer/src/render/part.ts +++ b/packages/viewer/src/render/part.ts @@ -3,6 +3,7 @@ import { type BufferGeometry, Color, DataTexture, + DoubleSide, Float32BufferAttribute, Group, LineBasicMaterial, @@ -255,6 +256,9 @@ export function createPart( const focusMaterial = new MeshLambertMaterial({ color: theme.part, emissive: theme.partEmissive, + // A selected face can be viewed through the part from the side its normal + // faces away from. + side: DoubleSide, }) configureShader(focusMaterial, true) configureShader(material, false) diff --git a/packages/viewer/tests/focus.test.ts b/packages/viewer/tests/focus.test.ts index d10a447..1d85168 100644 --- a/packages/viewer/tests/focus.test.ts +++ b/packages/viewer/tests/focus.test.ts @@ -1,7 +1,7 @@ -import { Mesh, MeshLambertMaterial } from 'three' +import { DoubleSide, Mesh, MeshLambertMaterial } from 'three' import { describe, expect, it } from 'vitest' import { parsePartGeometry } from '../src/engine/geometry.js' -import { DEFAULT_FOCUS_OPACITY } from '../src/render/focus.js' +import { DEFAULT_FOCUS_OPACITY, focusStateKey } from '../src/render/focus.js' import { createPart } from '../src/render/part.js' import { DEFAULT_THEME } from '../src/render/theme.js' import { cubeModel, loadMeshFixture } from './fixtures.js' @@ -21,6 +21,10 @@ function faceOn(model: ReturnType, z: 1 | -1) { } describe('selection-driven focus', () => { + it('changes the render effect key when default focus is turned off', () => { + expect(focusStateKey({}, ['bottom-face'])).not.toBe(focusStateKey(undefined, ['bottom-face'])) + }) + it('keeps selected regions solid and fades every other region', async () => { const { model, part } = await loadCube() const top = faceOn(model, 1) @@ -39,6 +43,7 @@ describe('selection-driven focus', () => { expect(focusMesh.visible).toBe(true) expect(focusMaterial.transparent).toBe(false) expect(focusMaterial.depthWrite).toBe(true) + expect(focusMaterial.side).toBe(DoubleSide) }) it('returns to a fully opaque part when there is no selected feature', async () => { From ae6c6be57f68da381993bd668320c7149483b0f0 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:18:57 -0400 Subject: [PATCH 14/16] Add classes for custom toolbar styling --- examples/react-viewer/tests/toolbar.spec.ts | 20 ++++++ packages/viewer/README.md | 42 +++++++++++++ packages/viewer/src/hover-card.tsx | 8 ++- packages/viewer/src/toolbar-icons.tsx | 2 + packages/viewer/src/toolbar.css | 24 ++++---- packages/viewer/src/viewer-toolbar.tsx | 29 +++++++-- packages/viewer/src/viewer.tsx | 12 +++- packages/viewer/tests/styling.test.tsx | 68 +++++++++++++++++++++ 8 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 packages/viewer/tests/styling.test.tsx diff --git a/examples/react-viewer/tests/toolbar.spec.ts b/examples/react-viewer/tests/toolbar.spec.ts index 06c40fc..5669fcc 100644 --- a/examples/react-viewer/tests/toolbar.spec.ts +++ b/examples/react-viewer/tests/toolbar.spec.ts @@ -9,7 +9,27 @@ for (const projection of ['perspective', 'orthographic'] as const) { if (message.type() === 'error') errors.push(message.text()) }) const { canvas, box } = await openViewer(page, `projection=${projection}`) + await expect(page.locator('.viewer-root')).toHaveAttribute('data-viewer-root', 'true') + await expect(page.locator('.viewer-canvas-container')).toHaveAttribute( + 'data-viewer-canvas-container', + 'true', + ) + await expect(page.locator('.viewer-canvas-frame')).toHaveAttribute( + 'data-viewer-canvas-frame', + 'true', + ) + await expect(canvas).toHaveClass('viewer-canvas') + await expect(canvas).toHaveAttribute('data-viewer-canvas', 'true') const controls = page.getByRole('group', { name: 'Viewer controls', exact: true }) + await expect(controls).toHaveAttribute('data-viewer-toolbar-controls', 'true') + await expect(page.locator('.viewer-toolbar-stack')).toHaveAttribute( + 'data-viewer-toolbar', + 'true', + ) + await expect(page.getByRole('button', { name: 'Show stock', exact: true })).toHaveAttribute( + 'data-viewer-toolbar-action', + 'stock', + ) const toolbarBox = await controls.boundingBox() expect(toolbarBox!.y).toBeGreaterThan(box.y + box.height * 0.75) const original = await canvas.screenshot() diff --git a/packages/viewer/README.md b/packages/viewer/README.md index dd4d799..142735a 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -259,6 +259,48 @@ The toolbar owns no application state; pass the current values and callbacks fro `children` are rendered above the standard controls for app-specific options. `BananaButton` is also exported for applications that want the bundled banana-for-scale control elsewhere. +#### Styling hooks + +Every viewer, hover-card, and toolbar DOM boundary has a stable class name. Your `className` on +``, ``, or `` is appended to the component's own class; it never +replaces the hook. The toolbar stylesheet uses the same names, so import it for the default look or +override any of these selectors in your application stylesheet. + +| Component | Class | Element | +| ----------------- | ------------------------- | --------------------------------------------------- | +| `` | `viewer-root` | Wrapper around the canvas | +| `` | `viewer-canvas-container` | R3F canvas event container | +| `` | `viewer-canvas-frame` | R3F's canvas-sizing frame | +| `` | `viewer-canvas` | The `` itself | +| `` | `viewer-hover-card` | Cursor-following tooltip shell | +| `` | `viewer-toolbar-stack` | Outer stack, including app-supplied option children | +| `` | `viewer-toolbar` | Standard-controls group | +| `` | `viewer-toolbar-button` | Each standard control button | +| `` | `viewer-toolbar-icon` | SVG within a standard control | +| `` | `viewer-toolbar-tooltip` | Label shown on button hover/focus | +| `` | `viewer-toolbar-divider` | Separator between control groups | + +The following `data-*` attributes identify generated elements without depending on labels, which +can change with state or localization: + +| Attribute | Element and values | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `data-viewer-root="true"` | `` wrapper | +| `data-viewer-canvas-container="true"` | R3F canvas event container | +| `data-viewer-canvas-frame="true"` | R3F canvas-sizing frame | +| `data-viewer-canvas="true"` | The `` itself | +| `data-viewer-hover-card="true"` | `` shell | +| `data-viewer-toolbar="true"` | Toolbar stack | +| `data-viewer-toolbar-controls="true"` | Standard-controls group | +| `data-viewer-toolbar-action` | Standard button: `stock`, `axes`, `grid`, `banana`, `directions`, `hover`, `focus`, `wireframe`, `section`, `measure`, `fit`, `reset`, or `top` | +| `data-viewer-toolbar-icon="true"` | Standard button SVG | +| `data-viewer-toolbar-tooltip="true"` | Standard button tooltip | +| `data-viewer-toolbar-divider="true"` | Toolbar separator | + +Toggle buttons also carry standard `aria-pressed="true"` or `"false"`; use that to style their +current state. `Fit`, `Reset`, and `Top view` are actions rather than toggles, so they omit the +attribute. + ### `` The canvas that holds everything. Import it from `@toolpath/viewer`. diff --git a/packages/viewer/src/hover-card.tsx b/packages/viewer/src/hover-card.tsx index 9c00b37..4715b1b 100644 --- a/packages/viewer/src/hover-card.tsx +++ b/packages/viewer/src/hover-card.tsx @@ -6,6 +6,7 @@ export interface HoverCardProps { pick: PartPick | null /** Card content belongs to the application that understands the feature data. */ children: (pick: PartPick) => ReactNode + /** Added beside the stable `viewer-hover-card` class. */ className?: string offset?: number } @@ -35,7 +36,12 @@ export const HoverCard = ({ pick, children, className, offset = 16 }: HoverCardP } return ( -

+
{children(pick)}
) diff --git a/packages/viewer/src/toolbar-icons.tsx b/packages/viewer/src/toolbar-icons.tsx index 0a81c04..fb534f3 100644 --- a/packages/viewer/src/toolbar-icons.tsx +++ b/packages/viewer/src/toolbar-icons.tsx @@ -8,6 +8,8 @@ interface SvgIconProps { const SvgIcon = ({ viewBox = '0 0 24 24', fill = 'none', children }: SvgIconProps) => ( ( - + {label} @@ -34,6 +41,8 @@ export const BananaButton = ({ shown, onClick }: BananaButtonProps) => ( ) export interface ViewerToolbarProps { + /** Added to the outer toolbar stack, beside `viewer-toolbar-stack`. */ + className?: string stock: boolean axes: boolean grid: boolean @@ -62,9 +71,17 @@ export interface ViewerToolbarProps { /** The standard toolbar for the viewer's camera, display, and analysis controls. */ export const ViewerToolbar = (props: ViewerToolbarProps) => ( -
+
{props.children} -
+
( {props.onBanana ? ( ) : null} - + ( pressed={props.measuring} onClick={props.onMeasure} /> - + diff --git a/packages/viewer/src/viewer.tsx b/packages/viewer/src/viewer.tsx index a77bd37..8bda43c 100644 --- a/packages/viewer/src/viewer.tsx +++ b/packages/viewer/src/viewer.tsx @@ -674,7 +674,8 @@ export const Viewer = forwardRef(function Viewer( it. */}
{ if (event.target instanceof HTMLCanvasElement) event.preventDefault() @@ -682,6 +683,8 @@ export const Viewer = forwardRef(function Viewer( style={{ height: '100%', width: '100%', ...style }} > (function Viewer( // looks hollow. `localClippingEnabled` is what lets a material carry // its own clipping plane rather than the whole scene sharing one. gl={{ antialias: true, alpha: true, stencil: true, localClippingEnabled: true }} + onCreated={({ gl }) => { + const canvas = gl.domElement + canvas.classList.add('viewer-canvas') + canvas.dataset.viewerCanvas = 'true' + canvas.parentElement?.classList.add('viewer-canvas-frame') + canvas.parentElement?.setAttribute('data-viewer-canvas-frame', 'true') + }} // Two guards, and both are about gestures that are not a click. // // Only the primary button puts a selection down. R3F treats diff --git a/packages/viewer/tests/styling.test.tsx b/packages/viewer/tests/styling.test.tsx new file mode 100644 index 0000000..77c1c96 --- /dev/null +++ b/packages/viewer/tests/styling.test.tsx @@ -0,0 +1,68 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { HoverCard } from '../src/hover-card.js' +import { ViewerToolbar } from '../src/viewer-toolbar.js' + +describe('viewer styling hooks', () => { + it('keeps stable toolbar classes and action identifiers beside a caller class', () => { + const markup = renderToStaticMarkup( + {}} + onReset={() => {}} + onTop={() => {}} + onStock={() => {}} + onAxes={() => {}} + onGrid={() => {}} + onDirections={() => {}} + onHover={() => {}} + onFocus={() => {}} + onWireframe={() => {}} + onSection={() => {}} + onMeasure={() => {}} + />, + ) + + expect(markup).toContain('class="viewer-toolbar-stack app-toolbar"') + expect(markup).toContain('data-viewer-toolbar="true"') + expect(markup).toContain('class="viewer-toolbar-button"') + expect(markup).toContain('data-viewer-toolbar-action="stock"') + expect(markup).toContain('class="viewer-toolbar-icon"') + expect(markup).toContain('class="viewer-toolbar-tooltip"') + expect(markup).toContain('class="viewer-toolbar-divider"') + }) + + it('gives hover cards a default class without replacing a caller class', () => { + const markup = renderToStaticMarkup( + + {() => 'Details'} + , + ) + + expect(markup).toContain('class="viewer-hover-card app-hover-card"') + expect(markup).toContain('data-viewer-hover-card="true"') + }) +}) From 2a9a807ebc49346b6d533e154c6f0963fb5af972 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:40:39 -0400 Subject: [PATCH 15/16] Merge section and measure options --- examples/react-viewer/package.json | 1 + .../react-viewer/src/analysis-options.tsx | 95 +++++++++++++++++++ examples/react-viewer/src/main.tsx | 90 ++++++------------ examples/react-viewer/src/style.css | 64 ++++++++++++- examples/react-viewer/tests/viewer.spec.ts | 30 +++++- pnpm-lock.yaml | 8 ++ 6 files changed, 221 insertions(+), 67 deletions(-) create mode 100644 examples/react-viewer/src/analysis-options.tsx diff --git a/examples/react-viewer/package.json b/examples/react-viewer/package.json index 6b8cf65..92310d8 100644 --- a/examples/react-viewer/package.json +++ b/examples/react-viewer/package.json @@ -10,6 +10,7 @@ "test": "pnpm build && playwright test" }, "dependencies": { + "@phosphor-icons/react": "2.1.10", "@react-three/drei": "10.7.8", "@react-three/fiber": "9.7.0", "@toolpath/viewer": "workspace:*", diff --git a/examples/react-viewer/src/analysis-options.tsx b/examples/react-viewer/src/analysis-options.tsx new file mode 100644 index 0000000..74e22fb --- /dev/null +++ b/examples/react-viewer/src/analysis-options.tsx @@ -0,0 +1,95 @@ +import { AngleIcon, RulerIcon, TrashIcon } from '@phosphor-icons/react' +import type { MeasureMode, SectionState } from '@toolpath/viewer' + +interface AnalysisOptionsProps { + sectioning: boolean + measuring: boolean + cut: SectionState | null + offset: number + measureMode: MeasureMode + onOffsetChange: (offset: number) => void + onClearCut: () => void + onMeasureModeChange: (mode: MeasureMode) => void + onClearMeasurements: () => void +} + +/** The single options card shared by sectioning and measuring. */ +export const AnalysisOptions = ({ + sectioning, + measuring, + cut, + offset, + measureMode, + onOffsetChange, + onClearCut, + onMeasureModeChange, + onClearMeasurements, +}: AnalysisOptionsProps) => { + if (!sectioning && !measuring) return null + + return ( +
+ {sectioning ? ( + cut ? ( + <> + onOffsetChange(Number(event.target.value))} + /> + + + ) : ( + Click a face or a plane · Esc clears + ) + ) : null} + {sectioning && measuring ?
+ ) +} diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index 7fbb35c..562def7 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -21,7 +21,6 @@ import { Viewer, CONTROL_SCHEME_OPTIONS, measurementLabel, - sectionMeasurement, type MeasureMode, type Measurement, type PartModel, @@ -33,6 +32,7 @@ import { type StockPosition, type ViewerHandle, } from '@toolpath/viewer' +import { AnalysisOptions } from './analysis-options' import { MODELS, modelFromQuery } from './models' import './style.css' @@ -198,13 +198,13 @@ const App = () => { const [sectioning, setSectioning] = useState(false) /** * Measuring is the same shape as sectioning: a mode the toolbar enters, with - * the selection put down on the way in. The list is the tool's own — - * `` below is given no `measurements` — and `measured` is only - * what it reports back, for the readout. + * the selection put down on the way in. The list is controlled here so the + * panel can clear it with its trash button. */ const [measuring, setMeasuring] = useState(false) const [measureMode, setMeasureMode] = useState('distance') const [measured, setMeasured] = useState([]) + const [measureInstance, setMeasureInstance] = useState(0) const [direction, setDirection] = useState(null) const [showStock, setShowStock] = useState(params.get('stock') === 'on') const [showAxes, setShowAxes] = useState(true) @@ -466,8 +466,7 @@ const App = () => { } else { if (!measuring) heldSelection.current = selected setSelected([]) - setMeasuring(false) - setMeasured([]) + if (!measuring) setMeasured([]) } setSectioning((on) => !on) }} @@ -485,59 +484,23 @@ const App = () => { setMeasuring((on) => !on) }} > - {sectioning ? ( -
- {cut ? ( - <> - - - - ) : ( - Click a face or a plane · Esc clears - )} -
- ) : null} - {measuring ? ( -
- - - - {measureMode === 'distance' ? 'Click two points' : 'Click end, vertex, end'} · Shift - locks an axis · Del removes last · Esc drops - -
- ) : null} + { + setOffset(next) + if (cut) viewerRef.current?.setSection(sweepTo(cut, next)) + }} + onClearCut={() => viewerRef.current?.setSection(null)} + onMeasureModeChange={setMeasureMode} + onClearMeasurements={() => { + setMeasured([]) + setMeasureInstance((instance) => instance + 1) + }} + /> {showDirections && !sectioning && !measuring ? (
{ onPickDirection={(index) => setDirection((held) => (held === index ? null : index))} /> {sectioning ? : null} - {measuring ? : null} + {measuring ? ( + + ) : null} {showGrid ? : null} {banana ? ( diff --git a/examples/react-viewer/src/style.css b/examples/react-viewer/src/style.css index 8ceed98..135454d 100644 --- a/examples/react-viewer/src/style.css +++ b/examples/react-viewer/src/style.css @@ -123,11 +123,6 @@ strong { cursor: pointer; padding: 0.45rem 0.65rem; } -.section-measurement { - color: #eef0f6; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} .direction-dot { display: inline-block; width: 0.65rem; @@ -172,10 +167,69 @@ strong { .stock-allowance select:focus-visible, .detail-button:focus-visible, .viewer-tool-options input:focus-visible, +.viewer-tool-options button:focus-visible, .model-picker select:focus-visible { outline: 2px solid #83a9ff; outline-offset: 2px; } + +.analysis-options { + gap: 0.75rem; + padding: 0.7rem 1rem; +} +.cut-slider { + width: min(20rem, 42vw); + accent-color: #424457; +} +.viewer-toolbar-stack .tool-icon-button { + display: grid; + place-items: center; + width: 2.25rem; + height: 2.25rem; + padding: 0.4rem; + border-color: transparent; + background: transparent; + color: #8f96b4; +} +.viewer-toolbar-stack .tool-icon-button:hover { + color: #eef0f6; +} +.viewer-tool-icon { + width: 1.45rem; + height: 1.45rem; +} +.analysis-divider { + width: 1px; + height: 2.25rem; + margin: 0 0.35rem; + background: #aeb3c766; +} +.measure-mode-toggle { + display: flex; + align-items: center; + padding: 0.2rem; + border-radius: 0.65rem; + background: #d8dbe566; +} +.measure-mode-toggle button { + display: grid; + place-items: center; + width: 4.2rem; + height: 2.4rem; + padding: 0.45rem; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: #777d9d; +} +.measure-mode-toggle button[aria-pressed='true'] { + background: #ffffff; + color: #424457; + box-shadow: 0 1px 3px #20273740; +} +.measure-mode-toggle button:hover { + color: #424457; +} .model-picker { display: grid; gap: 0.35rem; diff --git a/examples/react-viewer/tests/viewer.spec.ts b/examples/react-viewer/tests/viewer.spec.ts index 934451c..14e45b5 100644 --- a/examples/react-viewer/tests/viewer.spec.ts +++ b/examples/react-viewer/tests/viewer.spec.ts @@ -152,7 +152,7 @@ test('selects a feature and responds to CAD camera navigation', async ({ page }) await expect(hovered).toContainText('none') await canvas.click({ position: on(box, CENTRE) }) await expect(cut).toContainText('Part surface') - await expect(page.getByLabel('Cut amount')).toHaveText(/^\d+\.\d{2} mm$/) + await expect(page.getByText('Cut depth', { exact: true })).toHaveCount(0) await expect(selected).toContainText('none') await canvas.click({ position: on(box, ONE) }) await expect(selected).toContainText('none') @@ -162,7 +162,6 @@ test('selects a feature and responds to CAD camera navigation', async ({ page }) await page.getByRole('slider').fill('0.5') await expect(cut).toContainText('Part surface') await expect(cut).not.toContainText('0.22 mm') - await expect(page.getByLabel('Cut amount')).not.toHaveText('0.22 mm') await page.keyboard.press('Escape') await expect(cut).toContainText('none') await canvas.click({ position: on(box, CENTRE) }) @@ -287,6 +286,10 @@ test('measures a distance between two clicks, without selecting either face', as await canvas.click({ position: on(box, OTHER) }) await expect(measured).toContainText('°') + await page.getByRole('button', { name: 'Clear measurements' }).click() + await expect(measured).toContainText('none') + await expect(labels).toHaveCount(0) + await page.getByRole('button', { name: 'Exit measure' }).click() await expect(measured).toContainText('off') await expect(labels).toHaveCount(0) @@ -350,6 +353,9 @@ test('waits for a cut to be chosen before measuring beside the section tool', as await page.getByRole('button', { name: 'Section' }).click() await page.getByRole('button', { name: 'Measure', exact: true }).click() + await expect( + page.getByRole('group', { name: 'Section and measure options', exact: true }), + ).toHaveCount(1) await expect(cut).toContainText('none') await expect(measured).toContainText('none') @@ -384,6 +390,26 @@ test('waits for a cut to be chosen before measuring beside the section tool', as await expect(measured).toContainText('off') }) +test('merges the analysis panel regardless of which tool is entered first', async ({ page }) => { + await openViewer(page) + + await page.getByRole('button', { name: 'Measure', exact: true }).click() + await page.getByRole('button', { name: 'Section', exact: true }).click() + + await expect( + page.getByRole('group', { name: 'Section and measure options', exact: true }), + ).toHaveCount(1) + await expect(page.getByRole('group', { name: 'Measurement type', exact: true })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Exit measure', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await expect(page.getByRole('button', { name: 'Exit section', exact: true })).toHaveAttribute( + 'aria-pressed', + 'true', + ) +}) + test('Toolpath pans with the right button, from wherever the drag starts', async ({ page }) => { const { canvas, box } = await openViewer(page) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0cd4cf..93bddce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: examples/react-viewer: dependencies: + '@phosphor-icons/react': + specifier: 2.1.10 + version: 2.1.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@react-three/drei': specifier: 10.7.8 version: 10.7.8(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1))(@types/react@19.2.18)(@types/three@0.185.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1) @@ -6907,6 +6910,11 @@ snapshots: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + '@phosphor-icons/react@2.1.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 From a647f159125c97ad40d0696075474fa68b6b0a43 Mon Sep 17 00:00:00 2001 From: nathan-vandrei <87823030+dementive@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:02:13 -0400 Subject: [PATCH 16/16] Make toolbar more composable --- .changeset/viewer-toolbar.md | 2 +- examples/react-viewer/src/main.tsx | 348 +++++++++++++----------- packages/viewer/README.md | 84 ++++-- packages/viewer/src/index.ts | 9 +- packages/viewer/src/viewer-toolbar.tsx | 361 ++++++++++++++++--------- packages/viewer/tests/styling.test.tsx | 78 ++++-- 6 files changed, 539 insertions(+), 343 deletions(-) diff --git a/.changeset/viewer-toolbar.md b/.changeset/viewer-toolbar.md index 2b291ad..a069602 100644 --- a/.changeset/viewer-toolbar.md +++ b/.changeset/viewer-toolbar.md @@ -2,4 +2,4 @@ '@toolpath/viewer': minor --- -Add a shared controlled viewer toolbar with camera, display, analysis, and banana-for-scale controls. +Add a viewer-scoped, composable toolbar with camera, display, analysis, and banana-for-scale controls. diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx index 562def7..9264763 100644 --- a/examples/react-viewer/src/main.tsx +++ b/examples/react-viewer/src/main.tsx @@ -15,6 +15,7 @@ import { DirectionArrows, ViewCube, ViewerToolbar, + ViewerToolbarProvider, MeasureTool, PartMesh, SectionTool, @@ -31,6 +32,7 @@ import { type SectionState, type StockPosition, type ViewerHandle, + type ViewerToolbarControls, } from '@toolpath/viewer' import { AnalysisOptions } from './analysis-options' import { MODELS, modelFromQuery } from './models' @@ -239,6 +241,63 @@ const App = () => { (next: CameraState) => setPose((held) => (sameCamera(held, next) ? held : next)), [], ) + const toolbarControls: ViewerToolbarControls = { + fit: { onClick: () => viewerRef.current?.fit() }, + reset: { onClick: () => viewerRef.current?.reset() }, + top: { onClick: () => viewerRef.current?.setView('top') }, + stock: { pressed: showStock, onClick: () => setShowStock((on) => !on) }, + axes: { pressed: showAxes, onClick: () => setShowAxes((on) => !on) }, + grid: { pressed: showGrid, onClick: () => setShowGrid((on) => !on) }, + banana: { pressed: banana, onClick: () => setBanana((shown) => !shown) }, + directions: { + pressed: showDirections, + onClick: () => { + setShowDirections((on) => !on) + setDirection(null) + setSelected([]) + heldSelection.current = [] + setWireframe(false) + }, + }, + hover: { pressed: featureHover, onClick: () => setFeatureHover((enabled) => !enabled) }, + focus: { pressed: focus, onClick: () => setFocus((enabled) => !enabled) }, + wireframe: { + pressed: wireframe, + onClick: () => { + setWireframe((on) => !on) + setShowDirections(false) + setDirection(null) + }, + }, + section: { + pressed: sectioning, + onClick: () => { + if (sectioning) { + viewerRef.current?.setSection(null) + setSelected(heldSelection.current) + } else { + if (!measuring) heldSelection.current = selected + setSelected([]) + if (!measuring) setMeasured([]) + } + setSectioning((on) => !on) + }, + }, + measure: { + pressed: measuring, + onClick: () => { + if (measuring) { + setMeasured([]) + setSelected(heldSelection.current) + } else if (!sectioning) { + heldSelection.current = selected + setSelected([]) + viewerRef.current?.setSection(null) + } + setMeasuring((on) => !on) + }, + }, + } return (
@@ -424,116 +483,78 @@ const App = () => {

- - {(pick) => } - - viewerRef.current?.fit()} - onReset={() => viewerRef.current?.reset()} - onTop={() => viewerRef.current?.setView('top')} - onStock={() => setShowStock((on) => !on)} - onAxes={() => setShowAxes((on) => !on)} - onGrid={() => setShowGrid((on) => !on)} - onBanana={() => setBanana((shown) => !shown)} - onDirections={() => { - setShowDirections((on) => !on) - setDirection(null) - setSelected([]) - heldSelection.current = [] - setWireframe(false) - }} - onHover={() => setFeatureHover((enabled) => !enabled)} - onFocus={() => setFocus((enabled) => !enabled)} - onWireframe={() => { - setWireframe((on) => !on) - setShowDirections(false) - setDirection(null) - }} - onSection={() => { - if (sectioning) { - viewerRef.current?.setSection(null) - setSelected(heldSelection.current) - } else { - if (!measuring) heldSelection.current = selected - setSelected([]) - if (!measuring) setMeasured([]) - } - setSectioning((on) => !on) - }} - onMeasure={() => { - if (measuring) { - setMeasured([]) - setSelected(heldSelection.current) - } else { - if (!sectioning) { - heldSelection.current = selected - setSelected([]) - viewerRef.current?.setSection(null) - } - } - setMeasuring((on) => !on) - }} - > - { - setOffset(next) - if (cut) viewerRef.current?.setSection(sweepTo(cut, next)) - }} - onClearCut={() => viewerRef.current?.setSection(null)} - onMeasureModeChange={setMeasureMode} - onClearMeasurements={() => { - setMeasured([]) - setMeasureInstance((instance) => instance + 1) - }} - /> - {showDirections && !sectioning && !measuring ? ( -
- - {part.model.candidateDirections.map((axis, index) => ( - ))} -
- ) : null} -
- {/* + {part.model.candidateDirections.map((axis, index) => ( + + ))} +
+ ) : null} + + + + + + + + + + + + + + + + + + + {/* Perspective by default here, and the pin is the point rather than the value. @@ -556,66 +577,67 @@ const App = () => { that are on with it: the double-click re-target, and the pivot marker under `?orbitTarget=on`. */} - setSelected([])} - > - - { - setCut(state.enabled ? state : null) - if (state.enabled) setOffset(state.offset) - }} - onHover={(pick: PartPick | null) => { - setHovered(pick ? [...pick.owners] : []) - setHoverPick(pick) - }} - onPick={(pick: PartPick) => setSelected([...pick.ranked])} - /> - {showStock ? ( - setSelected([])} + > + + { + setCut(state.enabled ? state : null) + if (state.enabled) setOffset(state.offset) + }} + onHover={(pick: PartPick | null) => { + setHovered(pick ? [...pick.owners] : []) + setHoverPick(pick) + }} + onPick={(pick: PartPick) => setSelected([...pick.ranked])} /> - ) : null} - setDirection((held) => (held === index ? null : index))} - /> - {sectioning ? : null} - {measuring ? ( - + ) : null} + setDirection((held) => (held === index ? null : index))} /> - ) : null} - {showGrid ? : null} - {banana ? ( - - - - ) : null} - {showAxes ? : null} - - + {sectioning ? : null} + {measuring ? ( + + ) : null} + {showGrid ? : null} + {banana ? ( + + + + ) : null} + {showAxes ? : null} + + +
) diff --git a/packages/viewer/README.md b/packages/viewer/README.md index 142735a..81d7690 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -247,17 +247,45 @@ contextual Section/Measure controls. ### `` -`ViewerToolbar` is the standard controlled toolbar for the viewer's camera, stock, display, section, -and measurement controls. Import its stylesheet alongside your application stylesheet: +`ViewerToolbar` is a composable, viewer-scoped toolbar for camera, display, section, and measurement +controls. Import its stylesheet alongside your application stylesheet: ```tsx import '@toolpath/viewer/toolbar.css' -import { ViewerToolbar } from '@toolpath/viewer' +import { ViewerToolbar, ViewerToolbarProvider } from '@toolpath/viewer' ``` -The toolbar owns no application state; pass the current values and callbacks from the host app. Its -`children` are rendered above the standard controls for app-specific options. `BananaButton` is -also exported for applications that want the bundled banana-for-scale control elsewhere. +Give `ViewerToolbarProvider` the current values and callbacks from the host app, then place the +toolbar and viewer inside it. The toolbar owns no application state or behavior: it only reads the +provider. Omit `children` for every configured control in the standard order, or compose the +individual buttons to use only the controls and order your application needs. + +```tsx + setShowStock((shown) => !shown) }, + measure: { pressed: measuring, onClick: toggleMeasuring }, + fit: { onClick: () => viewer.current?.fit() }, + }} +> + + + + + + + + + … + +``` + +`ViewerToolbar.Controls`, `Divider`, and the thirteen `*Button` components are available on +`ViewerToolbar`: `StockButton`, `AxesButton`, `GridButton`, `BananaButton`, `DirectionsButton`, +`HoverButton`, `FocusButton`, `WireframeButton`, `SectionButton`, `MeasureButton`, `FitButton`, +`ResetButton`, and `TopButton`. A rendered button requires its matching provider control. For an +application-specific toolbar component, `useViewerToolbar()` reads the same controls from inside +the provider. #### Styling hooks @@ -266,19 +294,19 @@ Every viewer, hover-card, and toolbar DOM boundary has a stable class name. Your replaces the hook. The toolbar stylesheet uses the same names, so import it for the default look or override any of these selectors in your application stylesheet. -| Component | Class | Element | -| ----------------- | ------------------------- | --------------------------------------------------- | -| `` | `viewer-root` | Wrapper around the canvas | -| `` | `viewer-canvas-container` | R3F canvas event container | -| `` | `viewer-canvas-frame` | R3F's canvas-sizing frame | -| `` | `viewer-canvas` | The `` itself | -| `` | `viewer-hover-card` | Cursor-following tooltip shell | -| `` | `viewer-toolbar-stack` | Outer stack, including app-supplied option children | -| `` | `viewer-toolbar` | Standard-controls group | -| `` | `viewer-toolbar-button` | Each standard control button | -| `` | `viewer-toolbar-icon` | SVG within a standard control | -| `` | `viewer-toolbar-tooltip` | Label shown on button hover/focus | -| `` | `viewer-toolbar-divider` | Separator between control groups | +| Component | Class | Element | +| ----------------- | ------------------------- | --------------------------------- | +| `` | `viewer-root` | Wrapper around the canvas | +| `` | `viewer-canvas-container` | R3F canvas event container | +| `` | `viewer-canvas-frame` | R3F's canvas-sizing frame | +| `` | `viewer-canvas` | The `` itself | +| `` | `viewer-hover-card` | Cursor-following tooltip shell | +| `` | `viewer-toolbar-stack` | Outer toolbar stack | +| `` | `viewer-toolbar` | Controls group | +| `` | `viewer-toolbar-button` | Each standard control button | +| `` | `viewer-toolbar-icon` | SVG within a standard control | +| `` | `viewer-toolbar-tooltip` | Label shown on button hover/focus | +| `` | `viewer-toolbar-divider` | Separator between control groups | The following `data-*` attributes identify generated elements without depending on labels, which can change with state or localization: @@ -291,7 +319,7 @@ can change with state or localization: | `data-viewer-canvas="true"` | The `` itself | | `data-viewer-hover-card="true"` | `` shell | | `data-viewer-toolbar="true"` | Toolbar stack | -| `data-viewer-toolbar-controls="true"` | Standard-controls group | +| `data-viewer-toolbar-controls="true"` | Controls group | | `data-viewer-toolbar-action` | Standard button: `stock`, `axes`, `grid`, `banana`, `directions`, `hover`, `focus`, `wireframe`, `section`, `measure`, `fit`, `reset`, or `top` | | `data-viewer-toolbar-icon="true"` | Standard button SVG | | `data-viewer-toolbar-tooltip="true"` | Standard button tooltip | @@ -1074,12 +1102,13 @@ is a complete app built this way, with no API key needed. ### Hooks -| Hook | Use inside `` to… | -| --------------------- | -------------------------------------------------------------------------------- | -| `useViewerControls()` | Get `fit`, `reset`, `setView`, `setViewDirection`, `frameBox`, and `setSection`. | -| `useSectionStore()` | Read, set, or subscribe to the viewer's own cut. | -| `useContentBox()` | Get the part's bounding box (a `THREE.Box3`, empty until loaded). | -| `useTapGuard()` | Check whether a pointer event was a click and not a drag. | +| Hook | Where | What it does | +| --------------------- | -------------------------------- | -------------------------------------------------------------------------------- | +| `useViewerControls()` | Inside `` | Get `fit`, `reset`, `setView`, `setViewDirection`, `frameBox`, and `setSection`. | +| `useViewerToolbar()` | Inside `` | Get the actions and state supplied to the provider. | +| `useSectionStore()` | Inside `` | Read, set, or subscribe to the viewer's own cut. | +| `useContentBox()` | Inside `` | Get the part's bounding box (a `THREE.Box3`, empty until loaded). | +| `useTapGuard()` | Inside `` | Check whether a pointer event was a click and not a drag. | ### Helpers @@ -1109,7 +1138,8 @@ use. They're listed in `dist/index.d.ts`. `SectionState`, `SectionPlacement`, `SectionToolProps`, `SectionStore`, `MeasureToolProps`, `MeasureMode`, `Measurement`, `DistanceMeasurement`, `AngleMeasurement`, `Snap`, `SnapKind`, `ViewerTheme`, `ViewName`, `DirectionArrowsProps`, `NamedDirection`, `GridProps`, `AxesProps`, -`ViewCubeProps`, `ViewerToolbarProps`, `BananaButtonProps`. +`ViewCubeProps`, `ViewerToolbarProps`, `ViewerToolbarControlsProps`, `ViewerToolbarControls`, +`ViewerToolbarControl`. `FeatureType` and `ShapeKind` accept any string, because newer Engine versions add new values. Handle values you don't recognize. diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 40591cf..4524fc2 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -6,8 +6,13 @@ export { EnginePart, normalizePartReport, smoothRegionNormals } from './engine/i export { regionAdjacency } from './render/adjacency.js' export { PartMesh } from './part-mesh.js' export { HoverCard } from './hover-card.js' -export { BananaButton, ViewerToolbar } from './viewer-toolbar.js' -export type { BananaButtonProps, ViewerToolbarProps } from './viewer-toolbar.js' +export { ViewerToolbar, ViewerToolbarProvider, useViewerToolbar } from './viewer-toolbar.js' +export type { + ViewerToolbarControl, + ViewerToolbarControls, + ViewerToolbarControlsProps, + ViewerToolbarProps, +} from './viewer-toolbar.js' export { Stock, BoxStock } from './stock.js' export type { StockProps, BoxStockProps } from './stock.js' export { Banana, BANANA_MODEL_URL } from './banana.js' diff --git a/packages/viewer/src/viewer-toolbar.tsx b/packages/viewer/src/viewer-toolbar.tsx index fd09956..0ce169a 100644 --- a/packages/viewer/src/viewer-toolbar.tsx +++ b/packages/viewer/src/viewer-toolbar.tsx @@ -1,149 +1,260 @@ -import type { ReactNode } from 'react' +import { createContext, useContext } from 'react' +import type { PropsWithChildren, ReactNode } from 'react' import { ToolbarIcon, type ToolbarIconName } from './toolbar-icons.js' -interface ToolbarButtonProps { - icon: ToolbarIconName - label: string +/** One action a toolbar button can read and invoke. */ +export interface ViewerToolbarControl { + /** Whether a toggle-style control is currently active. */ pressed?: boolean + /** Invoked when the control is clicked. */ onClick: () => void } -const ToolbarButton = ({ icon, label, pressed, onClick }: ToolbarButtonProps) => ( - +/** + * The state and actions that toolbar controls read from their enclosing + * {@link ViewerToolbarProvider}. + * + * Controls are optional so an application can expose only the buttons it + * renders. Rendering a button without its matching control is an error. + */ +export interface ViewerToolbarControls { + stock?: ViewerToolbarControl + axes?: ViewerToolbarControl + grid?: ViewerToolbarControl + banana?: ViewerToolbarControl + directions?: ViewerToolbarControl + hover?: ViewerToolbarControl + focus?: ViewerToolbarControl + wireframe?: ViewerToolbarControl + section?: ViewerToolbarControl + measure?: ViewerToolbarControl + fit?: ViewerToolbarControl + reset?: ViewerToolbarControl + top?: ViewerToolbarControl +} + +const ViewerToolbarContext = createContext(null) + +/** Supplies viewer state and callbacks to a toolbar and its compound controls. */ +export const ViewerToolbarProvider = ({ + controls, + children, +}: PropsWithChildren<{ controls: ViewerToolbarControls }>) => ( + {children} ) -export interface BananaButtonProps { - /** Whether the banana for scale is currently shown. */ - shown: boolean - onClick: () => void +/** + * Read the state and callbacks configured for the enclosing viewer toolbar. + * + * Use this to build a toolbar control with an application's own component kit. + */ +export const useViewerToolbar = (): ViewerToolbarControls => { + const controls = useContext(ViewerToolbarContext) + if (!controls) throw new Error('useViewerToolbar must be used inside ') + return controls +} + +interface ToolbarButtonProps { + action: keyof ViewerToolbarControls + icon: ToolbarIconName + label: (pressed: boolean) => string + toggle?: boolean +} + +const buttonName = (action: keyof ViewerToolbarControls): string => + `${action.slice(0, 1).toUpperCase()}${action.slice(1)}Button` + +const ToolbarButton = ({ action, icon, label, toggle = true }: ToolbarButtonProps) => { + const control = useViewerToolbar()[action] + if (!control) { + throw new Error(` requires a '${action}' toolbar control`) + } + + const pressed = control.pressed ?? false + return ( + + ) } -/** The viewer toolbar control for showing a banana beside a 3D part. */ -export const BananaButton = ({ shown, onClick }: BananaButtonProps) => ( +const StockButton = () => ( (pressed ? 'Hide stock' : 'Show stock')} + /> +) +const AxesButton = () => ( + (pressed ? 'Hide axis' : 'Show axis')} + /> +) +const GridButton = () => ( + (pressed ? 'Hide grid' : 'Show grid')} + /> +) +const BananaButton = () => ( + (pressed ? 'Banana for scale (on)' : 'Banana for scale')} + /> +) +const DirectionsButton = () => ( + 'Highlight faces by direction'} + /> +) +const HoverButton = () => ( + (pressed ? 'Disable feature hover' : 'Enable feature hover')} /> ) +const FocusButton = () => ( + (pressed ? 'Show full part' : 'Focus selection')} + /> +) +const WireframeButton = () => ( + 'Wireframe'} /> +) +const SectionButton = () => ( + (pressed ? 'Exit section' : 'Section')} + /> +) +const MeasureButton = () => ( + (pressed ? 'Exit measure' : 'Measure')} + /> +) +const FitButton = () => 'Fit'} toggle={false} /> +const ResetButton = () => ( + 'Reset'} toggle={false} /> +) +const TopButton = () => ( + 'Top view'} toggle={false} /> +) + +/** A visual separator between toolbar control groups. */ +const Divider = () => + +export interface ViewerToolbarControlsProps extends PropsWithChildren { + /** Added to the standard toolbar control group. */ + className?: string +} + +/** Groups controls into the standard styled toolbar surface. */ +const Controls = ({ children, className }: ViewerToolbarControlsProps) => ( +
+ {children} +
+) + +const DefaultControls = () => { + const controls = useViewerToolbar() + const display = [ + controls.stock ? : null, + controls.axes ? : null, + controls.grid ? : null, + controls.banana ? : null, + ].filter(Boolean) + const analysis = [ + controls.directions ? : null, + controls.hover ? : null, + controls.focus ? : null, + controls.wireframe ? : null, + controls.section ? : null, + controls.measure ? : null, + ].filter(Boolean) + const camera = [ + controls.fit ? : null, + controls.reset ? : null, + controls.top ? : null, + ].filter(Boolean) + + return ( + + {display} + {display.length && analysis.length ? : null} + {analysis} + {(display.length || analysis.length) && camera.length ? : null} + {camera} + + ) +} export interface ViewerToolbarProps { /** Added to the outer toolbar stack, beside `viewer-toolbar-stack`. */ className?: string - stock: boolean - axes: boolean - grid: boolean - banana?: boolean - directions: boolean - hover: boolean - focus: boolean - wireframe: boolean - sectioning: boolean - measuring: boolean - onFit: () => void - onReset: () => void - onTop: () => void - onStock: () => void - onAxes: () => void - onGrid: () => void - onBanana?: () => void - onDirections: () => void - onHover: () => void - onFocus: () => void - onWireframe: () => void - onSection: () => void - onMeasure: () => void + /** + * Toolbar contents. Omit this for the standard control order, or compose + * `ViewerToolbar.Controls` with the individual button components. + */ children?: ReactNode } -/** The standard toolbar for the viewer's camera, display, and analysis controls. */ -export const ViewerToolbar = (props: ViewerToolbarProps) => ( -
- {props.children} +/** + * A viewer-scoped toolbar. It reads state and callbacks from + * {@link ViewerToolbarProvider}; it never owns application behavior itself. + */ +const ViewerToolbarRoot = ({ className, children }: ViewerToolbarProps) => { + useViewerToolbar() + return (
- - - - {props.onBanana ? ( - - ) : null} - - - - - - - - - - - + {children ?? }
-
-) + ) +} + +export const ViewerToolbar = Object.assign(ViewerToolbarRoot, { + Controls, + Divider, + StockButton, + AxesButton, + GridButton, + BananaButton, + DirectionsButton, + HoverButton, + FocusButton, + WireframeButton, + SectionButton, + MeasureButton, + FitButton, + ResetButton, + TopButton, +}) diff --git a/packages/viewer/tests/styling.test.tsx b/packages/viewer/tests/styling.test.tsx index 77c1c96..caab7f9 100644 --- a/packages/viewer/tests/styling.test.tsx +++ b/packages/viewer/tests/styling.test.tsx @@ -1,35 +1,30 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import { HoverCard } from '../src/hover-card.js' -import { ViewerToolbar } from '../src/viewer-toolbar.js' +import { ViewerToolbar, ViewerToolbarProvider } from '../src/viewer-toolbar.js' describe('viewer styling hooks', () => { it('keeps stable toolbar classes and action identifiers beside a caller class', () => { const markup = renderToStaticMarkup( - {}} - onReset={() => {}} - onTop={() => {}} - onStock={() => {}} - onAxes={() => {}} - onGrid={() => {}} - onDirections={() => {}} - onHover={() => {}} - onFocus={() => {}} - onWireframe={() => {}} - onSection={() => {}} - onMeasure={() => {}} - />, + {} }, + axes: { pressed: false, onClick: () => {} }, + grid: { pressed: false, onClick: () => {} }, + banana: { pressed: false, onClick: () => {} }, + directions: { pressed: false, onClick: () => {} }, + hover: { pressed: false, onClick: () => {} }, + focus: { pressed: false, onClick: () => {} }, + wireframe: { pressed: false, onClick: () => {} }, + section: { pressed: false, onClick: () => {} }, + measure: { pressed: false, onClick: () => {} }, + fit: { onClick: () => {} }, + reset: { onClick: () => {} }, + top: { onClick: () => {} }, + }} + > + + , ) expect(markup).toContain('class="viewer-toolbar-stack app-toolbar"') @@ -41,6 +36,39 @@ describe('viewer styling hooks', () => { expect(markup).toContain('class="viewer-toolbar-divider"') }) + it('allows an application to render only its chosen controls in its chosen order', () => { + const markup = renderToStaticMarkup( + {} }, + banana: { pressed: true, onClick: () => {} }, + }} + > + + + + + + + , + ) + + const fit = markup.indexOf('data-viewer-toolbar-action="fit"') + const banana = markup.indexOf('data-viewer-toolbar-action="banana"') + expect(fit).toBeGreaterThan(-1) + expect(banana).toBeGreaterThan(fit) + expect(markup).not.toContain('data-viewer-toolbar-action="stock"') + expect(markup).not.toContain('aria-pressed="false"') + expect(markup).toContain('data-viewer-toolbar-action="banana"') + expect(markup).toContain('aria-pressed="true"') + }) + + it('requires a provider for compound toolbar controls', () => { + expect(() => renderToStaticMarkup()).toThrow( + 'useViewerToolbar must be used inside ', + ) + }) + it('gives hover cards a default class without replacing a caller class', () => { const markup = renderToStaticMarkup(