diff --git a/.changeset/measure-waits-for-cut.md b/.changeset/measure-waits-for-cut.md new file mode 100644 index 0000000..e0136c2 --- /dev/null +++ b/.changeset/measure-waits-for-cut.md @@ -0,0 +1,8 @@ +--- +'@toolpath/viewer': minor +--- + +`` mounted beside a `` 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. diff --git a/examples/react-viewer/tests/viewer.spec.ts b/examples/react-viewer/tests/viewer.spec.ts index 059c0ad..26f185f 100644 --- a/examples/react-viewer/tests/viewer.spec.ts +++ b/examples/react-viewer/tests/viewer.spec.ts @@ -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) diff --git a/packages/viewer/README.md b/packages/viewer/README.md index 0420079..f7a91a7 100644 --- a/packages/viewer/README.md +++ b/packages/viewer/README.md @@ -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 `` 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 diff --git a/packages/viewer/src/measure-tool.tsx b/packages/viewer/src/measure-tool.tsx index 1029585..e64420f 100644 --- a/packages/viewer/src/measure-tool.tsx +++ b/packages/viewer/src/measure-tool.tsx @@ -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, @@ -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 `` 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. * @@ -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([]) const controlled = measurements !== undefined @@ -218,14 +233,16 @@ export const MeasureTool = ({ return ( - + {picking ? null : ( + + )} {draft.map((point, index) => ( { + 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) @@ -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) diff --git a/packages/viewer/src/render/section-store.ts b/packages/viewer/src/render/section-store.ts index 59a5788..1783fc2 100644 --- a/packages/viewer/src/render/section-store.ts +++ b/packages/viewer/src/render/section-store.ts @@ -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** — `` 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: `` 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. */ @@ -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, @@ -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 () => { @@ -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() + }, + } +} diff --git a/packages/viewer/src/section-tool.tsx b/packages/viewer/src/section-tool.tsx index b543d35..e899dbc 100644 --- a/packages/viewer/src/section-tool.tsx +++ b/packages/viewer/src/section-tool.tsx @@ -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 `` 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() @@ -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() diff --git a/packages/viewer/tests/section-tool.test.ts b/packages/viewer/tests/section-tool.test.ts index c090b59..3ec83df 100644 --- a/packages/viewer/tests/section-tool.test.ts +++ b/packages/viewer/tests/section-tool.test.ts @@ -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()