=> {
const zoom = await readout.getAttribute('data-zoom')
const distance = await readout.getAttribute('data-distance')
const target = await readout.getAttribute('data-target')
- if (zoom === null || distance === null || target === null) {
+ const position = await readout.getAttribute('data-position')
+ if (zoom === null || distance === null || target === null || position === null) {
throw new Error('The example is not reporting its camera')
}
const [x, y, z] = target.split(' ').map(Number)
- return { zoom: Number(zoom), distance: Number(distance), target: [x, y, z] }
+ const [positionX, positionY, positionZ] = position.split(' ').map(Number)
+ return {
+ zoom: Number(zoom),
+ distance: Number(distance),
+ target: [x, y, z],
+ position: [positionX, positionY, positionZ],
+ }
}
/**
diff --git a/examples/react-viewer/tests/controls.spec.ts b/examples/react-viewer/tests/controls.spec.ts
new file mode 100644
index 0000000..2b561e3
--- /dev/null
+++ b/examples/react-viewer/tests/controls.spec.ts
@@ -0,0 +1,55 @@
+import { expect, test, type Page } from '@playwright/test'
+import { at, openViewer, readCamera } from './canvas.js'
+
+const centre = { x: 0.5, y: 0.5 }
+
+const drag = async (
+ page: Page,
+ start: { x: number; y: number },
+ end: { x: number; y: number },
+ button: 'left' | 'middle' | 'right',
+) => {
+ await page.mouse.move(start.x, start.y)
+ await page.mouse.down({ button })
+ await page.mouse.move(end.x, end.y, { steps: 8 })
+ await page.mouse.up({ button })
+ await page.waitForTimeout(100)
+}
+
+test('Toolpath and Onshape assign orbit and pan to their documented mouse buttons', async ({
+ page,
+}) => {
+ const toolpath = await openViewer(page, 'controls=toolpath')
+ await expect(page.getByText('Controls: toolpath')).toBeVisible()
+ const toolpathBefore = await readCamera(page)
+ await drag(page, at(toolpath.box, centre), at(toolpath.box, { x: 0.62, y: 0.5 }), 'left')
+ const toolpathAfter = await readCamera(page)
+ expect(toolpathAfter.position).not.toEqual(toolpathBefore.position)
+ expect(toolpathBefore.target).toEqual(toolpathAfter.target)
+
+ await page.getByLabel('3D controls').selectOption('onshape')
+ await expect(page.getByText('Controls: onshape')).toBeVisible()
+ const onshapeBefore = await readCamera(page)
+ await drag(page, at(toolpath.box, centre), at(toolpath.box, { x: 0.62, y: 0.5 }), 'middle')
+ const onshapeAfter = await readCamera(page)
+ expect(onshapeAfter.target).not.toEqual(onshapeBefore.target)
+})
+
+test('Fusion and SolidWorks apply their modifier-aware middle-button controls', async ({
+ page,
+}) => {
+ const fusion = await openViewer(page, 'controls=fusion')
+ const fusionBefore = await readCamera(page)
+ await drag(page, at(fusion.box, centre), at(fusion.box, { x: 0.62, y: 0.5 }), 'middle')
+ const fusionAfter = await readCamera(page)
+ expect(fusionAfter.target).not.toEqual(fusionBefore.target)
+
+ await page.getByLabel('3D controls').selectOption('solidworks')
+ await expect(page.getByText('Controls: solidworks')).toBeVisible()
+ const solidworksBefore = await readCamera(page)
+ await page.keyboard.down('Control')
+ await drag(page, at(fusion.box, centre), at(fusion.box, { x: 0.62, y: 0.5 }), 'middle')
+ await page.keyboard.up('Control')
+ const solidworksAfter = await readCamera(page)
+ expect(solidworksAfter.target).not.toEqual(solidworksBefore.target)
+})
diff --git a/examples/react-viewer/tests/orthographic.spec.ts b/examples/react-viewer/tests/orthographic.spec.ts
index a5a7f0e..0d24ad4 100644
--- a/examples/react-viewer/tests/orthographic.spec.ts
+++ b/examples/react-viewer/tests/orthographic.spec.ts
@@ -80,6 +80,7 @@ test('the orthographic click points hit the faces the rest of this file is writt
*/
test('a double click on a face aims the orbit at the point that was clicked', async ({ page }) => {
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/toolbar.spec.ts b/examples/react-viewer/tests/toolbar.spec.ts
new file mode 100644
index 0000000..5669fcc
--- /dev/null
+++ b/examples/react-viewer/tests/toolbar.spec.ts
@@ -0,0 +1,188 @@
+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}`)
+ await expect(page.locator('.viewer-root')).toHaveAttribute('data-viewer-root', 'true')
+ await expect(page.locator('.viewer-canvas-container')).toHaveAttribute(
+ 'data-viewer-canvas-container',
+ 'true',
+ )
+ await expect(page.locator('.viewer-canvas-frame')).toHaveAttribute(
+ 'data-viewer-canvas-frame',
+ 'true',
+ )
+ await expect(canvas).toHaveClass('viewer-canvas')
+ await expect(canvas).toHaveAttribute('data-viewer-canvas', 'true')
+ const controls = page.getByRole('group', { name: 'Viewer controls', exact: true })
+ await expect(controls).toHaveAttribute('data-viewer-toolbar-controls', 'true')
+ await expect(page.locator('.viewer-toolbar-stack')).toHaveAttribute(
+ 'data-viewer-toolbar',
+ 'true',
+ )
+ await expect(page.getByRole('button', { name: 'Show stock', exact: true })).toHaveAttribute(
+ 'data-viewer-toolbar-action',
+ 'stock',
+ )
+ const toolbarBox = await controls.boundingBox()
+ expect(toolbarBox!.y).toBeGreaterThan(box.y + box.height * 0.75)
+ const original = await canvas.screenshot()
+ 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('25.91 × 25.91 × 25.91')
+ 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: 'Stock X dimension (mm)' }).fill('37.4')
+ await page.getByRole('spinbutton', { name: 'Stock Y dimension (mm)' }).fill('37.4')
+ await page.getByRole('spinbutton', { name: 'Stock Z dimension (mm)' }).fill('37.4')
+ 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: 'Exit section', exact: true })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ await expect(page.locator('p', { hasText: 'Cut:' })).toContainText('Part surface')
+ 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').first().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(`focus fades geometry outside the selected feature (${projection})`, async ({ page }) => {
+ 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)
+ const focused = await canvas.screenshot()
+ await page.getByRole('button', { name: 'Show full part', exact: true }).click()
+ await expect(focus).toHaveAttribute('aria-pressed', 'false')
+ await expect.poll(async () => Buffer.compare(focused, await canvas.screenshot())).not.toBe(0)
+ })
+
+ test(`stock present on mount does not move the section midpoint (${projection})`, async ({
+ 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..14e45b5 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.
@@ -68,14 +68,64 @@ 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')
await expect(selected).toHaveText(before ?? '')
})
+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')
+ 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: 'Enable feature hover' })
+ const selected = page.locator('p', { hasText: 'Selected:' })
+
+ 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 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
@@ -102,6 +152,7 @@ test('selects a feature and responds to CAD camera navigation', async ({ page })
await expect(hovered).toContainText('none')
await canvas.click({ position: on(box, CENTRE) })
await expect(cut).toContainText('Part surface')
+ await expect(page.getByText('Cut depth', { exact: true })).toHaveCount(0)
await expect(selected).toContainText('none')
await canvas.click({ position: on(box, ONE) })
await expect(selected).toContainText('none')
@@ -132,6 +183,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')
@@ -172,6 +228,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:' })
@@ -229,6 +286,10 @@ test('measures a distance between two clicks, without selecting either face', as
await canvas.click({ position: on(box, OTHER) })
await expect(measured).toContainText('°')
+ await page.getByRole('button', { name: 'Clear measurements' }).click()
+ await expect(measured).toContainText('none')
+ await expect(labels).toHaveCount(0)
+
await page.getByRole('button', { name: 'Exit measure' }).click()
await expect(measured).toContainText('off')
await expect(labels).toHaveCount(0)
@@ -292,6 +353,9 @@ test('waits for a cut to be chosen before measuring beside the section tool', as
await page.getByRole('button', { name: 'Section' }).click()
await page.getByRole('button', { name: 'Measure', exact: true }).click()
+ await expect(
+ page.getByRole('group', { name: 'Section and measure options', exact: true }),
+ ).toHaveCount(1)
await expect(cut).toContainText('none')
await expect(measured).toContainText('none')
@@ -326,7 +390,27 @@ test('waits for a cut to be chosen before measuring beside the section tool', as
await expect(measured).toContainText('off')
})
-test('pans with either pan button, from wherever the drag starts', async ({ page }) => {
+test('merges the analysis panel regardless of which tool is entered first', async ({ page }) => {
+ await openViewer(page)
+
+ await page.getByRole('button', { name: 'Measure', exact: true }).click()
+ await page.getByRole('button', { name: 'Section', exact: true }).click()
+
+ await expect(
+ page.getByRole('group', { name: 'Section and measure options', exact: true }),
+ ).toHaveCount(1)
+ await expect(page.getByRole('group', { name: 'Measurement type', exact: true })).toHaveCount(1)
+ await expect(page.getByRole('button', { name: 'Exit measure', exact: true })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+ await expect(page.getByRole('button', { name: 'Exit section', exact: true })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ )
+})
+
+test('Toolpath pans with the right button, from wherever the drag starts', async ({ page }) => {
const { canvas, box } = await openViewer(page)
// The , not the inside it: `getByText('Selected:')` matches the
@@ -339,36 +423,33 @@ test('pans with either pan button, from wherever the drag starts', async ({ page
// as the viewport gets, and clear of the toolbar in the top-left. A pan that
// needs the pointer over the part is a pan that stops working on exactly the
// view somebody was trying to fix.
- const panFromCorner = async (button: 'right' | 'middle') => {
+ const panFromCorner = async () => {
const from = { x: box.x + 40, y: box.y + box.height - 40 }
await page.mouse.move(from.x, from.y)
- await page.mouse.down({ button })
+ await page.mouse.down({ button: 'right' })
for (let step = 1; step <= 10; step += 1) {
await page.mouse.move(from.x + step * (box.width * 0.15), from.y)
}
- await page.mouse.up({ button })
+ await page.mouse.up({ button: 'right' })
await page.waitForTimeout(300)
}
- for (const button of ['right', 'middle'] as const) {
- await page.getByRole('button', { name: 'Fit' }).click()
- await page.waitForTimeout(300)
- await canvas.click({ position: centre })
- await expect(selected).not.toContainText('none')
+ await canvas.click({ position: centre })
+ await expect(selected).not.toContainText('none')
- await panFromCorner(button)
+ await panFromCorner()
- // The part has left the middle of the view, which a pan does and an orbit
- // does not: an orbit turns the part about that point and leaves it there.
- // Clicking where it was now hits nothing, which is what puts the selection
- // down.
- await canvas.click({ position: centre })
- await expect(selected).toContainText('none')
- }
+ // The part has left the middle of the view, which a pan does and an orbit
+ // does not: an orbit turns the part about that point and leaves it there.
+ // Clicking where it was now hits nothing, which is what puts the selection
+ // down.
+ await canvas.click({ position: centre })
+ await expect(selected).toContainText('none')
})
test('finishing a drag over a face is not a request to select it', async ({ page }) => {
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:' })
@@ -448,8 +529,10 @@ test('panning over empty space keeps the selection', async ({ page }) => {
* The left button has had this guard on the mesh all along; this is the middle
* one getting it too.
*/
-test('two middle-button pans released in the same place do not re-frame', async ({ page }) => {
- const { canvas, box } = await openViewer(page)
+test('two Onshape middle-button pans released in the same place do not re-frame', async ({
+ page,
+}) => {
+ const { canvas, box } = await openViewer(page, 'controls=onshape')
const selected = page.locator('p', { hasText: 'Selected:' })
const centre = on(box, CENTRE)
diff --git a/packages/viewer/README.md b/packages/viewer/README.md
index f7a91a7..81d7690 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
@@ -95,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
@@ -103,23 +114,221 @@ 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.
+Hover feedback is on by default; pass `hover={false}` to disable 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
+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
+
+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**, in millimetres. The preferred form is
+`{ wall, floor }`: wall stock expands X/Y and floor stock expands Z, matching the wall/floor
+roughing-stock distinction used by machining settings. A number or `{ x, y, z }` remains accepted
+for uniform or axis-specific padding. It defaults to zero. `offset` translates the stock from the
+part's bounding-box centre. `boxStockBounds(geometry, allowance, offset)` returns the same `Box3`
+for displaying dimensions.
+
+For fixed-box stock, pass `dimensions={{ x, y, z }}` instead. `position` accepts
+`model_centered`, `offset_from_top`, or `offset_from_bottom`, and `positionOffset` is the distance
+from the selected top or bottom bound. All fixed-box values use millimetres and the part's Z-up
+coordinate frame.
+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.
+
+### ``
+
+`ViewerToolbar` is a composable, viewer-scoped toolbar for camera, display, section, and measurement
+controls. Import its stylesheet alongside your application stylesheet:
+
+```tsx
+import '@toolpath/viewer/toolbar.css'
+import { ViewerToolbar, ViewerToolbarProvider } from '@toolpath/viewer'
+```
+
+Give `ViewerToolbarProvider` the current values and callbacks from the host app, then place the
+toolbar and viewer inside it. The toolbar owns no application state or behavior: it only reads the
+provider. Omit `children` for every configured control in the standard order, or compose the
+individual buttons to use only the controls and order your application needs.
+
+```tsx
+ setShowStock((shown) => !shown) },
+ measure: { pressed: measuring, onClick: toggleMeasuring },
+ fit: { onClick: () => viewer.current?.fit() },
+ }}
+>
+
+
+
+
+
+
+
+
+ …
+
+```
+
+`ViewerToolbar.Controls`, `Divider`, and the thirteen `*Button` components are available on
+`ViewerToolbar`: `StockButton`, `AxesButton`, `GridButton`, `BananaButton`, `DirectionsButton`,
+`HoverButton`, `FocusButton`, `WireframeButton`, `SectionButton`, `MeasureButton`, `FitButton`,
+`ResetButton`, and `TopButton`. A rendered button requires its matching provider control. For an
+application-specific toolbar component, `useViewerToolbar()` reads the same controls from inside
+the provider.
+
+#### Styling hooks
+
+Every viewer, hover-card, and toolbar DOM boundary has a stable class name. Your `className` on
+``, ``, or `` is appended to the component's own class; it never
+replaces the hook. The toolbar stylesheet uses the same names, so import it for the default look or
+override any of these selectors in your application stylesheet.
+
+| Component | Class | Element |
+| ----------------- | ------------------------- | --------------------------------- |
+| `` | `viewer-root` | Wrapper around the canvas |
+| `` | `viewer-canvas-container` | R3F canvas event container |
+| `` | `viewer-canvas-frame` | R3F's canvas-sizing frame |
+| `` | `viewer-canvas` | The `` itself |
+| `` | `viewer-hover-card` | Cursor-following tooltip shell |
+| `` | `viewer-toolbar-stack` | Outer toolbar stack |
+| `` | `viewer-toolbar` | Controls group |
+| `` | `viewer-toolbar-button` | Each standard control button |
+| `` | `viewer-toolbar-icon` | SVG within a standard control |
+| `` | `viewer-toolbar-tooltip` | Label shown on button hover/focus |
+| `` | `viewer-toolbar-divider` | Separator between control groups |
+
+The following `data-*` attributes identify generated elements without depending on labels, which
+can change with state or localization:
+
+| Attribute | Element and values |
+| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `data-viewer-root="true"` | `` wrapper |
+| `data-viewer-canvas-container="true"` | R3F canvas event container |
+| `data-viewer-canvas-frame="true"` | R3F canvas-sizing frame |
+| `data-viewer-canvas="true"` | The `` itself |
+| `data-viewer-hover-card="true"` | `` shell |
+| `data-viewer-toolbar="true"` | Toolbar stack |
+| `data-viewer-toolbar-controls="true"` | Controls group |
+| `data-viewer-toolbar-action` | Standard button: `stock`, `axes`, `grid`, `banana`, `directions`, `hover`, `focus`, `wireframe`, `section`, `measure`, `fit`, `reset`, or `top` |
+| `data-viewer-toolbar-icon="true"` | Standard button SVG |
+| `data-viewer-toolbar-tooltip="true"` | Standard button tooltip |
+| `data-viewer-toolbar-divider="true"` | Toolbar separator |
+
+Toggle buttons also carry standard `aria-pressed="true"` or `"false"`; use that to style their
+current state. `Fit`, `Reset`, and `Top view` are actions rather than toggles, so they omit the
+attribute.
+
### ``
The canvas that holds everything. Import it from `@toolpath/viewer`.
@@ -127,7 +336,7 @@ The canvas that holds everything. Import it from `@toolpath/viewer`.
| Prop | Default | What it does |
| ----------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------- |
| `projection` | `'orthographic'` | `'orthographic'` keeps parallel lines parallel. `'perspective'` gives depth, which helps with deep pockets. |
-| `controls` | `'toolpath'` | Mouse mapping. See [Mouse controls](#mouse-controls). |
+| `controls` | `'toolpath'` | CAD navigation preset. See [CAD controls](#cad-controls). |
| `zoomTo` | `'cursor'` | Zoom toward the pointer, or toward `'centre'` (often easier on a trackpad). |
| `freeOrbit` | `true` | Let the view keep rotating past straight-up and straight-down. |
| `retargetOnDoubleClick` | `true` | Double-click the part to rotate around that point. |
@@ -142,20 +351,35 @@ Changing `projection` rebuilds the canvas and returns the camera to its starting
The canvas is transparent. To set a background colour, style the wrapper or its parent.
-#### Mouse controls
+#### CAD controls
+
+`controls` changes only the navigation gestures. It does not choose a projection: the viewer stays
+orthographic by default, and `projection="perspective"` remains an independent opt-in.
+
+| Value | Label | Rotate | Pan | Zoom |
+| -------------- | ---------- | ------------------------------------- | ----------------------------------------- | ----------------------------------- |
+| `'toolpath'` | Toolpath | Left-drag | Right-drag | Scroll wheel |
+| `'fusion'` | Fusion | Shift + middle-drag or Shift + scroll | Middle-drag, scroll, or two-finger scroll | Trackpad pinch |
+| `'alias'` | Alias | Left-drag | Middle-drag | Scroll wheel |
+| `'inventor'` | Inventor | Shift + middle-drag or Shift + scroll | Middle-drag, scroll, or two-finger scroll | Trackpad pinch |
+| `'solidworks'` | SolidWorks | Middle-drag | Ctrl + middle-drag | Shift + middle-drag or scroll wheel |
+| `'tinkercad'` | Tinkercad | Right-drag | Shift + right-drag | Scroll wheel |
+| `'powermill'` | PowerMill | Middle-drag | Shift + middle-drag | Scroll wheel |
+| `'onshape'` | Onshape | Right-drag | Middle-drag | Scroll wheel |
+
+Fusion and Inventor treat a two-finger scroll as pan, Shift + two-finger scroll as orbit, and a
+pinch (a wheel event with Ctrl set by the browser) as zoom. Other schemes use one-finger rotate,
+two-finger pinch-and-pan, and three-finger pan on touch screens.
-| Action | `controls="toolpath"` (default) | `controls="fusion"` (like Fusion 360) |
-| --------------------- | ------------------------------- | ------------------------------------------ |
-| Rotate | Left-drag | Shift + middle-drag, or Shift + scroll |
-| Pan | Right-drag or middle-drag | Middle-drag, or scroll / two-finger scroll |
-| Zoom | Scroll wheel | Trackpad pinch |
-| Rotate around a point | Double-click the part | Double-click the part |
-| Fit whole part | Double middle-click | Double middle-click |
+All schemes support double-clicking the part to rotate around that point and double-clicking the
+middle mouse button to fit the whole part. Dragging never selects anything; a click only counts if
+the pointer barely moved.
-In `fusion` mode, left-drag doesn't move the camera, and the scroll wheel pans instead of zooming.
-On touch screens, one finger rotates and two fingers pinch and pan.
+The package exports `CONTROL_SCHEME_OPTIONS`, `ControlScheme`, and `ControlSchemeOption` for a host
+application's settings UI. It deliberately stores no preferences: pass the selected `controls`,
+`freeOrbit`, and `zoomTo` values to `` from your own state or persistence layer.
-Dragging never selects anything. A click only counts if the pointer barely moved.
+`'toolpath'`, `freeOrbit={true}`, and `zoomTo="cursor"` are the defaults.
### ``
@@ -176,28 +400,30 @@ 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**
-| 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.
@@ -478,7 +704,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.
@@ -505,7 +734,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`.
@@ -625,10 +854,11 @@ go on the cube. See `DEFAULT_THEME` for every key. The default part colours are
default lighting, so if you change one, check the other.
The section cut is themed on the part too: `sectionCap` and `sectionHatch` are the cap's fill and
-hatch lines, `sectionHandle` the drag arrow, and `sectionOutline` the cutting-plane sheet and
-preview that `` draws. `measure` and `measureSnap` are ``'s lines and its
-snap indicator; a distance's X, Y, Z legs take `AXIS_COLORS`, the same hues as the section tool's
-global planes.
+hatch lines, `sectionHandle` overrides the drag arrow's colour, and `sectionOutline` the
+cutting-plane sheet and preview that `` draws. Without a `sectionHandle` override, the
+drag arrow uses the cutting plane's direction colour. `measure` and `measureSnap` are
+``'s lines and its snap indicator; a distance's X, Y, Z legs take `AXIS_COLORS`, the
+same hues as the section tool's global planes.
`HIGHLIGHT_COLORS` has the standard selection colours (`default`, `toolIssue`, `geometryIssue`).
`DIRECTION_COLORS` has the 9 direction colours, which repeat for parts with more than 9 directions.
@@ -872,12 +1102,13 @@ is a complete app built this way, with no API key needed.
### Hooks
-| Hook | Use inside `` to… |
-| --------------------- | -------------------------------------------------------------------------------- |
-| `useViewerControls()` | Get `fit`, `reset`, `setView`, `setViewDirection`, `frameBox`, and `setSection`. |
-| `useSectionStore()` | Read, set, or subscribe to the viewer's own cut. |
-| `useContentBox()` | Get the part's bounding box (a `THREE.Box3`, empty until loaded). |
-| `useTapGuard()` | Check whether a pointer event was a click and not a drag. |
+| Hook | Where | What it does |
+| --------------------- | -------------------------------- | -------------------------------------------------------------------------------- |
+| `useViewerControls()` | Inside `` | Get `fit`, `reset`, `setView`, `setViewDirection`, `frameBox`, and `setSection`. |
+| `useViewerToolbar()` | Inside `` | Get the actions and state supplied to the provider. |
+| `useSectionStore()` | Inside `` | Read, set, or subscribe to the viewer's own cut. |
+| `useContentBox()` | Inside `` | Get the part's bounding box (a `THREE.Box3`, empty until loaded). |
+| `useTapGuard()` | Inside `` | Check whether a pointer event was a click and not a drag. |
### Helpers
@@ -903,11 +1134,12 @@ 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`,
-`ViewCubeProps`.
+`ViewCubeProps`, `ViewerToolbarProps`, `ViewerToolbarControlsProps`, `ViewerToolbarControls`,
+`ViewerToolbarControl`.
`FeatureType` and `ShapeKind` accept any string, because newer Engine versions add new values.
Handle values you don't recognize.
diff --git a/packages/viewer/package.json b/packages/viewer/package.json
index cd467b3..34c14cb 100644
--- a/packages/viewer/package.json
+++ b/packages/viewer/package.json
@@ -30,7 +30,8 @@
"./engine": {
"types": "./dist/engine/index.d.ts",
"import": "./dist/engine/index.js"
- }
+ },
+ "./toolbar.css": "./dist/toolbar.css"
},
"files": [
"dist",
@@ -39,8 +40,8 @@
],
"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: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",
+ "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": "node scripts/copy-assets.mjs && 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..56a8950
--- /dev/null
+++ b/packages/viewer/scripts/copy-assets.mjs
@@ -0,0 +1,12 @@
+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)
+const stylesheet = new URL('../src/toolbar.css', import.meta.url)
+const stylesheetTarget = new URL('../dist/toolbar.css', import.meta.url)
+
+await mkdir(fileURLToPath(assetDirectory), { recursive: true })
+await cp(fileURLToPath(source), fileURLToPath(target))
+await cp(fileURLToPath(stylesheet), fileURLToPath(stylesheetTarget))
diff --git a/packages/viewer/src/assets/banana.glb b/packages/viewer/src/assets/banana.glb
new file mode 100644
index 0000000..f6dc32a
Binary files /dev/null and b/packages/viewer/src/assets/banana.glb differ
diff --git a/packages/viewer/src/banana.tsx b/packages/viewer/src/banana.tsx
new file mode 100644
index 0000000..d155f19
--- /dev/null
+++ b/packages/viewer/src/banana.tsx
@@ -0,0 +1,75 @@
+import { useGLTF } from '@react-three/drei'
+import { useThree } from '@react-three/fiber'
+import { useLayoutEffect, useMemo, useRef } from 'react'
+import { Box3, type BufferGeometry, type Group, type Mesh } from 'three'
+import { useContentBox } from './content-box.js'
+import { EXCLUDE_FROM_FRAME } from './render/camera.js'
+import { bananaFrameBounds, bananaPosition } from './render/banana.js'
+
+/** The six-inch (154 mm) reference model bundled with `@toolpath/viewer`. */
+export const BANANA_MODEL_URL = new URL('./assets/banana.glb', import.meta.url).href
+
+const SKIN = '#e8b530'
+const FURNITURE = { [EXCLUDE_FROM_FRAME]: true }
+
+export interface BananaProps {
+ /** Bounds of the part and placed banana, for `ViewerHandle.frameBox`. */
+ onPlaced?: (bounds: Box3) => void
+}
+
+/**
+ * A 154 mm banana beside the part, for immediate visual scale.
+ *
+ * It is excluded from normal part framing and overlay placement. Applications
+ * can use `onPlaced` to intentionally frame the comparison together. The model
+ * is fetched only when this component is mounted.
+ */
+export const Banana = ({ onPlaced }: BananaProps) => {
+ const part = useContentBox()
+ const invalidate = useThree((state) => state.invalidate)
+ const gltf = useGLTF(BANANA_MODEL_URL)
+ const group = useRef(null)
+
+ const geometry = useMemo(() => {
+ let found: BufferGeometry | null = null
+ gltf.scene.traverse((object) => {
+ const mesh = object as Mesh
+ if (found === null && mesh.isMesh) found = mesh.geometry
+ })
+ const source = found as BufferGeometry | null
+ if (!source) return null
+
+ const own = source.clone()
+ own.computeVertexNormals()
+ own.rotateY(Math.PI / 2)
+ own.rotateX(Math.PI / 2)
+ own.computeBoundingBox()
+ return own
+ }, [gltf])
+
+ useLayoutEffect(() => {
+ const node = group.current
+ const own = geometry?.boundingBox
+ if (!node || !own || part.isEmpty()) return
+
+ const position = bananaPosition(part, own)
+ node.position.copy(position)
+ // The content bounds arrive one frame after a Suspense-loaded part. Until
+ // then the group would render at its default origin, briefly inside the
+ // part, before this placement effect could move it beside the part.
+ node.visible = true
+ node.updateWorldMatrix(true, true)
+ onPlaced?.(bananaFrameBounds(part, own, position))
+ invalidate()
+ }, [geometry, invalidate, onPlaced, part])
+
+ if (!geometry) return null
+
+ return (
+
+
+
+
+
+ )
+}
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/hover-card.tsx b/packages/viewer/src/hover-card.tsx
new file mode 100644
index 0000000..4715b1b
--- /dev/null
+++ b/packages/viewer/src/hover-card.tsx
@@ -0,0 +1,48 @@
+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
+ /** Added beside the stable `viewer-hover-card` class. */
+ 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 22c90bb..4524fc2 100644
--- a/packages/viewer/src/index.ts
+++ b/packages/viewer/src/index.ts
@@ -5,6 +5,21 @@
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 { ViewerToolbar, ViewerToolbarProvider, useViewerToolbar } from './viewer-toolbar.js'
+export type {
+ ViewerToolbarControl,
+ ViewerToolbarControls,
+ ViewerToolbarControlsProps,
+ ViewerToolbarProps,
+} from './viewer-toolbar.js'
+export { Stock, BoxStock } from './stock.js'
+export type { StockProps, BoxStockProps } from './stock.js'
+export { Banana, BANANA_MODEL_URL } from './banana.js'
+export { boxStockBounds, fixedBoxStockBounds } from './render/stock.js'
+export type { StockAllowance, StockPosition } 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'
@@ -49,6 +64,7 @@ export {
PICKED_SURFACE_LABEL,
PREVIEW_SCALE,
SECTION_RENDER_ORDER,
+ SECTION_GIZMO_FRAME_SCALE,
axesPlanes,
dragPlane,
hitUnderRay,
@@ -61,6 +77,7 @@ export {
sectionDepthRange,
sectionFromPick,
sectionOffset,
+ sectionMeasurement,
sectionOptionsFromState,
sectionPlane,
surfaceUnderRay,
@@ -100,6 +117,7 @@ export {
viewVector,
} from './render/view-cube.js'
export { gridGeometry, gridSpec } from './render/grid.js'
+export { BANANA_GAP, bananaFrameBounds, bananaPosition } from './render/banana.js'
export { regionEdgesGeometry } from './render/edges.js'
export { visualSurfaces } from './model/surfaces.js'
export { CadCameraControls } from './camera.js'
@@ -128,6 +146,7 @@ export {
targetBoundary,
} from './render/camera.js'
export { ExtendedCameraControls } from './render/controls.js'
+export { CONTROL_SCHEME_OPTIONS } from './render/control-schemes.js'
export { useRetarget, useSectionStore, useViewerControls, Viewer } from './viewer.js'
export { PartReportFormatError, UnsupportedKernelVersionError } from './model/errors.js'
export { buildRegionIndex } from './model/region-index.js'
@@ -179,7 +198,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,
@@ -200,8 +219,11 @@ 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 { BananaProps } from './banana.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'
@@ -242,5 +264,6 @@ export type {
ViewerCamera,
ViewportSize,
} from './render/camera.js'
-export type { ControlScheme, ExtendedCameraControlsOptions } from './render/controls.js'
+export type { ExtendedCameraControlsOptions } from './render/controls.js'
+export type { ControlScheme, ControlSchemeOption } from './render/control-schemes.js'
export type { Retarget, ViewerProps } from './viewer.js'
diff --git a/packages/viewer/src/part-mesh.tsx b/packages/viewer/src/part-mesh.tsx
index e307dc0..ced42d3 100644
--- a/packages/viewer/src/part-mesh.tsx
+++ b/packages/viewer/src/part-mesh.tsx
@@ -12,17 +12,19 @@ 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 { focusStateKey, type FocusOptions } from './render/focus.js'
import {
DISABLED_SECTION,
type SectionOptions,
type SectionState,
sectionBounds,
+ sectionCutDistance,
sectionDepth,
sectionOffset,
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'
@@ -46,6 +48,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.
@@ -67,6 +74,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
@@ -120,6 +132,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
}
/**
@@ -136,11 +150,13 @@ export const PartMesh = ({
model,
geometry,
selection = [],
+ focus,
candidates = [],
highlights = [],
regionHighlights = [],
pickedRegions = [],
hoveredFeatureIds = [],
+ hover = true,
activeDirection = null,
section,
onSectionChange,
@@ -150,6 +166,7 @@ export const PartMesh = ({
onPick,
theme,
showEdges = true,
+ display = 'solid',
}: PartMeshProps) => {
const { camera, controls, invalidate } = useThree()
const viewerControls = useViewerControls()
@@ -160,6 +177,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.
@@ -230,10 +251,20 @@ export const PartMesh = ({
repaint()
}, [part, repaint, resolved])
+ const focusRef = useRef(focus)
+ focusRef.current = focus
+ const selectionRef = useRef(selection)
+ selectionRef.current = selection
+ const focusKey = focusStateKey(focus, selection)
+ useLayoutEffect(() => {
+ part.setFocus(selectionRef.current, focusRef.current)
+ invalidate()
+ }, [focusKey, invalidate, part])
+
useLayoutEffect(() => {
- part.edges.visible = showEdges
+ part.setDisplay(display, showEdges)
invalidate()
- }, [invalidate, part, showEdges])
+ }, [display, invalidate, part, showEdges])
useLayoutEffect(() => {
part.setClippingPlanes(cut ? [cut.plane] : null)
@@ -299,6 +330,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),
@@ -323,10 +355,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
@@ -338,11 +378,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))
@@ -359,7 +402,7 @@ export const PartMesh = ({
pressedWhileEngaged.current = engaged
}}
onPointerMove={(event: ThreeEvent) => {
- if (!engaged) emitHover(pickFor(event))
+ if (!engaged && hover) emitHover(pickFor(event))
}}
onPointerOut={() => {
emitHover(null)
@@ -465,6 +508,7 @@ export const PartMesh = ({
box={box}
plane={cut.plane}
theme={resolved}
+ handleColor={theme?.sectionHandle}
showHandle={!controlled || onSectionChange !== undefined}
onDrag={!controlled || onSectionChange ? dragSection : undefined}
/>
diff --git a/packages/viewer/src/render/banana.ts b/packages/viewer/src/render/banana.ts
new file mode 100644
index 0000000..0e1bed6
--- /dev/null
+++ b/packages/viewer/src/render/banana.ts
@@ -0,0 +1,22 @@
+import { Box3, Vector3 } from 'three'
+
+/** The clearance between a part and the banana, as a fraction of part reach. */
+export const BANANA_GAP = 0.1
+
+/** Places a banana on the same ground plane, just to the right of a part. */
+export function bananaPosition(part: Box3, banana: Box3): Vector3 {
+ const size = part.getSize(new Vector3())
+ const reach = Math.max(size.x, size.y, size.z)
+ const centre = part.getCenter(new Vector3())
+
+ return new Vector3(
+ part.max.x + reach * BANANA_GAP - banana.min.x,
+ centre.y - (banana.min.y + banana.max.y) / 2,
+ part.min.z - banana.min.z,
+ )
+}
+
+/** The bounds a consumer should frame when showing a banana for scale. */
+export function bananaFrameBounds(part: Box3, banana: Box3, position: Vector3): Box3 {
+ return part.clone().union(banana.clone().translate(position))
+}
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/control-schemes.ts b/packages/viewer/src/render/control-schemes.ts
new file mode 100644
index 0000000..cc60e30
--- /dev/null
+++ b/packages/viewer/src/render/control-schemes.ts
@@ -0,0 +1,141 @@
+/** A named CAD navigation preset a consumer may pass to ``. */
+export const CONTROL_SCHEME_OPTIONS = [
+ { value: 'toolpath', label: 'Toolpath' },
+ { value: 'fusion', label: 'Fusion' },
+ { value: 'alias', label: 'Alias' },
+ { value: 'inventor', label: 'Inventor' },
+ { value: 'solidworks', label: 'SolidWorks' },
+ { value: 'tinkercad', label: 'Tinkercad' },
+ { value: 'powermill', label: 'PowerMill' },
+ { value: 'onshape', label: 'Onshape' },
+] as const
+
+/** The public values accepted by `` and `CadCameraControls`. */
+export type ControlScheme = (typeof CONTROL_SCHEME_OPTIONS)[number]['value']
+
+export type ControlSchemeOption = (typeof CONTROL_SCHEME_OPTIONS)[number]
+
+type PointerAction = 'none' | 'rotate' | 'truck' | 'zoom'
+
+type WheelAction = 'none' | 'zoom' | 'dolly'
+
+export interface ControlSchemeMapping {
+ readonly mouse: {
+ readonly left: PointerAction
+ readonly middle: PointerAction
+ readonly right: PointerAction
+ readonly wheel: WheelAction
+ }
+ readonly touches: {
+ readonly one: 'rotate'
+ readonly two: 'rotate' | 'truck' | 'dolly-truck'
+ readonly three: 'truck'
+ }
+ /** Fusion and Inventor distinguish a trackpad scroll from a pinch wheel event. */
+ readonly usesCadWheel: boolean
+ /** These presets track pointer and trackpad movement with no damping. */
+ readonly immediate: boolean
+}
+
+interface ControlModifiers {
+ readonly shift: boolean
+ readonly ctrl: boolean
+}
+
+export interface ControlSchemeEnvironment {
+ readonly orthographic: boolean
+ readonly modifiers: ControlModifiers
+}
+
+const DEFAULT_TOUCHES: ControlSchemeMapping['touches'] = {
+ one: 'rotate',
+ two: 'dolly-truck',
+ three: 'truck',
+}
+
+const wheelFor = (orthographic: boolean): WheelAction => (orthographic ? 'zoom' : 'dolly')
+
+const mapping = (
+ environment: ControlSchemeEnvironment,
+ mouse: Partial,
+ options: Pick,
+ touches: ControlSchemeMapping['touches'] = DEFAULT_TOUCHES,
+): ControlSchemeMapping => ({
+ mouse: {
+ left: 'none',
+ middle: 'none',
+ right: 'none',
+ wheel: wheelFor(environment.orthographic),
+ ...mouse,
+ },
+ touches,
+ ...options,
+})
+
+/**
+ * Resolves the pointer, touch, and wheel actions for a named CAD preset.
+ *
+ * This is deliberately data-only: `ExtendedCameraControls` owns the DOM
+ * listeners and translates these names to camera-controls actions, while this
+ * function is the single, testable record of Toolpath's navigation parity.
+ */
+export const resolveControlScheme = (
+ scheme: ControlScheme,
+ environment: ControlSchemeEnvironment,
+): ControlSchemeMapping => {
+ const { shift, ctrl } = environment.modifiers
+
+ switch (scheme) {
+ case 'fusion':
+ case 'inventor': {
+ const rotating = shift
+ return mapping(
+ environment,
+ { middle: rotating ? 'rotate' : 'truck', wheel: 'none' },
+ { usesCadWheel: true, immediate: true },
+ {
+ one: 'rotate',
+ two: rotating ? 'rotate' : 'truck',
+ three: 'truck',
+ },
+ )
+ }
+ case 'solidworks':
+ // This order matches the legacy viewer: Shift wins when both are held.
+ return mapping(
+ environment,
+ { middle: shift ? 'zoom' : ctrl ? 'truck' : 'rotate' },
+ { usesCadWheel: false, immediate: false },
+ )
+ case 'alias':
+ return mapping(
+ environment,
+ { left: 'rotate', middle: 'truck' },
+ { usesCadWheel: false, immediate: false },
+ )
+ case 'tinkercad':
+ return mapping(
+ environment,
+ { right: shift ? 'truck' : 'rotate' },
+ { usesCadWheel: false, immediate: false },
+ )
+ case 'powermill':
+ return mapping(
+ environment,
+ { middle: shift ? 'truck' : 'rotate' },
+ { usesCadWheel: false, immediate: false },
+ )
+ case 'onshape':
+ return mapping(
+ environment,
+ { middle: 'truck', right: 'rotate' },
+ { usesCadWheel: false, immediate: false },
+ )
+ case 'toolpath':
+ return mapping(
+ environment,
+ { left: 'rotate', right: 'truck' },
+ { usesCadWheel: false, immediate: false },
+ )
+ }
+}
diff --git a/packages/viewer/src/render/controls.ts b/packages/viewer/src/render/controls.ts
index 96ead68..a19b814 100644
--- a/packages/viewer/src/render/controls.ts
+++ b/packages/viewer/src/render/controls.ts
@@ -14,6 +14,8 @@ import {
import { adaptedUp } from './camera.js'
import type { CameraLimits, ViewerCamera } from './camera.js'
+import { resolveControlScheme } from './control-schemes.js'
+import type { ControlScheme, ControlSchemeMapping } from './control-schemes.js'
/**
* `camera-controls` needs the three classes it constructs injected once, and
@@ -37,16 +39,7 @@ CameraControls.install({
},
})
-/**
- * Mouse and trackpad presets.
- *
- * - `toolpath` — left-drag orbits, right- and middle-drag pan. The product
- * default.
- * - `fusion` — middle-drag and two-finger scroll pan, shift makes them orbit,
- * pinch zooms. Matches Fusion 360, which is what most of our users have open
- * in the other window.
- */
-export type ControlScheme = 'toolpath' | 'fusion'
+export type { ControlScheme } from './control-schemes.js'
export type ExtendedCameraControlsOptions = {
/**
@@ -106,8 +99,8 @@ const DOLLY_SPEED = 1.15
const REST_THRESHOLD = 0.005
/**
- * `CameraControls` with free orbit, camera-relative up, and the Fusion wheel
- * scheme.
+ * `CameraControls` with free orbit, camera-relative up, and CAD navigation
+ * schemes.
*
* Two departures from the legacy implementation, both deliberate:
*
@@ -128,6 +121,7 @@ export class ExtendedCameraControls extends CameraControls {
#attached = false
#autoUpEnabled = false
#shiftPressed = false
+ #ctrlPressed = false
#wheelHandler: ((event: WheelEvent) => void) | null = null
// Scratch objects — `#onPointerMove` and `#adaptUpVector` run at pointer and
@@ -200,7 +194,7 @@ export class ExtendedCameraControls extends CameraControls {
view?.addEventListener('keydown', this.#onModifierChange)
view?.addEventListener('keyup', this.#onModifierChange)
// A window that loses focus never delivers the matching keyup, which would
- // otherwise leave the Fusion scheme stuck in its shift variant.
+ // otherwise leave a modifier-aware scheme stuck in its shifted variant.
view?.addEventListener('blur', this.#onWindowBlur)
if (this.#freeOrbit) {
@@ -224,7 +218,7 @@ export class ExtendedCameraControls extends CameraControls {
view?.removeEventListener('blur', this.#onWindowBlur)
this.#disableAutoUp()
- this.#disableFusionWheel()
+ this.#disableCadWheel()
}
override dispose(): void {
@@ -239,52 +233,45 @@ export class ExtendedCameraControls extends CameraControls {
*/
applyScheme(scheme: ControlScheme): void {
this.#scheme = scheme
+ const mapping = resolveControlScheme(scheme, {
+ orthographic: this.camera instanceof OrthographicCamera,
+ modifiers: { shift: this.#shiftPressed, ctrl: this.#ctrlPressed },
+ })
- this.mouseButtons.left = CameraControls.ACTION.NONE
- this.mouseButtons.middle = CameraControls.ACTION.NONE
- this.mouseButtons.right = CameraControls.ACTION.NONE
- this.mouseButtons.wheel = CameraControls.ACTION.NONE
-
- this.touches.one = CameraControls.ACTION.TOUCH_ROTATE
- this.touches.two = CameraControls.ACTION.TOUCH_DOLLY_TRUCK
- this.touches.three = CameraControls.ACTION.TOUCH_TRUCK
-
- this.smoothTime = DEFAULT_SMOOTH_TIME
- this.draggingSmoothTime = DEFAULT_SMOOTH_TIME
-
- this.#disableFusionWheel()
-
- if (scheme === 'fusion') {
- // Shift turns the pan gestures into orbit gestures, matching Fusion.
- const rotating = this.#shiftPressed
-
- this.mouseButtons.middle = rotating
- ? CameraControls.ACTION.ROTATE
- : CameraControls.ACTION.TRUCK
- this.touches.two = rotating
- ? CameraControls.ACTION.TOUCH_ROTATE
- : CameraControls.ACTION.TOUCH_TRUCK
-
- // Fusion feels wrong with damping; the view has to track the trackpad.
- this.smoothTime = 0
- this.draggingSmoothTime = 0
- this.#enableFusionWheel()
+ this.#applyMapping(mapping)
+ }
- return
+ #applyMapping(mapping: ControlSchemeMapping): void {
+ const action = {
+ none: CameraControls.ACTION.NONE,
+ rotate: CameraControls.ACTION.ROTATE,
+ truck: CameraControls.ACTION.TRUCK,
+ zoom: CameraControls.ACTION.ZOOM,
+ dolly: CameraControls.ACTION.DOLLY,
+ } as const
+ const touchAction = {
+ rotate: CameraControls.ACTION.TOUCH_ROTATE,
+ truck: CameraControls.ACTION.TOUCH_TRUCK,
+ 'dolly-truck': CameraControls.ACTION.TOUCH_DOLLY_TRUCK,
+ } as const
+
+ this.#disableCadWheel()
+
+ this.mouseButtons.left = action[mapping.mouse.left]
+ this.mouseButtons.middle = action[mapping.mouse.middle]
+ this.mouseButtons.right = action[mapping.mouse.right]
+ this.mouseButtons.wheel = action[mapping.mouse.wheel]
+
+ this.touches.one = touchAction[mapping.touches.one]
+ this.touches.two = touchAction[mapping.touches.two]
+ this.touches.three = touchAction[mapping.touches.three]
+
+ this.smoothTime = mapping.immediate ? 0 : DEFAULT_SMOOTH_TIME
+ this.draggingSmoothTime = mapping.immediate ? 0 : DEFAULT_SMOOTH_TIME
+
+ if (mapping.usesCadWheel) {
+ this.#enableCadWheel()
}
-
- this.mouseButtons.left = CameraControls.ACTION.ROTATE
- this.mouseButtons.right = CameraControls.ACTION.TRUCK
- // Middle-drag pans too. It is the pan gesture in SolidWorks, Fusion and
- // Onshape, so somebody arriving from any of them reaches for it first —
- // and a gesture that does nothing reads as a viewport that has hung.
- this.mouseButtons.middle = CameraControls.ACTION.TRUCK
- // Dollying an orthographic camera moves it without changing what the
- // frustum covers, so the wheel has to scale the frustum instead.
- this.mouseButtons.wheel =
- this.camera instanceof OrthographicCamera
- ? CameraControls.ACTION.ZOOM
- : CameraControls.ACTION.DOLLY
}
setFreeOrbit(freeOrbit: boolean): void {
@@ -333,14 +320,18 @@ export class ExtendedCameraControls extends CameraControls {
this.removeEventListener('update', this.#adaptUpVector)
}
- #enableFusionWheel(): void {
- this.#wheelHandler = (event: WheelEvent) => this.#onFusionWheel(event)
+ #enableCadWheel(): void {
+ if (!this.#attached || this.#wheelHandler) {
+ return
+ }
+
+ this.#wheelHandler = (event: WheelEvent) => this.#onCadWheel(event)
this.#domElement.addEventListener('wheel', this.#wheelHandler, {
passive: false,
})
}
- #disableFusionWheel(): void {
+ #disableCadWheel(): void {
if (!this.#wheelHandler) {
return
}
@@ -399,7 +390,7 @@ export class ExtendedCameraControls extends CameraControls {
this.update(0)
}
- #onFusionWheel = (event: WheelEvent): void => {
+ #onCadWheel = (event: WheelEvent): void => {
event.preventDefault()
if (event.ctrlKey) {
@@ -423,23 +414,20 @@ export class ExtendedCameraControls extends CameraControls {
}
#onModifierChange = (event: KeyboardEvent): void => {
- this.#setShiftPressed(event.shiftKey)
+ this.#setModifiers(event.shiftKey, event.ctrlKey)
}
#onWindowBlur = (): void => {
- this.#setShiftPressed(false)
+ this.#setModifiers(false, false)
}
- #setShiftPressed(pressed: boolean): void {
- if (this.#shiftPressed === pressed) {
+ #setModifiers(shift: boolean, ctrl: boolean): void {
+ if (this.#shiftPressed === shift && this.#ctrlPressed === ctrl) {
return
}
- this.#shiftPressed = pressed
-
- // Only the Fusion scheme reads the modifier, so nothing else has to churn.
- if (this.#scheme === 'fusion') {
- this.applyScheme(this.#scheme)
- }
+ this.#shiftPressed = shift
+ this.#ctrlPressed = ctrl
+ this.applyScheme(this.#scheme)
}
}
diff --git a/packages/viewer/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/focus.ts b/packages/viewer/src/render/focus.ts
new file mode 100644
index 0000000..abcaf64
--- /dev/null
+++ b/packages/viewer/src/render/focus.ts
@@ -0,0 +1,35 @@
+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)
+}
+
+/** A stable effect key that distinguishes disabled focus from its default mode. */
+export function focusStateKey(
+ options: FocusOptions | undefined,
+ selection: readonly FeatureTag[],
+): string {
+ return `${options === undefined ? 'off' : `on:${options.opacity ?? ''}`}|${selection.join(' ')}`
+}
+
+/** The union of regions owned by the selected features. */
+export function focusedRegions(
+ model: PartModel,
+ 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 9cb72da..faf11c9 100644
--- a/packages/viewer/src/render/part.ts
+++ b/packages/viewer/src/render/part.ts
@@ -3,6 +3,7 @@ import {
type BufferGeometry,
Color,
DataTexture,
+ DoubleSide,
Float32BufferAttribute,
Group,
LineBasicMaterial,
@@ -10,12 +11,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. */
@@ -24,6 +27,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.
*
@@ -52,6 +57,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
/**
@@ -61,6 +70,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
}
@@ -163,6 +174,14 @@ 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 focusState = { value: false }
+ const wireframe = { value: false }
+ let focused = false
+ let currentTheme = theme
const material = new MeshLambertMaterial({
color: theme.part,
@@ -174,51 +193,76 @@ export function createPart(
polygonOffsetUnits: 1,
})
- material.onBeforeCompile = (shader) => {
- shader.uniforms['uRegionState'] = { value: stateTexture }
-
- shader.vertexShader = shader.vertexShader
- .replace(
- '#include ',
- `#include
+ const configureShader = (target: MeshLambertMaterial, opaqueFocusPass: boolean) => {
+ target.onBeforeCompile = (shader) => {
+ shader.uniforms['uRegionState'] = { value: stateTexture }
+ shader.uniforms['uRegionOpacity'] = { value: opacityTexture }
+ shader.uniforms['uWireframe'] = wireframe
+ shader.uniforms['uFocus'] = focusState
+ shader.uniforms['uOpaqueFocusPass'] = { value: opaqueFocusPass }
+
+ shader.vertexShader = shader.vertexShader
+ .replace(
+ '#include ',
+ `#include
attribute float ${REGION_ATTRIBUTE};
varying float vRegion;`,
- )
- .replace(
- '#include ',
- `#include
+ )
+ .replace(
+ '#include ',
+ `#include
vRegion = ${REGION_ATTRIBUTE};`,
- )
+ )
- shader.fragmentShader = shader.fragmentShader
- .replace(
- '#include ',
- `#include
+ shader.fragmentShader = shader.fragmentShader
+ .replace(
+ '#include ',
+ `#include
uniform sampler2D uRegionState;
+ uniform sampler2D uRegionOpacity;
+ uniform bool uWireframe;
+ uniform bool uFocus;
+ uniform bool uOpaqueFocusPass;
varying float vRegion;
vec4 regionState;`,
- )
- // three compiles built-in materials as GLSL 3.0, so `texelFetch` is
- // available: an exact integer lookup, with no filtering to defeat and no
- // texture width to pass in as a second uniform.
- .replace(
- '#include ',
- `#include
+ )
+ // three compiles built-in materials as GLSL 3.0, so `texelFetch` is
+ // available: an exact integer lookup, with no filtering to defeat and no
+ // texture width to pass in as a second uniform.
+ .replace(
+ '#include ',
+ `#include
regionState = texelFetch(uRegionState, ivec2(int(vRegion + 0.5), 0), 0);
diffuseColor.rgb = mix(diffuseColor.rgb, regionState.rgb, regionState.a);
+ float regionOpacity = texelFetch(uRegionOpacity, ivec2(int(vRegion + 0.5), 0), 0).r;
+ if (uFocus && uOpaqueFocusPass && regionOpacity < 0.999) discard;
+ if (uFocus && !uOpaqueFocusPass && regionOpacity >= 0.999) discard;
+ diffuseColor.a *= regionOpacity;
+ if (uWireframe) diffuseColor.a *= regionState.a;
`,
- )
- .replace(
- '#include ',
- `#include
+ )
+ .replace(
+ '#include ',
+ `#include
totalEmissiveRadiance = mix(
totalEmissiveRadiance,
regionState.rgb * ${EMISSIVE_MIX.toFixed(2)},
regionState.a
);`,
- )
+ )
+ }
}
+ const focusMaterial = new MeshLambertMaterial({
+ color: theme.part,
+ emissive: theme.partEmissive,
+ // A selected face can be viewed through the part from the side its normal
+ // faces away from.
+ side: DoubleSide,
+ })
+ configureShader(focusMaterial, true)
+ configureShader(material, false)
+
const mesh = new Mesh(geometry, material)
// Leaves room below for a section's stencil pass and its cap, which would
// otherwise be painted over by the very surface they exist to cap.
@@ -238,8 +282,24 @@ export function createPart(
// a line with no face index, which is not a surface anyone clicked.
edges.raycast = () => {}
+ const focusMesh = new Mesh(geometry, focusMaterial)
+ focusMesh.renderOrder = 3
+ focusMesh.visible = false
+ focusMesh.raycast = () => {}
+
+ const syncTransparency = () => {
+ const transparent = wireframe.value || focused
+ if (material.transparent !== transparent || material.depthWrite !== !transparent) {
+ material.transparent = transparent
+ material.depthWrite = !transparent
+ material.needsUpdate = true
+ }
+ focusState.value = focused
+ focusMesh.visible = focused
+ }
+
const object = new Group()
- object.add(mesh, edges)
+ object.add(focusMesh, mesh, edges)
const scratchColor = new Color()
const scratchVector = new Vector3()
@@ -292,6 +352,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
@@ -317,14 +405,29 @@ export function createPart(
setClippingPlanes(planes) {
const value = planes === null ? null : [...planes]
material.clippingPlanes = value
+ focusMaterial.clippingPlanes = value
edgeMaterial.clippingPlanes = value
},
setTheme(next) {
+ currentTheme = next
material.color.setHex(next.part)
material.emissive.setHex(next.partEmissive)
- edgeMaterial.color.setHex(next.edge)
- edgeMaterial.opacity = next.edgeOpacity
+ focusMaterial.color.setHex(next.part)
+ focusMaterial.emissive.setHex(next.partEmissive)
+ 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
+ syncTransparency()
+ }
+ edges.visible = enabled || showEdges
+ edgeMaterial.color.setHex(enabled ? currentTheme.part : currentTheme.edge)
+ edgeMaterial.opacity = enabled ? 1 : currentTheme.edgeOpacity
},
dispose() {
@@ -335,9 +438,11 @@ export function createPart(
geometry.deleteAttribute(REGION_ATTRIBUTE)
}
material.dispose()
+ focusMaterial.dispose()
edgeGeometry.dispose()
edgeMaterial.dispose()
stateTexture.dispose()
+ opacityTexture.dispose()
},
}
}
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/src/render/section.ts b/packages/viewer/src/render/section.ts
index 6695a43..80472fd 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 { excludedFromFrame } from './camera.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,46 @@ 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 ?? 0).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)
+}
+
+/** An explicit theme colour wins; otherwise the handle identifies its plane. */
+export function sectionHandleColor(themeColor: number | undefined, directionColor: number): number {
+ return themeColor ?? directionColor
+}
+
/**
* The options that would resolve to `state` again.
*
@@ -185,9 +232,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 +246,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
}
@@ -289,7 +336,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 +354,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 +369,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/render/stock.ts b/packages/viewer/src/render/stock.ts
new file mode 100644
index 0000000..3721007
--- /dev/null
+++ b/packages/viewer/src/render/stock.ts
@@ -0,0 +1,134 @@
+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 stock left in millimetres, in the part's coordinates.
+ *
+ * `wall` is radial stock on X/Y and `floor` is axial stock on Z. The number
+ * and `{ x, y, z }` forms remain accepted for callers using the original
+ * uniform/axis-specific box-stock API.
+ */
+export type StockAllowance = number | Vec3 | { wall: number; floor: number }
+
+/** How an explicit box is positioned relative to the part's Z bounds. */
+export type StockPosition = 'model_centered' | 'offset_from_top' | 'offset_from_bottom'
+
+/**
+ * Bounds for an explicit fixed box. X/Y are centered on the part's bounding
+ * box; Z is centered, offset from the top, or offset from the bottom. `offset`
+ * then translates the complete box in part coordinates.
+ */
+export function fixedBoxStockBounds(
+ geometry: BufferGeometry,
+ dimensions: Vec3,
+ position: StockPosition = 'model_centered',
+ positionOffset = 0,
+ offset: Vec3 = { x: 0, y: 0, z: 0 },
+): Box3 {
+ if (
+ ![dimensions.x, dimensions.y, dimensions.z].every(Number.isFinite) ||
+ [dimensions.x, dimensions.y, dimensions.z].some((value) => value <= 0)
+ ) {
+ throw new RangeError('Fixed stock dimensions must be finite and positive.')
+ }
+ if (!Number.isFinite(positionOffset)) {
+ throw new RangeError('Fixed stock position offset must be finite.')
+ }
+ if (![offset.x, offset.y, offset.z].every(Number.isFinite)) {
+ throw new RangeError('Fixed stock offset must be finite.')
+ }
+ const part = partBounds(geometry)
+ const center = part.getCenter(new Vector3())
+ const z =
+ position === 'offset_from_top'
+ ? part.max.z + positionOffset - dimensions.z / 2
+ : position === 'offset_from_bottom'
+ ? part.min.z - positionOffset + dimensions.z / 2
+ : center.z
+ const half = new Vector3(dimensions.x / 2, dimensions.y / 2, dimensions.z / 2)
+ return new Box3(
+ new Vector3(center.x, center.y, z).sub(half),
+ new Vector3(center.x, center.y, z).add(half),
+ ).translate(new Vector3(offset.x, offset.y, offset.z))
+}
+
+/** Per-side allowance and centre offset in millimetres, in the part's coordinates. */
+export function boxStockBounds(
+ geometry: BufferGeometry,
+ allowance: StockAllowance = 0,
+ offset: Vec3 = { x: 0, y: 0, z: 0 },
+): Box3 {
+ const padding =
+ typeof allowance === 'number'
+ ? new Vector3(allowance, allowance, allowance)
+ : 'wall' in allowance
+ ? new Vector3(allowance.wall, allowance.wall, allowance.floor)
+ : 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 box = partBounds(geometry)
+ 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
+}
+
+function partBounds(geometry: BufferGeometry): Box3 {
+ 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)
+ }
+ 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/render/theme.ts b/packages/viewer/src/render/theme.ts
index 2b8e444..f113e99 100644
--- a/packages/viewer/src/render/theme.ts
+++ b/packages/viewer/src/render/theme.ts
@@ -38,7 +38,8 @@ export interface ViewerTheme {
/** The cutting plane itself — the translucent sheet `` draws, its outline, and the surface preview. */
readonly sectionOutline: number
/**
- * The arrow that drags the cut, and the shell that outlines it. Hovered it
+ * The arrow that drags the cut when overridden. When omitted from a theme
+ * override, the handle uses the cutting plane's direction colour. Hovered it
* takes {@link ViewerTheme.hover}, like every other control here.
*/
readonly sectionHandle: number
diff --git a/packages/viewer/src/section-tool.tsx b/packages/viewer/src/section-tool.tsx
index e899dbc..934e71c 100644
--- a/packages/viewer/src/section-tool.tsx
+++ b/packages/viewer/src/section-tool.tsx
@@ -192,6 +192,10 @@ const Picker = ({ box, theme }: PickerProps) => {
// The cut goes where the preview was, and a hair inside it: a plane
// exactly on the face cuts nothing and fights the face for the pixel.
const up = (event: PointerEvent) => {
+ // A click can arrive without a preceding pointermove (for example when
+ // a test or keyboard user activates the canvas), so sample the release
+ // position before relying on the preview's cached hover.
+ move(event)
const hit = hover.current
if (event.button !== 0 || !hit || !isTap(event)) return
store.set({
diff --git a/packages/viewer/src/section-view.tsx b/packages/viewer/src/section-view.tsx
index d1a7d6d..683ad23 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,9 @@ import {
sectionDepth,
sectionDepthConstant,
sectionDepthRange,
+ sectionCutDistance,
+ sectionDirectionColor,
+ sectionHandleColor,
sectionOffset,
} from './render/section.js'
import {
@@ -77,6 +83,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),
},
}
@@ -87,6 +94,8 @@ interface SectionViewProps {
box: Box3
plane: Plane
theme: ViewerTheme
+ /** An explicit `sectionHandle` override, if the caller supplied one. */
+ handleColor?: number
showHandle: boolean
onDrag?: (constant: number) => void
}
@@ -118,6 +127,7 @@ export const SectionView = ({
box,
plane,
theme,
+ handleColor,
showHandle,
onDrag,
}: SectionViewProps) => {
@@ -134,6 +144,9 @@ 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 resolvedHandleColor = sectionHandleColor(handleColor, directionColor)
const clip = useMemo(() => [plane], [plane])
// Where the cap sits: on the plane, over the part's centre.
@@ -148,6 +161,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 +353,70 @@ 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/src/stock.tsx b/packages/viewer/src/stock.tsx
new file mode 100644
index 0000000..f0fd4ab
--- /dev/null
+++ b/packages/viewer/src/stock.tsx
@@ -0,0 +1,91 @@
+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,
+ fixedBoxStockBounds,
+ type StockAllowance,
+ type StockPosition,
+} 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
+ /** Explicit X/Y/Z dimensions, in millimetres, for fixed-box stock. */
+ dimensions?: Vec3
+ /** Position mode for explicit fixed-box stock. Defaults to model-centered. */
+ position?: StockPosition
+ /** Distance from the selected top/bottom part bound, in millimetres. */
+ positionOffset?: number
+ /**
+ * Stock left around the part, in millimetres. In the `{ wall, floor }` form,
+ * wall applies to X/Y and floor applies to Z. Number and `{ x, y, z }` forms
+ * are retained for compatibility. Defaults to zero.
+ */
+ allowance?: StockAllowance
+ /** 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,
+ dimensions,
+ position = 'model_centered',
+ positionOffset = 0,
+ allowance = 0,
+ offset,
+ ...props
+}: BoxStockProps) => {
+ const ox = offset?.x ?? 0
+ const oy = offset?.y ?? 0
+ const oz = offset?.z ?? 0
+ const geometry = useMemo(() => {
+ const box = dimensions
+ ? fixedBoxStockBounds(partGeometry, dimensions, position, positionOffset, {
+ x: ox,
+ y: oy,
+ z: oz,
+ })
+ : boxStockBounds(partGeometry, allowance, { 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)
+ }, [allowance, dimensions, ox, oy, oz, partGeometry, position, positionOffset])
+ useEffect(() => () => geometry.dispose(), [geometry])
+ return
+}
diff --git a/packages/viewer/src/toolbar-icons.tsx b/packages/viewer/src/toolbar-icons.tsx
new file mode 100644
index 0000000..fb534f3
--- /dev/null
+++ b/packages/viewer/src/toolbar-icons.tsx
@@ -0,0 +1,137 @@
+import type { ReactNode } from 'react'
+
+interface SvgIconProps {
+ viewBox?: string
+ fill?: string
+ children: ReactNode
+}
+
+const SvgIcon = ({ viewBox = '0 0 24 24', fill = 'none', children }: SvgIconProps) => (
+
+ {children}
+
+)
+
+const FitIcon = () => (
+
+
+
+)
+
+const ResetIcon = () => (
+
+
+
+)
+
+const TopIcon = () => (
+
+
+
+)
+
+const StockIcon = () => (
+
+
+
+)
+
+const AxesIcon = () => (
+
+
+
+)
+
+const GridIcon = () => (
+
+
+
+)
+
+const BananaIcon = () => (
+
+
+
+)
+
+const DirectionsIcon = () => (
+
+
+
+)
+
+const HoverIcon = () => (
+
+
+
+)
+
+const FocusIcon = () => (
+
+
+
+)
+
+const WireframeIcon = () => (
+
+
+
+)
+
+const SectionIcon = () => (
+
+
+
+)
+
+const MeasureIcon = () => (
+
+
+
+)
+
+export type ToolbarIconName =
+ | 'fit'
+ | 'reset'
+ | 'top'
+ | 'stock'
+ | 'axes'
+ | 'grid'
+ | 'banana'
+ | 'directions'
+ | 'hover'
+ | 'focus'
+ | 'wireframe'
+ | 'section'
+ | 'measure'
+
+const ICONS: Record ReactNode> = {
+ fit: FitIcon,
+ reset: ResetIcon,
+ top: TopIcon,
+ stock: StockIcon,
+ axes: AxesIcon,
+ grid: GridIcon,
+ banana: BananaIcon,
+ directions: DirectionsIcon,
+ hover: HoverIcon,
+ focus: FocusIcon,
+ wireframe: WireframeIcon,
+ section: SectionIcon,
+ measure: MeasureIcon,
+}
+
+export const ToolbarIcon = ({ name }: { name: ToolbarIconName }) => {
+ const Icon = ICONS[name]
+ return
+}
diff --git a/packages/viewer/src/toolbar.css b/packages/viewer/src/toolbar.css
new file mode 100644
index 0000000..a7ce606
--- /dev/null
+++ b/packages/viewer/src/toolbar.css
@@ -0,0 +1,108 @@
+.viewer-toolbar-stack {
+ position: absolute;
+ bottom: 1.25rem;
+ left: 0;
+ width: 100%;
+ padding: 0 0.75rem;
+ z-index: 2;
+ pointer-events: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.6rem;
+}
+.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,
+.viewer-toolbar-button {
+ border: 1px solid #526078;
+ border-radius: 0.4rem;
+ background: #202737;
+ color: #eef0f6;
+ cursor: pointer;
+ padding: 0.45rem 0.65rem;
+}
+.viewer-toolbar button,
+.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-icon {
+ width: 1.3rem;
+ height: 1.3rem;
+}
+.viewer-toolbar-divider {
+ width: 1px;
+ height: 1.4rem;
+ margin: 0 0.2rem;
+ background: #526078;
+}
+.viewer-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 .viewer-toolbar-tooltip,
+.viewer-toolbar-button:focus-visible .viewer-toolbar-tooltip {
+ display: block;
+}
+.viewer-toolbar-button:focus {
+ outline: none;
+}
+.viewer-toolbar-button:focus-visible {
+ outline: 2px solid #83a9ff;
+ outline-offset: 2px;
+}
+.viewer-toolbar-button:hover {
+ background: #303b51;
+}
+.viewer-toolbar-button[aria-pressed='true'] {
+ border-color: #83a9ff;
+ background: #2b3f66;
+}
+.viewer-toolbar label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: #b9c0d1;
+ font-size: 0.85rem;
+}
+.viewer-tool-options label {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+.viewer-hint {
+ align-self: center;
+ color: #b9c0d1;
+ font-size: 0.85rem;
+}
diff --git a/packages/viewer/src/viewer-toolbar.tsx b/packages/viewer/src/viewer-toolbar.tsx
new file mode 100644
index 0000000..0ce169a
--- /dev/null
+++ b/packages/viewer/src/viewer-toolbar.tsx
@@ -0,0 +1,260 @@
+import { createContext, useContext } from 'react'
+import type { PropsWithChildren, ReactNode } from 'react'
+import { ToolbarIcon, type ToolbarIconName } from './toolbar-icons.js'
+
+/** One action a toolbar button can read and invoke. */
+export interface ViewerToolbarControl {
+ /** Whether a toggle-style control is currently active. */
+ pressed?: boolean
+ /** Invoked when the control is clicked. */
+ onClick: () => void
+}
+
+/**
+ * The state and actions that toolbar controls read from their enclosing
+ * {@link ViewerToolbarProvider}.
+ *
+ * Controls are optional so an application can expose only the buttons it
+ * renders. Rendering a button without its matching control is an error.
+ */
+export interface ViewerToolbarControls {
+ stock?: ViewerToolbarControl
+ axes?: ViewerToolbarControl
+ grid?: ViewerToolbarControl
+ banana?: ViewerToolbarControl
+ directions?: ViewerToolbarControl
+ hover?: ViewerToolbarControl
+ focus?: ViewerToolbarControl
+ wireframe?: ViewerToolbarControl
+ section?: ViewerToolbarControl
+ measure?: ViewerToolbarControl
+ fit?: ViewerToolbarControl
+ reset?: ViewerToolbarControl
+ top?: ViewerToolbarControl
+}
+
+const ViewerToolbarContext = createContext(null)
+
+/** Supplies viewer state and callbacks to a toolbar and its compound controls. */
+export const ViewerToolbarProvider = ({
+ controls,
+ children,
+}: PropsWithChildren<{ controls: ViewerToolbarControls }>) => (
+ {children}
+)
+
+/**
+ * Read the state and callbacks configured for the enclosing viewer toolbar.
+ *
+ * Use this to build a toolbar control with an application's own component kit.
+ */
+export const useViewerToolbar = (): ViewerToolbarControls => {
+ const controls = useContext(ViewerToolbarContext)
+ if (!controls) throw new Error('useViewerToolbar must be used inside ')
+ return controls
+}
+
+interface ToolbarButtonProps {
+ action: keyof ViewerToolbarControls
+ icon: ToolbarIconName
+ label: (pressed: boolean) => string
+ toggle?: boolean
+}
+
+const buttonName = (action: keyof ViewerToolbarControls): string =>
+ `${action.slice(0, 1).toUpperCase()}${action.slice(1)}Button`
+
+const ToolbarButton = ({ action, icon, label, toggle = true }: ToolbarButtonProps) => {
+ const control = useViewerToolbar()[action]
+ if (!control) {
+ throw new Error(` requires a '${action}' toolbar control`)
+ }
+
+ const pressed = control.pressed ?? false
+ return (
+
+
+
+ {label(pressed)}
+
+
+ )
+}
+
+const StockButton = () => (
+ (pressed ? 'Hide stock' : 'Show stock')}
+ />
+)
+const AxesButton = () => (
+ (pressed ? 'Hide axis' : 'Show axis')}
+ />
+)
+const GridButton = () => (
+ (pressed ? 'Hide grid' : 'Show grid')}
+ />
+)
+const BananaButton = () => (
+ (pressed ? 'Banana for scale (on)' : 'Banana for scale')}
+ />
+)
+const DirectionsButton = () => (
+ 'Highlight faces by direction'}
+ />
+)
+const HoverButton = () => (
+ (pressed ? 'Disable feature hover' : 'Enable feature hover')}
+ />
+)
+const FocusButton = () => (
+ (pressed ? 'Show full part' : 'Focus selection')}
+ />
+)
+const WireframeButton = () => (
+ 'Wireframe'} />
+)
+const SectionButton = () => (
+ (pressed ? 'Exit section' : 'Section')}
+ />
+)
+const MeasureButton = () => (
+ (pressed ? 'Exit measure' : 'Measure')}
+ />
+)
+const FitButton = () => 'Fit'} toggle={false} />
+const ResetButton = () => (
+ 'Reset'} toggle={false} />
+)
+const TopButton = () => (
+ 'Top view'} toggle={false} />
+)
+
+/** A visual separator between toolbar control groups. */
+const Divider = () =>
+
+export interface ViewerToolbarControlsProps extends PropsWithChildren {
+ /** Added to the standard toolbar control group. */
+ className?: string
+}
+
+/** Groups controls into the standard styled toolbar surface. */
+const Controls = ({ children, className }: ViewerToolbarControlsProps) => (
+
+ {children}
+
+)
+
+const DefaultControls = () => {
+ const controls = useViewerToolbar()
+ const display = [
+ controls.stock ? : null,
+ controls.axes ? : null,
+ controls.grid ? : null,
+ controls.banana ? : null,
+ ].filter(Boolean)
+ const analysis = [
+ controls.directions ? : null,
+ controls.hover ? : null,
+ controls.focus ? : null,
+ controls.wireframe ? : null,
+ controls.section ? : null,
+ controls.measure ? : null,
+ ].filter(Boolean)
+ const camera = [
+ controls.fit ? : null,
+ controls.reset ? : null,
+ controls.top ? : null,
+ ].filter(Boolean)
+
+ return (
+
+ {display}
+ {display.length && analysis.length ? : null}
+ {analysis}
+ {(display.length || analysis.length) && camera.length ? : null}
+ {camera}
+
+ )
+}
+
+export interface ViewerToolbarProps {
+ /** Added to the outer toolbar stack, beside `viewer-toolbar-stack`. */
+ className?: string
+ /**
+ * Toolbar contents. Omit this for the standard control order, or compose
+ * `ViewerToolbar.Controls` with the individual button components.
+ */
+ children?: ReactNode
+}
+
+/**
+ * A viewer-scoped toolbar. It reads state and callbacks from
+ * {@link ViewerToolbarProvider}; it never owns application behavior itself.
+ */
+const ViewerToolbarRoot = ({ className, children }: ViewerToolbarProps) => {
+ useViewerToolbar()
+ return (
+
+ {children ?? }
+
+ )
+}
+
+export const ViewerToolbar = Object.assign(ViewerToolbarRoot, {
+ Controls,
+ Divider,
+ StockButton,
+ AxesButton,
+ GridButton,
+ BananaButton,
+ DirectionsButton,
+ HoverButton,
+ FocusButton,
+ WireframeButton,
+ SectionButton,
+ MeasureButton,
+ FitButton,
+ ResetButton,
+ TopButton,
+})
diff --git a/packages/viewer/src/viewer.tsx b/packages/viewer/src/viewer.tsx
index a77bd37..8bda43c 100644
--- a/packages/viewer/src/viewer.tsx
+++ b/packages/viewer/src/viewer.tsx
@@ -674,7 +674,8 @@ export const Viewer = forwardRef(function Viewer(
it.
*/}
{
if (event.target instanceof HTMLCanvasElement) event.preventDefault()
@@ -682,6 +683,8 @@ export const Viewer = forwardRef(function Viewer(
style={{ height: '100%', width: '100%', ...style }}
>
(function Viewer(
// looks hollow. `localClippingEnabled` is what lets a material carry
// its own clipping plane rather than the whole scene sharing one.
gl={{ antialias: true, alpha: true, stencil: true, localClippingEnabled: true }}
+ onCreated={({ gl }) => {
+ const canvas = gl.domElement
+ canvas.classList.add('viewer-canvas')
+ canvas.dataset.viewerCanvas = 'true'
+ canvas.parentElement?.classList.add('viewer-canvas-frame')
+ canvas.parentElement?.setAttribute('data-viewer-canvas-frame', 'true')
+ }}
// Two guards, and both are about gestures that are not a click.
//
// Only the primary button puts a selection down. R3F treats
diff --git a/packages/viewer/tests/banana.test.ts b/packages/viewer/tests/banana.test.ts
new file mode 100644
index 0000000..fd0f9fb
--- /dev/null
+++ b/packages/viewer/tests/banana.test.ts
@@ -0,0 +1,27 @@
+import { Box3, Vector3 } from 'three'
+import { describe, expect, it } from 'vitest'
+import { BANANA_GAP, bananaFrameBounds, bananaPosition } from '../src/render/banana.js'
+
+const box = (min: readonly [number, number, number], max: readonly [number, number, number]) =>
+ new Box3(new Vector3(...min), new Vector3(...max))
+
+describe('banana placement', () => {
+ it('stands on the part ground and clears its right side', () => {
+ const part = box([-10, -20, 5], [30, 20, 45])
+ const banana = box([-4, -3, 0], [4, 3, 100])
+ const position = bananaPosition(part, banana)
+
+ expect(position.x + banana.min.x).toBeCloseTo(part.max.x + 40 * BANANA_GAP)
+ expect(position.y + (banana.min.y + banana.max.y) / 2).toBeCloseTo(0)
+ expect(position.z + banana.min.z).toBe(part.min.z)
+ })
+
+ it('returns bounds that contain both objects for explicit framing', () => {
+ const part = box([0, 0, 0], [10, 10, 10])
+ const banana = box([0, 0, 0], [2, 2, 2])
+ const bounds = bananaFrameBounds(part, banana, new Vector3(20, 0, 0))
+
+ expect(bounds.min.toArray()).toEqual([0, 0, 0])
+ expect(bounds.max.toArray()).toEqual([22, 10, 10])
+ })
+})
diff --git a/packages/viewer/tests/control-schemes.test.ts b/packages/viewer/tests/control-schemes.test.ts
new file mode 100644
index 0000000..22774a5
--- /dev/null
+++ b/packages/viewer/tests/control-schemes.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+import {
+ CONTROL_SCHEME_OPTIONS,
+ resolveControlScheme,
+ type ControlScheme,
+} from '../src/render/control-schemes.js'
+
+const mappingFor = (
+ scheme: ControlScheme,
+ modifiers = { shift: false, ctrl: false },
+ orthographic = true,
+) => resolveControlScheme(scheme, { modifiers, orthographic })
+
+describe('control scheme options', () => {
+ it('exports the complete legacy CAD preset list with stable public values', () => {
+ expect(CONTROL_SCHEME_OPTIONS).toEqual([
+ { value: 'toolpath', label: 'Toolpath' },
+ { value: 'fusion', label: 'Fusion' },
+ { value: 'alias', label: 'Alias' },
+ { value: 'inventor', label: 'Inventor' },
+ { value: 'solidworks', label: 'SolidWorks' },
+ { value: 'tinkercad', label: 'Tinkercad' },
+ { value: 'powermill', label: 'PowerMill' },
+ { value: 'onshape', label: 'Onshape' },
+ ])
+ })
+})
+
+describe('resolveControlScheme', () => {
+ it.each([
+ ['toolpath', { left: 'rotate', middle: 'none', right: 'truck' }],
+ ['alias', { left: 'rotate', middle: 'truck', right: 'none' }],
+ ['tinkercad', { left: 'none', middle: 'none', right: 'rotate' }],
+ ['powermill', { left: 'none', middle: 'rotate', right: 'none' }],
+ ['onshape', { left: 'none', middle: 'truck', right: 'rotate' }],
+ ] as const)('%s maps its unmodified mouse buttons', (scheme, mouse) => {
+ const mapping = mappingFor(scheme)
+
+ expect(mapping.mouse).toMatchObject(mouse)
+ expect(mapping.mouse.wheel).toBe('zoom')
+ expect(mapping.touches).toEqual({ one: 'rotate', two: 'dolly-truck', three: 'truck' })
+ expect(mapping.usesCadWheel).toBe(false)
+ expect(mapping.immediate).toBe(false)
+ })
+
+ it.each(['fusion', 'inventor'] as const)(
+ '%s uses the Fusion-style trackpad mapping',
+ (scheme) => {
+ const plain = mappingFor(scheme)
+ const shifted = mappingFor(scheme, { shift: true, ctrl: false })
+
+ expect(plain.mouse).toMatchObject({
+ left: 'none',
+ middle: 'truck',
+ right: 'none',
+ wheel: 'none',
+ })
+ expect(plain.touches).toEqual({ one: 'rotate', two: 'truck', three: 'truck' })
+ expect(plain.usesCadWheel).toBe(true)
+ expect(plain.immediate).toBe(true)
+
+ expect(shifted.mouse.middle).toBe('rotate')
+ expect(shifted.touches.two).toBe('rotate')
+ },
+ )
+
+ it('matches SolidWorks middle-button modifiers, with Shift taking precedence', () => {
+ expect(mappingFor('solidworks').mouse.middle).toBe('rotate')
+ expect(mappingFor('solidworks', { shift: false, ctrl: true }).mouse.middle).toBe('truck')
+ expect(mappingFor('solidworks', { shift: true, ctrl: false }).mouse.middle).toBe('zoom')
+ expect(mappingFor('solidworks', { shift: true, ctrl: true }).mouse.middle).toBe('zoom')
+ })
+
+ it('maps Tinkercad and PowerMill Shift gestures to pan', () => {
+ expect(mappingFor('tinkercad', { shift: true, ctrl: false }).mouse.right).toBe('truck')
+ expect(mappingFor('powermill', { shift: true, ctrl: false }).mouse.middle).toBe('truck')
+ })
+
+ it.each(['toolpath', 'alias', 'solidworks', 'tinkercad', 'powermill', 'onshape'] as const)(
+ '%s dollies with a perspective camera',
+ (scheme) => {
+ expect(mappingFor(scheme, { shift: false, ctrl: false }, false).mouse.wheel).toBe('dolly')
+ },
+ )
+})
diff --git a/packages/viewer/tests/controls.test.ts b/packages/viewer/tests/controls.test.ts
new file mode 100644
index 0000000..c01d920
--- /dev/null
+++ b/packages/viewer/tests/controls.test.ts
@@ -0,0 +1,171 @@
+import CameraControls from 'camera-controls'
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
+import { OrthographicCamera } from 'three'
+import { ExtendedCameraControls } from '../src/render/controls.js'
+
+class TestRect {
+ x: number
+ y: number
+ width: number
+ height: number
+ left: number
+ top: number
+ right: number
+ bottom: number
+
+ constructor(x = 0, y = 0, width = 1, height = 1) {
+ this.x = x
+ this.y = y
+ this.width = width
+ this.height = height
+ this.left = x
+ this.top = y
+ this.right = x + width
+ this.bottom = y + height
+ }
+}
+
+class TestDocument extends EventTarget {
+ defaultView = new EventTarget()
+ pointerLockElement: Element | null = null
+
+ exitPointerLock(): void {}
+}
+
+class TestElement extends EventTarget {
+ readonly ownerDocument = new TestDocument()
+ readonly style = { touchAction: '', userSelect: '', webkitUserSelect: '' }
+ clientWidth = 600
+ clientHeight = 400
+
+ setAttribute(): void {}
+ removeAttribute(): void {}
+ getBoundingClientRect(): DOMRect {
+ return new DOMRect(0, 0, this.clientWidth, this.clientHeight)
+ }
+}
+
+const keyboardEvent = (shiftKey: boolean, ctrlKey: boolean) => {
+ const event = new Event('keydown') as KeyboardEvent
+ Object.defineProperties(event, {
+ shiftKey: { value: shiftKey },
+ ctrlKey: { value: ctrlKey },
+ })
+ return event
+}
+
+const wheelEvent = ({ ctrlKey = false }: { ctrlKey?: boolean } = {}) => {
+ const event = new Event('wheel', { cancelable: true }) as WheelEvent
+ Object.defineProperties(event, {
+ clientX: { value: 300 },
+ clientY: { value: 200 },
+ ctrlKey: { value: ctrlKey },
+ deltaX: { value: 10 },
+ deltaY: { value: 10 },
+ deltaMode: { value: 0 },
+ })
+ return event
+}
+
+const originalDomRect = globalThis.DOMRect
+
+beforeAll(() => {
+ Object.assign(globalThis, { DOMRect: TestRect })
+})
+
+afterAll(() => {
+ Object.assign(globalThis, { DOMRect: originalDomRect })
+})
+
+const createControls = () => {
+ const element = new TestElement()
+ const camera = new OrthographicCamera(-1, 1, 1, -1)
+ camera.position.set(1, -1, 1)
+ const controls = new ExtendedCameraControls(camera, element as unknown as HTMLElement)
+ controls.attach()
+ return { controls, element }
+}
+
+describe('ExtendedCameraControls preset lifecycle', () => {
+ it('reapplies SolidWorks modifiers and clears them on window blur', () => {
+ const { controls, element } = createControls()
+ controls.applyScheme('solidworks')
+
+ expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.ROTATE)
+
+ element.ownerDocument.defaultView.dispatchEvent(keyboardEvent(false, true))
+ expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.TRUCK)
+
+ element.ownerDocument.defaultView.dispatchEvent(keyboardEvent(true, true))
+ expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.ZOOM)
+
+ element.ownerDocument.defaultView.dispatchEvent(new Event('blur'))
+ expect(controls.mouseButtons.middle).toBe(CameraControls.ACTION.ROTATE)
+
+ controls.dispose()
+ })
+
+ it('removes the custom CAD wheel listener when switching away from Fusion', () => {
+ const { controls, element } = createControls()
+ const truck = vi.spyOn(controls, 'truck')
+
+ controls.applyScheme('fusion')
+ element.dispatchEvent(wheelEvent())
+ expect(truck).toHaveBeenCalledOnce()
+
+ controls.applyScheme('toolpath')
+ truck.mockClear()
+ element.dispatchEvent(wheelEvent())
+ expect(truck).not.toHaveBeenCalled()
+
+ controls.dispose()
+ })
+
+ it.each(['fusion', 'inventor'] as const)(
+ '%s pans, orbits, and pinch-zooms with its custom wheel listener',
+ (scheme) => {
+ const { controls, element } = createControls()
+ const truck = vi.spyOn(controls, 'truck')
+ const rotate = vi.spyOn(controls, 'rotate')
+ const zoom = vi.spyOn(controls, 'zoom')
+ controls.applyScheme(scheme)
+
+ const pan = wheelEvent()
+ element.dispatchEvent(pan)
+ expect(pan.defaultPrevented).toBe(true)
+ expect(truck).toHaveBeenCalledOnce()
+ expect(rotate).not.toHaveBeenCalled()
+ expect(zoom).not.toHaveBeenCalled()
+
+ truck.mockClear()
+ element.ownerDocument.defaultView.dispatchEvent(keyboardEvent(true, false))
+ const orbit = wheelEvent()
+ element.dispatchEvent(orbit)
+ expect(orbit.defaultPrevented).toBe(true)
+ expect(truck).not.toHaveBeenCalled()
+ expect(rotate).toHaveBeenCalledOnce()
+ expect(zoom).not.toHaveBeenCalled()
+
+ rotate.mockClear()
+ const pinch = wheelEvent({ ctrlKey: true })
+ element.dispatchEvent(pinch)
+ expect(pinch.defaultPrevented).toBe(true)
+ expect(truck).not.toHaveBeenCalled()
+ expect(rotate).not.toHaveBeenCalled()
+ expect(zoom).toHaveBeenCalledOnce()
+
+ controls.dispose()
+ },
+ )
+
+ it('does not retain the custom wheel listener after disposal', () => {
+ const { controls, element } = createControls()
+ const truck = vi.spyOn(controls, 'truck')
+
+ controls.applyScheme('inventor')
+ controls.dispose()
+ element.dispatchEvent(wheelEvent())
+
+ expect(truck).not.toHaveBeenCalled()
+ })
+})
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/focus.test.ts b/packages/viewer/tests/focus.test.ts
new file mode 100644
index 0000000..1d85168
--- /dev/null
+++ b/packages/viewer/tests/focus.test.ts
@@ -0,0 +1,88 @@
+import { DoubleSide, Mesh, MeshLambertMaterial } from 'three'
+import { describe, expect, it } from 'vitest'
+import { parsePartGeometry } from '../src/engine/geometry.js'
+import { DEFAULT_FOCUS_OPACITY, focusStateKey } from '../src/render/focus.js'
+import { createPart } from '../src/render/part.js'
+import { DEFAULT_THEME } from '../src/render/theme.js'
+import { cubeModel, loadMeshFixture } from './fixtures.js'
+
+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('changes the render effect key when default focus is turned off', () => {
+ expect(focusStateKey({}, ['bottom-face'])).not.toBe(focusStateKey(undefined, ['bottom-face']))
+ })
+
+ it('keeps selected regions solid and fades every other region', async () => {
+ const { model, part } = await loadCube()
+ const top = faceOn(model, 1)
+ 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)
+
+ const focusMesh = part.object.children[0] as Mesh
+ const focusMaterial = focusMesh.material as MeshLambertMaterial
+ expect(focusMesh.visible).toBe(true)
+ expect(focusMaterial.transparent).toBe(false)
+ expect(focusMaterial.depthWrite).toBe(true)
+ expect(focusMaterial.side).toBe(DoubleSide)
+ })
+
+ it('returns to a fully opaque part when there is no selected feature', async () => {
+ 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)
+ })
+})
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()
diff --git a/packages/viewer/tests/section.test.ts b/packages/viewer/tests/section.test.ts
index 00036c1..b64af4d 100644
--- a/packages/viewer/tests/section.test.ts
+++ b/packages/viewer/tests/section.test.ts
@@ -5,12 +5,16 @@ import {
dragPlane,
sectionBounds,
sectionConstant,
+ sectionCutDistance,
sectionDepth,
sectionDepthConstant,
sectionDepthRange,
+ sectionDirectionColor,
+ sectionHandleColor,
sectionFromPick,
sectionOffset,
sectionPlane,
+ sectionMeasurement,
} from '../src/render/section.js'
import { resolveSectionPlane } from '../src/section-view.js'
@@ -147,6 +151,73 @@ 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)
+ })
+
+ it('accepts section states from older consumers without cutDistance', () => {
+ expect(
+ sectionMeasurement({
+ enabled: true,
+ normal: { x: 0, y: 0, z: 1 },
+ offset: 0.5,
+ constant: 0,
+ plane: null,
+ depth: null,
+ depthRange: null,
+ }),
+ ).toBe('0.00 mm')
+ })
+})
+
+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('sectionHandleColor', () => {
+ it('uses the direction colour when no theme override is supplied', () => {
+ expect(sectionHandleColor(undefined, 0x123456)).toBe(0x123456)
+ })
+
+ it('preserves explicit theme colours, including black', () => {
+ expect(sectionHandleColor(0, 0x123456)).toBe(0)
+ expect(sectionHandleColor(0xabcdef, 0x123456)).toBe(0xabcdef)
+ })
+})
+
describe('dragPlane', () => {
it('faces the camera while containing the axis being dragged', () => {
const axis = new Vector3(0, 0, 1)
diff --git a/packages/viewer/tests/stock.test.ts b/packages/viewer/tests/stock.test.ts
new file mode 100644
index 0000000..debfa9b
--- /dev/null
+++ b/packages/viewer/tests/stock.test.ts
@@ -0,0 +1,112 @@
+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, fixedBoxStockBounds } 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('maps wall leave to X/Y and floor leave to Z', () => {
+ const geometry = new BoxGeometry(20, 30, 10)
+ expect(
+ boxStockBounds(geometry, { wall: 0.254, floor: 0.508 }).getSize(new Vector3()).toArray(),
+ ).toEqual([20.508, 30.508, 11.016])
+ })
+
+ it('positions fixed box stock from the model center or top/bottom', () => {
+ const geometry = new BoxGeometry(20, 30, 10)
+ const dimensions = { x: 40, y: 50, z: 20 }
+ expect(fixedBoxStockBounds(geometry, dimensions).getCenter(new Vector3()).toArray()).toEqual([
+ 0, 0, 0,
+ ])
+ expect(
+ fixedBoxStockBounds(geometry, dimensions, 'offset_from_top', 2)
+ .getCenter(new Vector3())
+ .toArray(),
+ ).toEqual([0, 0, -3])
+ expect(
+ fixedBoxStockBounds(geometry, dimensions, 'offset_from_bottom', 2)
+ .getCenter(new Vector3())
+ .toArray(),
+ ).toEqual([0, 0, 3])
+ expect(
+ fixedBoxStockBounds(geometry, dimensions, 'model_centered', 0, { x: 4, y: -2, z: 3 })
+ .getCenter(new Vector3())
+ .toArray(),
+ ).toEqual([4, -2, 3])
+ })
+
+ 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()
+ })
+})
diff --git a/packages/viewer/tests/styling.test.tsx b/packages/viewer/tests/styling.test.tsx
new file mode 100644
index 0000000..caab7f9
--- /dev/null
+++ b/packages/viewer/tests/styling.test.tsx
@@ -0,0 +1,96 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+import { HoverCard } from '../src/hover-card.js'
+import { ViewerToolbar, ViewerToolbarProvider } from '../src/viewer-toolbar.js'
+
+describe('viewer styling hooks', () => {
+ it('keeps stable toolbar classes and action identifiers beside a caller class', () => {
+ const markup = renderToStaticMarkup(
+ {} },
+ axes: { pressed: false, onClick: () => {} },
+ grid: { pressed: false, onClick: () => {} },
+ banana: { pressed: false, onClick: () => {} },
+ directions: { pressed: false, onClick: () => {} },
+ hover: { pressed: false, onClick: () => {} },
+ focus: { pressed: false, onClick: () => {} },
+ wireframe: { pressed: false, onClick: () => {} },
+ section: { pressed: false, onClick: () => {} },
+ measure: { pressed: false, onClick: () => {} },
+ fit: { onClick: () => {} },
+ reset: { onClick: () => {} },
+ top: { onClick: () => {} },
+ }}
+ >
+
+ ,
+ )
+
+ expect(markup).toContain('class="viewer-toolbar-stack app-toolbar"')
+ expect(markup).toContain('data-viewer-toolbar="true"')
+ expect(markup).toContain('class="viewer-toolbar-button"')
+ expect(markup).toContain('data-viewer-toolbar-action="stock"')
+ expect(markup).toContain('class="viewer-toolbar-icon"')
+ expect(markup).toContain('class="viewer-toolbar-tooltip"')
+ expect(markup).toContain('class="viewer-toolbar-divider"')
+ })
+
+ it('allows an application to render only its chosen controls in its chosen order', () => {
+ const markup = renderToStaticMarkup(
+ {} },
+ banana: { pressed: true, onClick: () => {} },
+ }}
+ >
+
+
+
+
+
+
+ ,
+ )
+
+ const fit = markup.indexOf('data-viewer-toolbar-action="fit"')
+ const banana = markup.indexOf('data-viewer-toolbar-action="banana"')
+ expect(fit).toBeGreaterThan(-1)
+ expect(banana).toBeGreaterThan(fit)
+ expect(markup).not.toContain('data-viewer-toolbar-action="stock"')
+ expect(markup).not.toContain('aria-pressed="false"')
+ expect(markup).toContain('data-viewer-toolbar-action="banana"')
+ expect(markup).toContain('aria-pressed="true"')
+ })
+
+ it('requires a provider for compound toolbar controls', () => {
+ expect(() => renderToStaticMarkup( )).toThrow(
+ 'useViewerToolbar must be used inside ',
+ )
+ })
+
+ it('gives hover cards a default class without replacing a caller class', () => {
+ const markup = renderToStaticMarkup(
+
+ {() => 'Details'}
+ ,
+ )
+
+ expect(markup).toContain('class="viewer-hover-card app-hover-card"')
+ expect(markup).toContain('data-viewer-hover-card="true"')
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f0cd4cf..93bddce 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -49,6 +49,9 @@ importers:
examples/react-viewer:
dependencies:
+ '@phosphor-icons/react':
+ specifier: 2.1.10
+ version: 2.1.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@react-three/drei':
specifier: 10.7.8
version: 10.7.8(@react-three/fiber@9.7.0(@types/react@19.2.18)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1))(@types/react@19.2.18)(@types/three@0.185.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(three@0.185.1)
@@ -6907,6 +6910,11 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
+ '@phosphor-icons/react@2.1.10(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+
'@playwright/test@1.59.1':
dependencies:
playwright: 1.59.1