Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/measure-waits-for-cut.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@toolpath/viewer': minor
---

`<MeasureTool>` mounted beside a `<SectionTool>` that has no cut yet offers no snap and places no
point until a cut is chosen or the section tool is unmounted, so the click that picks a cut is not
also the first point of a measurement. `SectionStore` gains `isPicking` and `setPicking`, which the
section tool raises while it offers a cut.
53 changes: 53 additions & 0 deletions examples/react-viewer/tests/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,59 @@ test('measures on the capped face of a cut part', async ({ page }) => {
await expect(cut).toContainText('off')
})

/**
* A section tool with no cut yet owns the next click on the part. The measure
* tool mounted beside it places nothing until the cut is chosen, and again
* places nothing once Escape has cleared it, so the click that picks a cut is
* never also the first point of a measurement. Leaving section mode hands the
* click back to the measure tool.
*
* A stray first point would not show in the count on its own — a draft is not
* a measurement — so each cut is followed by one click and a check that the
* count has not moved, before the click that would finish a measurement.
*/
test('waits for a cut to be chosen before measuring beside the section tool', async ({ page }) => {
const { canvas, box } = await openViewer(page)

const cut = page.locator('p', { hasText: 'Cut:' })
const measured = page.locator('p', { hasText: 'Measured:' })

await page.getByRole('button', { name: 'Section' }).click()
await page.getByRole('button', { name: 'Measure', exact: true }).click()
await expect(cut).toContainText('none')
await expect(measured).toContainText('none')

// The first click chooses the cut and is not a measurement point: the next
// is a first point, and only the one after that finishes a measurement.
await canvas.click({ position: on(box, CENTRE) })
await expect(cut).toContainText('Part surface')
await canvas.click({ position: on(box, ONE) })
await expect(measured).toContainText('none')
await canvas.click({ position: on(box, CENTRE) })
await expect(measured).toContainText('1 measurement, last')

// Clearing the cut puts the section tool back to offering one, and the
// measure tool back to waiting: this click cuts, and does not measure.
await page.keyboard.press('Escape')
await expect(cut).toContainText('none')
await canvas.click({ position: on(box, CENTRE) })
await expect(cut).toContainText('Part surface')
await canvas.click({ position: on(box, ONE) })
await expect(measured).toContainText('1 measurement, last')
await canvas.click({ position: on(box, CENTRE) })
await expect(measured).toContainText('2 measurements, last')

// With the section tool gone the measure tool has every click.
await page.getByRole('button', { name: 'Exit section' }).click()
await expect(cut).toContainText('off')
await canvas.click({ position: on(box, CENTRE) })
await canvas.click({ position: on(box, ONE) })
await expect(measured).toContainText('3 measurements, last')

await page.getByRole('button', { name: 'Exit measure' }).click()
await expect(measured).toContainText('off')
})

