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)
+
+
+ Allowance per side (mm)
+ {
+ const value = event.target.valueAsNumber
+ if (Number.isFinite(value) && value >= 0 && value <= 100) setAllowance(value)
+ }}
+ />
+
+ Demo box stock; adjust the allowance for your setup.
+ viewerRef.current?.frameBox(DETAIL)}
+ >
+ 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
@@ -220,87 +272,87 @@ const App = () => {
-
-
viewerRef.current?.fit()}>
- Fit
-
-
viewerRef.current?.reset()}>
- Reset
-
-
viewerRef.current?.setView('top')}>
- Top view
-
-
viewerRef.current?.frameBox(DETAIL)}>
- Frame detail
-
-
{
- // Entering section mode puts the selection down — the part
- // reports no picks while the tool is up, so a selection left
- // standing could not be changed — and offers a cut; the tool
- // below is what does the offering. Leaving takes the cut with it
- // and picks the selection back up where it was.
- if (sectioning) {
- viewerRef.current?.setSection(null)
- setSelected(heldSelection.current)
- } else {
- heldSelection.current = selected
- setSelected([])
- }
- setSectioning((on) => !on)
- }}
- >
- {sectioning ? 'Exit section' : 'Section'}
-
- {sectioning && cut ? (
- <>
-
- Cut depth
- {
- const next = Number(event.target.value)
- setOffset(next)
- viewerRef.current?.setSection(sweepTo(cut, next))
- }}
- />
-
-
viewerRef.current?.setSection(null)}>
- Clear 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 ? (
+ <>
+
+ Cut depth
+ {
+ const next = Number(event.target.value)
+ setOffset(next)
+ viewerRef.current?.setSection(sweepTo(cut, next))
+ }}
+ />
+
+ viewerRef.current?.setSection(null)}>
+ Clear cut
+
+ >
+ ) : (
+ Click a face or a plane · Esc clears
+ )}
+
) : null}
- {
- // The same bargain as section mode: the part reports no picks
- // while a tool is up, so the selection is put down on the way in
- // and picked back up on the way out. The measurements go with the
- // tool — unmounting it is what clears them.
- if (measuring) {
- setMeasured([])
- setSelected(heldSelection.current)
- } else {
- heldSelection.current = selected
- setSelected([])
- }
- setMeasuring((on) => !on)
- }}
- >
- {measuring ? 'Exit measure' : 'Measure'}
-
{measuring ? (
- <>
+
{
{measureMode === 'distance' ? 'Click two points' : 'Click end, vertex, end'} · Shift
locks an axis · Del removes last · Esc drops
- >
+
+ ) : null}
+ {showDirections && !sectioning && !measuring ? (
+
+ setDirection(null)}
+ >
+ All
+
+ {part.model.candidateDirections.map((axis, index) => (
+ setDirection((held) => (held === index ? null : index))}
+ >
+
+ {directionLabel(axis)}
+
+ ))}
+
) : 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) => (
+
+
+
+
+
+ {label}
+
+
+)
+
+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))}
+ />
+
+ setStock(!stock)}>
+ Stock
+
+ setAxes(!axes)}>
+ Axes
+
+ setDirections(!directions)}>
+ By direction
+
+ setWireframe(!wireframe)}>
+ Wireframe
+
+ viewer.current?.fit()}>Fit
+ >
+ )
+}
+```
+
+`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)}
+
viewerRef.current?.setSection(null)}>
Clear cut
diff --git a/examples/react-viewer/src/style.css b/examples/react-viewer/src/style.css
index 15e23a3..2b65634 100644
--- a/examples/react-viewer/src/style.css
+++ b/examples/react-viewer/src/style.css
@@ -195,6 +195,11 @@ strong {
align-items: center;
gap: 0.4rem;
}
+.section-measurement {
+ color: #eef0f6;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
.direction-dot {
display: inline-block;
width: 0.65rem;
diff --git a/examples/react-viewer/src/viewer-toolbar.tsx b/examples/react-viewer/src/viewer-toolbar.tsx
index b648720..a07f15b 100644
--- a/examples/react-viewer/src/viewer-toolbar.tsx
+++ b/examples/react-viewer/src/viewer-toolbar.tsx
@@ -11,7 +11,7 @@ const paths = {
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',
+ section: 'M3 6h9v12H3zM12 3v18M16 6h2m3 3v2m0 3v2m-5 3h-2',
measure: 'M3 16L16 3l5 5L8 21zM8 11l3 3m1-7 3 3m-11 5 3 3',
} as const
diff --git a/examples/react-viewer/tests/viewer.spec.ts b/examples/react-viewer/tests/viewer.spec.ts
index 808516a..48e6773 100644
--- a/examples/react-viewer/tests/viewer.spec.ts
+++ b/examples/react-viewer/tests/viewer.spec.ts
@@ -138,6 +138,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(selected).toContainText('none')
await canvas.click({ position: on(box, ONE) })
await expect(selected).toContainText('none')
@@ -147,6 +148,7 @@ 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) })
diff --git a/packages/viewer/README.md b/packages/viewer/README.md
index 95c1eaa..2ca4c67 100644
--- a/packages/viewer/README.md
+++ b/packages/viewer/README.md
@@ -597,7 +597,10 @@ no `section` prop:
Hovering the part previews a cut through the face under the pointer; clicking places it. Three
coloured planes stand behind the part — click one to cut along that axis, in from your side. Once
-there is a cut, an arrow drags it, an outlined sheet shows the cutting plane, and Escape clears it.
+there is a cut, a framed plane and two-way normal-axis handle show exactly what will move; drag the
+handle to move it. Keep the canvas clear by presenting `sectionMeasurement(state)` beside the host
+application's section slider; it reports the depth from a picked surface, or the distance swept
+through the part bounds. Escape clears it.
The cut belongs to the viewer: `viewer.current.setSection(null)` clears it from a button outside
the canvas, `setSection({ enabled: true, normal, offset })` sets one, and `onSectionChange` on
`PartMesh` still reports every move.
@@ -624,7 +627,7 @@ const [offset, setOffset] = useState(0.5)
```
The cut surface is filled in with a hatch and outlined, so the part doesn't look hollow and the cut
-reads as a cut. Passing `onSectionChange` also shows an arrow handle users can drag. The handler is
+reads as a cut. Passing `onSectionChange` also shows the draggable plane-frame gizmo. The handler is
called on every drag and only when the cut actually changes, so it's safe to store the value in
state; a cut going away is reported once, with `enabled: false`.
diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts
index 4f364d8..b2b7369 100644
--- a/packages/viewer/src/index.ts
+++ b/packages/viewer/src/index.ts
@@ -55,6 +55,7 @@ export {
PICKED_SURFACE_LABEL,
PREVIEW_SCALE,
SECTION_RENDER_ORDER,
+ SECTION_GIZMO_FRAME_SCALE,
axesPlanes,
dragPlane,
hitUnderRay,
@@ -67,6 +68,7 @@ export {
sectionDepthRange,
sectionFromPick,
sectionOffset,
+ sectionMeasurement,
sectionOptionsFromState,
sectionPlane,
surfaceUnderRay,
diff --git a/packages/viewer/src/part-mesh.tsx b/packages/viewer/src/part-mesh.tsx
index 82a3870..5579da2 100644
--- a/packages/viewer/src/part-mesh.tsx
+++ b/packages/viewer/src/part-mesh.tsx
@@ -18,6 +18,7 @@ import {
type SectionOptions,
type SectionState,
sectionBounds,
+ sectionCutDistance,
sectionDepth,
sectionOffset,
sectionOptionsFromState,
@@ -373,11 +374,14 @@ export const PartMesh = ({
(constant: number) => {
if (!cut) return
const anchor = options?.plane?.point
+ const bounds = sectionBounds(box, cut.state.normal)
+ const offset = sectionOffset(bounds, constant)
const state: SectionState = {
...cut.state,
constant,
- offset: sectionOffset(sectionBounds(box, cut.state.normal), constant),
+ offset,
depth: anchor ? sectionDepth(cut.state.normal, anchor, constant) : null,
+ cutDistance: sectionCutDistance(box, cut.state.normal, constant),
}
// The viewer's own cut moves itself; a consumer's is theirs to move.
if (!controlled) store.set(sectionOptionsFromState(state))
diff --git a/packages/viewer/src/render/section.ts b/packages/viewer/src/render/section.ts
index 371dd9a..da4cc07 100644
--- a/packages/viewer/src/render/section.ts
+++ b/packages/viewer/src/render/section.ts
@@ -1,6 +1,7 @@
import { type Box3, type Intersection, type Object3D, Plane, type Raycaster, Vector3 } from 'three'
import type { Vec3 } from '../model/types.js'
import { excludedFromPart } from './camera.js'
+import { AXIS_COLORS } from './measure.js'
/**
* Render order. The stencil pass must precede the cap, and the part must draw
@@ -21,6 +22,9 @@ const START_DEPTH = 0.005
/** The handle's length on screen, in CSS pixels, whatever the zoom. */
export const HANDLE_PIXELS = 78
+/** The viewer-space span of the visible section-plane frame, relative to the part diagonal. */
+export const SECTION_GIZMO_FRAME_SCALE = 1.1
+
/**
* The hatch on the cap: stripe pitch and line thickness in CSS pixels, and the
* outline's width. Screen-space, so the cut reads the same at any zoom.
@@ -70,6 +74,12 @@ export interface SectionState {
readonly constant: number
readonly plane: SectionPlacement | null
readonly depth: number | null
+ /**
+ * Distance swept from the edge of the part into its projected bounds, in
+ * model units. A surface-anchored cut also reports `depth`, which is its
+ * more useful physical datum.
+ */
+ readonly cutDistance: number
/**
* How far the cut can travel from its anchor, in model units, or `null` for a
* sweep — which is measured as a fraction of the part rather than a distance.
@@ -96,9 +106,41 @@ export const DISABLED_SECTION: SectionState = {
constant: 0,
plane: null,
depth: null,
+ cutDistance: 0,
depthRange: null,
}
+/**
+ * A concise physical measurement for a host application's section control.
+ *
+ * A cut placed from a surface is measured from that surface. A free sweep is
+ * measured from the edge of the part along the cut normal.
+ */
+export function sectionMeasurement(state: SectionState): string {
+ return `${(state.depth ?? state.cutDistance).toFixed(2)} mm`
+}
+
+/**
+ * A section plane's direction colour. Cardinal normals match the X/Y/Z axis
+ * colours used by the directional arrows; tilted normals blend those colours
+ * by their absolute axis contributions.
+ */
+export function sectionDirectionColor(normal: Vec3): number {
+ const x = Math.abs(normal.x)
+ const y = Math.abs(normal.y)
+ const z = Math.abs(normal.z)
+ const total = x + y + z || 1
+ const channel = (shift: number) =>
+ Math.round(
+ (((AXIS_COLORS.x >> shift) & 0xff) * x +
+ ((AXIS_COLORS.y >> shift) & 0xff) * y +
+ ((AXIS_COLORS.z >> shift) & 0xff) * z) /
+ total,
+ )
+
+ return (channel(16) << 16) | (channel(8) << 8) | channel(0)
+}
+
/**
* The options that would resolve to `state` again.
*
@@ -289,7 +331,7 @@ function clamp01(value: number): number {
* constant clips less. `min` and `max` are named for the constant, not for how
* much they remove.
*/
-export function sectionBounds(box: Box3, normal: Vec3): SectionBounds {
+function projectedBounds(box: Box3, normal: Vec3): { low: number; high: number } {
const axis = unit(normal)
const corner = new Vector3()
@@ -307,6 +349,12 @@ export function sectionBounds(box: Box3, normal: Vec3): SectionBounds {
high = Math.max(high, distance)
}
+ return { low, high }
+}
+
+export function sectionBounds(box: Box3, normal: Vec3): SectionBounds {
+ const { low, high } = projectedBounds(box, normal)
+
// Widen both ends slightly. Without it the extreme corner lies exactly *on*
// the plane at `t = 0` and `t = 1`, and `Plane` keeps the half-space where the
// distance is strictly positive — so "uncut" would already have shaved the
@@ -316,6 +364,15 @@ export function sectionBounds(box: Box3, normal: Vec3): SectionBounds {
return { min: -(high + margin), max: -(low - margin) }
}
+/**
+ * The distance actually cut through the part, excluding the off-part margin
+ * that makes the clipping plane reliably start and finish outside the mesh.
+ */
+export function sectionCutDistance(box: Box3, normal: Vec3, constant: number): number {
+ const { low, high } = projectedBounds(box, normal)
+ return Math.max(0, Math.min(high - low, -constant - low))
+}
+
/** The plane constant at `t`, from 0 (uncut) to 1 (fully cut away). */
export function sectionConstant(bounds: SectionBounds, t: number): number {
return bounds.max + clamp01(t) * (bounds.min - bounds.max)
diff --git a/packages/viewer/src/section-view.tsx b/packages/viewer/src/section-view.tsx
index d1a7d6d..baf025d 100644
--- a/packages/viewer/src/section-view.tsx
+++ b/packages/viewer/src/section-view.tsx
@@ -4,6 +4,8 @@ import {
type Box3,
type BufferGeometry,
Color,
+ DoubleSide,
+ EdgesGeometry,
Group,
Mesh,
Plane,
@@ -17,6 +19,7 @@ import {
import { CONE_AXIS } from './render/directions.js'
import {
HANDLE_PIXELS,
+ SECTION_GIZMO_FRAME_SCALE,
SECTION_RENDER_ORDER,
type SectionOptions,
type SectionState,
@@ -28,6 +31,8 @@ import {
sectionDepth,
sectionDepthConstant,
sectionDepthRange,
+ sectionCutDistance,
+ sectionDirectionColor,
sectionOffset,
} from './render/section.js'
import {
@@ -77,6 +82,7 @@ export function resolveSectionPlane(
constant,
plane: options.plane ?? null,
depth: anchor === null ? null : sectionDepth(normal, anchor, constant),
+ cutDistance: sectionCutDistance(box, normal, constant),
depthRange: anchor === null ? null : sectionDepthRange(bounds, normal, anchor),
},
}
@@ -134,6 +140,8 @@ export const SectionView = ({
const dragging = useRef<{ plane: Plane; from: number; constant: number } | null>(null)
const span = useMemo(() => box.getSize(new Vector3()).length(), [box])
+ const frameSize = span * SECTION_GIZMO_FRAME_SCALE
+ const directionColor = useMemo(() => sectionDirectionColor(plane.normal), [plane])
const clip = useMemo(() => [plane], [plane])
// Where the cap sits: on the plane, over the part's centre.
@@ -148,6 +156,14 @@ export const SectionView = ({
[plane],
)
+ // The control's frame deliberately follows the whole plane rather than the
+ // cap outline: a narrow intersection on a thin wall is still a cut plane a
+ // person needs to find and grab.
+ const frameSurface = useMemo(() => new PlaneGeometry(frameSize, frameSize), [frameSize])
+ useEffect(() => () => frameSurface.dispose(), [frameSurface])
+ const frameEdges = useMemo(() => new EdgesGeometry(frameSurface), [frameSurface])
+ useEffect(() => () => frameEdges.dispose(), [frameEdges])
+
// The stencil materials, the mask target and the cap material are built once
// and updated in place: a drag moves the plane on every pointer event, and
// rebuilding three materials and a scene for each one is work the GPU has to
@@ -332,31 +348,58 @@ export const SectionView = ({
/>
{showHandle && onDrag ? (
-
- {/* Handlers on each mesh rather than on the group. Either works —
- R3F bubbles from the hit mesh to an ancestor — but the ray hits
- both the head and the shaft, and stopping propagation at the mesh
- is what keeps that from counting as two presses. */}
-
-
-
-
-
-
-
-
-
+ <>
+ {/* A translucent frame makes the clipping plane legible even where
+ its cap is a thin sliver. It is visual only; the normal-axis
+ handle below remains the one clear drag affordance. */}
+
+ null}>
+
+
+ null}>
+
+
+
+
+
+ {/* Handlers on each mesh rather than on the group. Either works —
+ R3F bubbles from the hit mesh to an ancestor — but the ray hits
+ several parts of the gizmo, and stopping propagation at the mesh
+ is what keeps that from counting as two presses. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
) : null}
)
diff --git a/packages/viewer/tests/section.test.ts b/packages/viewer/tests/section.test.ts
index 00036c1..3ce02df 100644
--- a/packages/viewer/tests/section.test.ts
+++ b/packages/viewer/tests/section.test.ts
@@ -5,12 +5,15 @@ import {
dragPlane,
sectionBounds,
sectionConstant,
+ sectionCutDistance,
sectionDepth,
sectionDepthConstant,
sectionDepthRange,
+ sectionDirectionColor,
sectionFromPick,
sectionOffset,
sectionPlane,
+ sectionMeasurement,
} from '../src/render/section.js'
import { resolveSectionPlane } from '../src/section-view.js'
@@ -147,6 +150,48 @@ describe('sectionDepth', () => {
})
})
+describe('sectionMeasurement', () => {
+ it('reports an anchored cut against its physical depth', () => {
+ const cut = resolveSectionPlane(
+ {
+ enabled: true,
+ plane: sectionFromPick({ point: { x: 0, y: 0, z: 50.8 }, normal: { x: 0, y: 0, z: 1 } }),
+ depth: 3.125,
+ },
+ cube(),
+ )
+
+ expect(sectionMeasurement(cut!.state)).toBe('3.13 mm')
+ })
+
+ it('reports a free sweep by its physical distance', () => {
+ const cut = resolveSectionPlane({ enabled: true, offset: 0.456 }, cube())
+
+ expect(sectionMeasurement({ ...cut!.state, cutDistance: 12.34 })).toBe('12.34 mm')
+ })
+
+ it('clamps a sweep measurement to the part bounds', () => {
+ const box = new Box3(new Vector3(0, 0, 0), new Vector3(40, 20, 10))
+ const normal = { x: 1, y: 0, z: 0 }
+
+ expect(sectionCutDistance(box, normal, 10)).toBe(0)
+ expect(sectionCutDistance(box, normal, -10)).toBe(10)
+ expect(sectionCutDistance(box, normal, -50)).toBe(40)
+ })
+})
+
+describe('sectionDirectionColor', () => {
+ it('uses the axis colour for cardinal normals', () => {
+ expect(sectionDirectionColor({ x: 1, y: 0, z: 0 })).toBe(0xff6b6b)
+ expect(sectionDirectionColor({ x: 0, y: -1, z: 0 })).toBe(0x6fe08a)
+ expect(sectionDirectionColor({ x: 0, y: 0, z: 1 })).toBe(0x6f9bff)
+ })
+
+ it('blends the participating axis colours for tilted normals', () => {
+ expect(sectionDirectionColor({ x: 1, y: 1, z: 0 })).toBe(0xb7a67b)
+ })
+})
+
describe('dragPlane', () => {
it('faces the camera while containing the axis being dragged', () => {
const axis = new Vector3(0, 0, 1)
From 321a9e1314d7779545309b6543d7fe1832f73028 Mon Sep 17 00:00:00 2001
From: nathan-vandrei <87823030+dementive@users.noreply.github.com>
Date: Fri, 18 Sep 2026 17:54:08 -0400
Subject: [PATCH 05/16] Add banana for scale to viewer
---
.changeset/banana-for-scale.md | 6 ++
examples/react-viewer/src/main.tsx | 14 +++-
examples/react-viewer/src/viewer-toolbar.tsx | 16 +++-
.../react-viewer/tests/orthographic.spec.ts | 1 +
examples/react-viewer/tests/viewer.spec.ts | 32 ++++++--
.../ui/src/banana-button/banana-button.tsx | 26 ++++++
packages/ui/src/banana-button/index.ts | 2 +
packages/ui/src/index.ts | 1 +
packages/ui/tests/banana-button.test.tsx | 21 +++++
packages/viewer/README.md | 4 +-
packages/viewer/package.json | 2 +-
packages/viewer/scripts/copy-assets.mjs | 9 +++
packages/viewer/src/assets/banana.glb | Bin 0 -> 763952 bytes
packages/viewer/src/banana.tsx | 75 ++++++++++++++++++
packages/viewer/src/index.ts | 3 +
packages/viewer/src/part-mesh.tsx | 2 +-
packages/viewer/src/render/banana.ts | 22 +++++
packages/viewer/tests/banana.test.ts | 27 +++++++
18 files changed, 245 insertions(+), 18 deletions(-)
create mode 100644 .changeset/banana-for-scale.md
create mode 100644 packages/ui/src/banana-button/banana-button.tsx
create mode 100644 packages/ui/src/banana-button/index.ts
create mode 100644 packages/ui/tests/banana-button.test.tsx
create mode 100644 packages/viewer/scripts/copy-assets.mjs
create mode 100644 packages/viewer/src/assets/banana.glb
create mode 100644 packages/viewer/src/banana.tsx
create mode 100644 packages/viewer/src/render/banana.ts
create mode 100644 packages/viewer/tests/banana.test.ts
diff --git a/.changeset/banana-for-scale.md b/.changeset/banana-for-scale.md
new file mode 100644
index 0000000..41e5280
--- /dev/null
+++ b/.changeset/banana-for-scale.md
@@ -0,0 +1,6 @@
+---
+'@toolpath/viewer': minor
+'@toolpath/ui': minor
+---
+
+Add a banana-for-scale model and shared toggle button. Disable feature hover by default.
diff --git a/examples/react-viewer/src/main.tsx b/examples/react-viewer/src/main.tsx
index 091a8c5..f1e9281 100644
--- a/examples/react-viewer/src/main.tsx
+++ b/examples/react-viewer/src/main.tsx
@@ -1,9 +1,10 @@
-import { StrictMode, useCallback, useMemo, useRef, useState } from 'react'
+import { StrictMode, Suspense, useCallback, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { useFrame, useThree } from '@react-three/fiber'
import * as THREE from 'three'
import {
Axes,
+ Banana,
BoxStock,
boxStockBounds,
directionHighlights,
@@ -164,7 +165,7 @@ const App = () => {
const viewerRef = useRef(null)
const [hovered, setHovered] = useState([])
const [hoverPick, setHoverPick] = useState(null)
- const [featureHover, setFeatureHover] = useState(true)
+ const [featureHover, setFeatureHover] = useState(false)
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.
@@ -190,6 +191,7 @@ const App = () => {
const [showStock, setShowStock] = useState(params.get('stock') === 'on')
const [showAxes, setShowAxes] = useState(true)
const [showGrid, setShowGrid] = useState(true)
+ const [banana, setBanana] = useState(false)
const [showDirections, setShowDirections] = useState(false)
const [focus, setFocus] = useState(false)
const [wireframe, setWireframe] = useState(false)
@@ -211,7 +213,6 @@ const App = () => {
(next: CameraState) => setPose((held) => (sameCamera(held, next) ? held : next)),
[],
)
-
return (
@@ -325,6 +326,7 @@ const App = () => {
stock={showStock}
axes={showAxes}
grid={showGrid}
+ banana={banana}
directions={showDirections}
hover={featureHover}
focus={focus}
@@ -337,6 +339,7 @@ const App = () => {
onStock={() => setShowStock((on) => !on)}
onAxes={() => setShowAxes((on) => !on)}
onGrid={() => setShowGrid((on) => !on)}
+ onBanana={() => setBanana((shown) => !shown)}
onDirections={() => {
setShowDirections((on) => !on)
setDirection(null)
@@ -521,6 +524,11 @@ const App = () => {
{sectioning ? : null}
{measuring ? : null}
{showGrid ? : null}
+ {banana ? (
+
+
+
+ ) : null}
{showAxes ? : null}
diff --git a/examples/react-viewer/src/viewer-toolbar.tsx b/examples/react-viewer/src/viewer-toolbar.tsx
index a07f15b..32f2d0c 100644
--- a/examples/react-viewer/src/viewer-toolbar.tsx
+++ b/examples/react-viewer/src/viewer-toolbar.tsx
@@ -7,6 +7,8 @@ const paths = {
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',
+ banana:
+ 'M284.2 245.6c12.99 6.929 25.35 15.14 36.08 25.8L334.5 285.6l65.75-23.47c14.75-5.265 30.18-7.849 45.81-8.389c1.154-10.73 1.764-21.38 1.764-31.87c0-118.5-81.33-221.9-119.7-221.9c-21.01 0-40.91 17.04-40.91 39.25c0 16.18 16.74 41.9 16.74 103C303.1 170.1 300.6 203.1 284.2 245.6zM575.1 389.6c0-3.687-.8637-7.429-2.687-10.93l-15.12-29.11c-21.05-40.53-63.08-64.51-106.9-64.51c-13.43 0-27.02 2.252-40.22 6.969l-84.84 30.27l-28.59-28.41C274.4 270.9 243.7 258.1 212.8 258.1c-23.72 0-47.57 6.97-68.29 21.2L106.3 306.4c-6.732 4.631-10.35 12.07-10.35 19.63c0 14.93 12.7 23.87 24.04 23.87c4.695 0 9.443-1.376 13.61-4.26l38.13-26.23c12.43-8.525 26.71-12.69 40.91-12.69c10.64 0 21.24 2.339 30.97 6.934c-50.62 62.23-128.3 99.85-211.4 99.85C14.42 413.5 0 427.8 0 445.5v31.38c0 17.68 14.66 32.02 32.46 32.02l28.98.0009c14.15 0 34.69 1.098 59.07 1.098c93.51 0 243.4-16.15 304.8-172.4c9.021-3.22 17.95-4.712 26.53-4.712c27.65 0 51.7 15.49 63.7 38.55l15.12 29.11c3.484 6.723 11.74 12.98 21.41 12.98C564.1 413.6 575.1 403.8 575.1 389.6z',
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',
@@ -25,9 +27,9 @@ interface ButtonProps {
const ToolbarButton = ({ icon, label, pressed, onClick }: ButtonProps) => (
void
onAxes: () => void
onGrid: () => void
+ onBanana: () => void
onDirections: () => void
onHover: () => void
onFocus: () => void
@@ -88,6 +92,12 @@ export const ViewerToolbar = (props: ViewerToolbarProps) => (
pressed={props.grid}
onClick={props.onGrid}
/>
+
{
const { canvas, box } = await openViewer(page, ORTHOGRAPHIC)
+ await page.getByRole('button', { name: 'Enable feature hover' }).click()
expectPivot(await readCamera(page), ORIGIN)
diff --git a/examples/react-viewer/tests/viewer.spec.ts b/examples/react-viewer/tests/viewer.spec.ts
index 48e6773..bec9274 100644
--- a/examples/react-viewer/tests/viewer.spec.ts
+++ b/examples/react-viewer/tests/viewer.spec.ts
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test'
-import { at, on, openViewer } from './canvas.js'
+import { at, on, openViewer, readCamera } from './canvas.js'
/**
* The example viewer, driven the way somebody would drive it.
@@ -82,6 +82,7 @@ test('the click points hit the faces the rest of this file is written about', as
test('hovering a face exposes an application-owned card at the pointer', async ({ page }) => {
const { canvas, box } = await openViewer(page)
+ await page.getByRole('button', { name: 'Enable feature hover' }).click()
await canvas.hover({ position: on(box, CENTRE) })
await expect(page.getByRole('tooltip')).toContainText('Feature')
await expect(page.getByRole('tooltip')).toContainText('Surface area')
@@ -91,27 +92,40 @@ test('hovering a face exposes an application-owned card at the pointer', async (
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 toggle = page.getByRole('button', { name: 'Enable 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 expect(toggle).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 toggle.click()
await canvas.hover({ position: on(box, CENTRE) })
await expect(page.getByRole('tooltip')).toContainText('Feature')
})
+test('loads a banana for scale only when its toolbar button is enabled', async ({ page }) => {
+ await openViewer(page)
+ const before = await readCamera(page)
+ const model = page.waitForResponse((response) => response.url().endsWith('.glb') && response.ok())
+
+ await page.getByRole('button', { name: 'Banana for scale' }).click()
+ await model
+ await expect(page.getByRole('button', { name: 'Banana for scale (on)' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ const after = await readCamera(page)
+ expect(after.distance).toBeCloseTo(before.distance, 3)
+ expect(after.zoom).toBeCloseTo(before.zoom, 3)
+})
+
test('selects a feature and responds to CAD camera navigation', async ({ page }) => {
const { canvas, box } = await openViewer(page)
+ await page.getByRole('button', { name: 'Enable feature hover' }).click()
// The section. A click on a face while the tool is up places a cut there,
// which is the tool's whole reason to exist. Moved through the slider rather
@@ -215,6 +229,7 @@ test('selects a feature and responds to CAD camera navigation', async ({ page })
*/
test('measures a distance between two clicks, without selecting either face', async ({ page }) => {
const { canvas, box } = await openViewer(page)
+ await page.getByRole('button', { name: 'Enable feature hover' }).click()
const measured = page.locator('p', { hasText: 'Measured:' })
const hovered = page.locator('p', { hasText: 'Hovered:' })
@@ -412,6 +427,7 @@ test('pans with either pan button, from wherever the drag starts', async ({ page
test('finishing a drag over a face is not a request to select it', async ({ page }) => {
const { canvas, box } = await openViewer(page)
+ await page.getByRole('button', { name: 'Enable feature hover' }).click()
const selected = page.locator('p', { hasText: 'Selected:' })
const hovered = page.locator('p', { hasText: 'Hovered:' })
diff --git a/packages/ui/src/banana-button/banana-button.tsx b/packages/ui/src/banana-button/banana-button.tsx
new file mode 100644
index 0000000..532ca2f
--- /dev/null
+++ b/packages/ui/src/banana-button/banana-button.tsx
@@ -0,0 +1,26 @@
+import type { ComponentProps } from 'react'
+import { IconButton } from '../icon-button'
+
+export interface BananaButtonProps
+ extends Omit, 'aria-label' | 'children' | 'toggled'> {
+ /** Whether the banana for scale is currently shown. */
+ shown: boolean
+}
+
+/** The shared toolbar control for showing a banana beside a 3D part. */
+export const BananaButton = ({ shown, ...props }: BananaButtonProps) => (
+
+
+
+)
+
+/** A banana, for scale. */
+export const BananaIcon = () => (
+
+
+
+)
diff --git a/packages/ui/src/banana-button/index.ts b/packages/ui/src/banana-button/index.ts
new file mode 100644
index 0000000..c6de358
--- /dev/null
+++ b/packages/ui/src/banana-button/index.ts
@@ -0,0 +1,2 @@
+export { BananaButton, BananaIcon } from './banana-button'
+export type { BananaButtonProps } from './banana-button'
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 3eefe97..28336c9 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -6,6 +6,7 @@ export * from './combobox'
export * from './dialog'
export * from './editable-cell'
export * from './badge'
+export * from './banana-button'
export * from './breadcrumbs'
export * from './card'
export * from './field'
diff --git a/packages/ui/tests/banana-button.test.tsx b/packages/ui/tests/banana-button.test.tsx
new file mode 100644
index 0000000..1dfe01a
--- /dev/null
+++ b/packages/ui/tests/banana-button.test.tsx
@@ -0,0 +1,21 @@
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { BananaButton } from '../src'
+
+describe('BananaButton', () => {
+ afterEach(cleanup)
+
+ it('names and exposes its shown state to assistive technology', () => {
+ const { rerender } = render( {}} />)
+
+ expect(screen.getByRole('button', { name: 'Banana for scale (on)' })).toHaveAttribute(
+ 'data-toggled',
+ 'true',
+ )
+
+ rerender( {}} />)
+ expect(screen.getByRole('button', { name: 'Banana for scale' })).not.toHaveAttribute(
+ 'data-toggled',
+ )
+ })
+})
diff --git a/packages/viewer/README.md b/packages/viewer/README.md
index 2ca4c67..791c9ea 100644
--- a/packages/viewer/README.md
+++ b/packages/viewer/README.md
@@ -133,8 +133,8 @@ 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.
+Hover feedback is off by default; pass `hover` to enable both 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
diff --git a/packages/viewer/package.json b/packages/viewer/package.json
index cd467b3..4370bef 100644
--- a/packages/viewer/package.json
+++ b/packages/viewer/package.json
@@ -39,7 +39,7 @@
],
"sideEffects": false,
"scripts": {
- "build": "tsup src/index.ts src/engine/index.ts --format esm --dts --clean --external react --external react-dom --external three --external @react-three/fiber --external @react-three/drei",
+ "build": "tsup src/index.ts src/engine/index.ts --format esm --dts --clean --external react --external react-dom --external three --external @react-three/fiber --external @react-three/drei && node scripts/copy-assets.mjs",
"build:watch": "tsup src/index.ts src/engine/index.ts --format esm --dts --watch --external react --external react-dom --external three --external @react-three/fiber --external @react-three/drei",
"check-types": "tsc --noEmit",
"test": "vitest run"
diff --git a/packages/viewer/scripts/copy-assets.mjs b/packages/viewer/scripts/copy-assets.mjs
new file mode 100644
index 0000000..52a39e6
--- /dev/null
+++ b/packages/viewer/scripts/copy-assets.mjs
@@ -0,0 +1,9 @@
+import { cp, mkdir } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+
+const source = new URL('../src/assets/banana.glb', import.meta.url)
+const assetDirectory = new URL('../dist/assets/', import.meta.url)
+const target = new URL('banana.glb', assetDirectory)
+
+await mkdir(fileURLToPath(assetDirectory), { recursive: true })
+await cp(fileURLToPath(source), fileURLToPath(target))
diff --git a/packages/viewer/src/assets/banana.glb b/packages/viewer/src/assets/banana.glb
new file mode 100644
index 0000000000000000000000000000000000000000..f6dc32aaf5f71e10695f86c3eb87b51e3e34bd62
GIT binary patch
literal 763952
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