test('pans with either pan button, from wherever the drag starts', async ({ page }) => {
const { canvas, box } = await openViewer(page)

Expand Down
3 changes: 3 additions & 0 deletions packages/viewer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,9 @@ to, the part's edges stop at the plane, the outline where the plane passes throu
is an edge with corners of its own, and the capped face is a surface a point can land on. The
sample follows the cut — drag the handle or set a depth and the next pointer move sees the new cut.
It reads the plane off the part's own material, so a controlled `section` prop works the same way.
Beside a `<SectionTool>` that has no cut yet, the measure tool waits: it offers no snap and places
no point until a cut is chosen or the section tool is unmounted, so the click that picks the cut is
not also the first point of a measurement. Measurements already made stay up meanwhile.

Hold Shift to hold the next point to the X, Y or Z line through the last one — whichever axis the
pointer is furthest along — so a length along an edge is measured square rather than slightly across
Expand Down
55 changes: 44 additions & 11 deletions packages/viewer/src/measure-tool.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { Html, Line } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
import { type ComponentRef, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
type ComponentRef,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {
type BufferGeometry,
Group,
Expand Down Expand Up @@ -129,7 +137,12 @@ export interface MeasureToolProps {
*
* Nothing needs wiring. While it is mounted the part reports no hovers or
* picks — a click that places a point is not a click that selects a face — and
* unmounting it hands the pointer back. It snaps to the edges the part draws,
* unmounting it hands the pointer back. Beside a `<SectionTool>` that has no
* cut yet it waits: no snap is offered and no click places a point until the
* cut is chosen or the section tool is unmounted, and the click that chooses
* the cut — the one it wakes on — places nothing either: a click counts only
* when it was pressed as well as released while the tool was taking points.
* Measurements already made stay up meanwhile. It snaps to the edges the part draws,
* which are the boundaries between analytic surfaces rather than the mesh's
* triangles, so a corner is a corner of the part and not of a facet.
*
Expand Down Expand Up @@ -157,6 +170,8 @@ export const MeasureTool = ({
const store = useSectionStore()
const box = useContentBox()
const resolved = useMemo(() => resolveTheme(theme), [theme])
// A section tool waiting for its cut has the next click. See `render/section-store.ts`.
const picking = useSyncExternalStore(store.subscribe, store.isPicking, store.isPicking)

const [held, setHeld] = useState<readonly Measurement[]>([])
const controlled = measurements !== undefined
Expand Down Expand Up @@ -218,14 +233,16 @@ export const MeasureTool = ({

return (
<group userData={FURNITURE}>
<Snapper
mode={mode}
draft={draft}
onPlace={place}
theme={resolved}
format={format}
label={label}
/>
{picking ? null : (
<Snapper
mode={mode}
draft={draft}
onPlace={place}
theme={resolved}
format={format}
label={label}
/>
)}
{draft.map((point, index) => (
<ScreenDot
key={index}
Expand Down Expand Up @@ -407,8 +424,22 @@ const Snapper = ({ mode, draft, onPlace, theme, format, label }: SnapperProps) =
update(last.x, last.y, event.type === 'keydown')
}

/**
* Whether the press under way began after this listener was up. The tool
* mounts on the click that chooses a section cut — the store changes on
* its `pointerup`, and the render that follows lands inside the same
* dispatch — so that release can arrive here with its press never seen.
* A release places a point only when its press was.
*/
let pressed = false
const down = () => {
pressed = true
}

const up = (event: PointerEvent) => {
if (event.button !== 0 || !isTap(event)) return
const seen = pressed
pressed = false
if (!seen || event.button !== 0 || !isTap(event)) return
// Judged with the modifier the click itself carries, so a Shift that
// came down with the button counts.
update(event.clientX, event.clientY, event.shiftKey)
Expand All @@ -424,12 +455,14 @@ const Snapper = ({ mode, draft, onPlace, theme, format, label }: SnapperProps) =

domElement.addEventListener('pointermove', move)
domElement.addEventListener('pointerleave', leave)
domElement.addEventListener('pointerdown', down)
domElement.addEventListener('pointerup', up)
window.addEventListener('keydown', key)
window.addEventListener('keyup', key)
return () => {
domElement.removeEventListener('pointermove', move)
domElement.removeEventListener('pointerleave', leave)
domElement.removeEventListener('pointerdown', down)
domElement.removeEventListener('pointerup', up)
window.removeEventListener('keydown', key)
window.removeEventListener('keyup', key)
Expand Down
42 changes: 34 additions & 8 deletions packages/viewer/src/render/section-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import type { SectionOptions } from './section.js'
* on a face is not a request to select the face — which is why the flag is a
* count rather than a boolean: two tools up at once let go one at a time.
*
* And whether a tool is **picking** — `<SectionTool>` is up with no cut in
* place, so the next click on the part chooses where the cut goes. That click
* is the section tool's alone: `<MeasureTool>` beside it offers no snap and
* places no point until the cut is chosen or the section tool is unmounted.
* Counted for the same reason as `engaged`.
*
* A subscription rather than React state, so a drag re-renders the two
* subscribers and not everything under the canvas.
*/
Expand All @@ -28,17 +34,22 @@ export interface SectionStore {
isEngaged(): boolean
/** Counted: a tool engages on mount and disengages on unmount, and the part waits for the last. */
setEngaged(engaged: boolean): void
/** Notified on every change to either the cut or the engaged flag. */
/** Whether a section tool is waiting for a click to say where the cut goes. */
isPicking(): boolean
/** Counted, as `setEngaged` is: raised while a section tool offers a cut, dropped once one is placed. */
setPicking(picking: boolean): void
/** Notified on every change to the cut, the engaged flag, or the picking flag. */
subscribe(listener: () => void): () => void
}

export function createSectionStore(): SectionStore {
let current: SectionOptions | null = null
let engaged = 0
const listeners = new Set<() => void>()
const notify = () => {
for (const listener of listeners) listener()
}
const engaged = counted(notify)
const picking = counted(notify)

return {
get: () => current,
Expand All @@ -47,12 +58,10 @@ export function createSectionStore(): SectionStore {
current = next
notify()
},
isEngaged: () => engaged > 0,
setEngaged: (next) => {
const was = engaged > 0
engaged = Math.max(0, engaged + (next ? 1 : -1))
if (engaged > 0 !== was) notify()
},
isEngaged: engaged.is,
setEngaged: engaged.set,
isPicking: picking.is,
setPicking: picking.set,
subscribe: (listener) => {
listeners.add(listener)
return () => {
Expand All @@ -61,3 +70,20 @@ export function createSectionStore(): SectionStore {
},
}
}

/**
* A flag held by however many tools raised it, and notified only when it
* changes. A release with nothing held is ignored rather than owed, so a tool
* that lets go twice does not leave the next one raising the flag for nothing.
*/
function counted(notify: () => void): { is(): boolean; set(next: boolean): void } {
let held = 0
return {
is: () => held > 0,
set: (next) => {
const was = held > 0
held = Math.max(0, held + (next ? 1 : -1))
if (held > 0 !== was) notify()
},
}
}
11 changes: 10 additions & 1 deletion packages/viewer/src/section-tool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export interface SectionToolProps {
* the way to set or clear it from outside the canvas. While this is mounted
* the part reports no hovers or picks: a click on a face cuts through it
* without also selecting it, and a cut part is looked at rather than picked
* at. Unmounting it hands the pointer back.
* at. Unmounting it hands the pointer back. While it is offering a cut — up,
* with none placed yet — a `<MeasureTool>` beside it waits too, so the click
* that chooses the cut does not also place a measurement point.
*/
export const SectionTool = ({ theme }: SectionToolProps) => {
const store = useSectionStore()
Expand Down Expand Up @@ -148,6 +150,13 @@ const Picker = ({ box, theme }: PickerProps) => {
const centre = useMemo(() => box.getCenter(new Vector3()), [box])
const previewSize = span * PREVIEW_SCALE

// For as long as a cut is being offered, the next click on the part is this
// component's: a measure tool up beside it waits. See `render/section-store.ts`.
useEffect(() => {
store.setPicking(true)
return () => store.setPicking(false)
}, [store])

useEffect(() => {
const raycaster = new Raycaster()
const pointer = new Vector2()
Expand Down
29 changes: 29 additions & 0 deletions packages/viewer/tests/section-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,35 @@ describe('createSectionStore', () => {
expect(store.isEngaged()).toBe(true)
})

/**
* The picker raises it while it offers a cut and drops it once one is
* placed; the measure tool reads it to keep its hands off the click that
* chooses the cut. Same counting as `engaged`, on the same subscription.
*/
it('carries the picking flag, counted, on the same subscription', () => {
const store = createSectionStore()
const listener = vi.fn()
store.subscribe(listener)

expect(store.isPicking()).toBe(false)
store.setPicking(true)
expect(store.isPicking()).toBe(true)
expect(listener).toHaveBeenCalledTimes(1)
store.setPicking(true)
expect(listener).toHaveBeenCalledTimes(1)
store.setPicking(false)
expect(store.isPicking()).toBe(true)
store.setPicking(false)
expect(store.isPicking()).toBe(false)
expect(listener).toHaveBeenCalledTimes(2)
// A release with nothing held is not a debt the next tool inherits.
store.setPicking(false)
store.setPicking(true)
expect(store.isPicking()).toBe(true)
// ...and it is its own flag, not the engaged one.
expect(store.isEngaged()).toBe(false)
})

it('stops notifying once unsubscribed', () => {
const store = createSectionStore()
const listener = vi.fn()
Expand Down
Loading