diff --git a/CHANGELOG.md b/CHANGELOG.md index 407ae39f..01261c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ saves carry the same number as `appVersion`. How a release is cut is in ## Unreleased +- **A shared workspace layout and guided measurement**. All workspaces use the + same header, workspace rail, resizable panels and persistent tool actions, + with sheets on narrow screens and light/dark themes. Tool search, keyboard + tree selection and a seven-step guide make the existing measurement workflow + easier to find and follow. A generated block supports measurement practice; + the bundled sample scan remains available. + +- **Keep keyboard focus in dialogs** (2026-11-01). Settings, units, agent + consent, support and imprint windows keep Tab inside and restore focus on + close. Escape closes the top window without changing background editors. + - **Contributors are credited** (2026-11-01). Each publish to this repository now names the people whose work it brings as co-authors, starting with Jozkah and Starzrng. CONTRIBUTING.md says how it works. diff --git a/docs/images/workspace-design/README.md b/docs/images/workspace-design/README.md new file mode 100644 index 00000000..0a905699 --- /dev/null +++ b/docs/images/workspace-design/README.md @@ -0,0 +1,17 @@ +# Workspace design review screenshots + +Captured from the implementation at `2e4552a`, based on upstream `5c1f2f9`, +with isolated headless Chrome profiles and the generated measurement block. +These are screenshots of the running app, not design mockups. + +- `measure-edit.png`: desktop element editing with Project and tool panels. +- `guided-measurement.png`: the optional guide at the surface-fitting step. +- `dark-deviation.png`: Surface deviation using the shared dark shell. +- `phone-tools.png`: 390 × 844 layout with the active tool's action footer. + +The measure/phone captures come from `scripts/e2e-measure-design.mjs`; +the guide/deviation captures come from `scripts/e2e-workspace-design.mjs` +against the production build. The scripts write fresh captures under `e2e-out/`. + +The generated geometry is for practice and layout verification. These images +do not establish physical scanner accuracy or hardware compatibility. diff --git a/docs/images/workspace-design/dark-deviation.png b/docs/images/workspace-design/dark-deviation.png new file mode 100644 index 00000000..782b59ad Binary files /dev/null and b/docs/images/workspace-design/dark-deviation.png differ diff --git a/docs/images/workspace-design/guided-measurement.png b/docs/images/workspace-design/guided-measurement.png new file mode 100644 index 00000000..f41e6d29 Binary files /dev/null and b/docs/images/workspace-design/guided-measurement.png differ diff --git a/docs/images/workspace-design/measure-edit.png b/docs/images/workspace-design/measure-edit.png new file mode 100644 index 00000000..3a851bc1 Binary files /dev/null and b/docs/images/workspace-design/measure-edit.png differ diff --git a/docs/images/workspace-design/phone-tools.png b/docs/images/workspace-design/phone-tools.png new file mode 100644 index 00000000..c8ebbc1e Binary files /dev/null and b/docs/images/workspace-design/phone-tools.png differ diff --git a/docs/manual.md b/docs/manual.md index c8a563b8..24b42095 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -7,6 +7,7 @@ workspaces have references of their own, in [plugins/reverse/README.md](../plugins/reverse/README.md) and [plugins/mesh-tools/README.md](../plugins/mesh-tools/README.md). +- [Workspace layout and guided measurement](#workspace-layout-and-guided-measurement) - [Undo and redo](#undo-and-redo) - [3D Measure: fitting and measuring features](#3d-measure-fitting-and-measuring-features) - [How to use it](#how-to-use-it) @@ -51,6 +52,38 @@ workspaces have references of their own, in - [Alignment, dimensions, and what leaves the tool](#alignment-dimensions-and-what-leaves-the-tool) - [Measuring a section](#measuring-a-section) +## Workspace layout and guided measurement + +All six workspaces share a top bar, workspace rail, Project panel and active-tool +panel. Drag a panel divider to resize it, or focus the divider and use the arrow +keys; Home and End choose its smallest and largest widths. On a narrow screen, +Project and tool controls open as sheets. The active tool's commit and cancel +buttons stay at the bottom of its panel. + +**Find a tool** or **Ctrl+K** (Mac: **Cmd+K**) searches the current workspace. +In 3D Measure it starts the selected tool; in the other workspaces it reveals +and focuses the existing control. **More** holds additional tools. **File** +contains New, Open and Export; Save Project downloads the editable project. +A browser recovery checkpoint is separate from that downloaded copy. + +In 3D Measure, **Guided measurement** walks through opening a scan, checking +its scale, choosing a measurement, fitting surfaces, adding a dimension, trying +Undo and Redo, and saving. It follows actual work in the project. Back reviews +instructions without undoing anything, and Skip closes the guide. The supported +paths are an angle between faces, distance between parallel faces, and a hole or +shaft diameter. The scale check is an acknowledgement, not a calibration: compare +a known size with the physical part before trusting a measurement. + +**Try a measurement sample** opens a generated 30 × 20 × 12 mm block through +the normal importer. It is ideal geometry for practice, not a scanner accuracy +test. **Open a sample scan** still opens the bundled fridge-bracket scan. + +Click a fitted element in the viewport or Project tree to select it. Shift adds +to the selection; Ctrl or Command also works in the viewport. Tree arrow keys +move focus, Space selects, and F2 renames. Visibility controls keep the geometry +while hiding it. Settings offers light, dark and system themes; reduced-motion +preferences disable interface transitions. + ## Undo and redo The top bar's **Undo** and **Redo** buttons work across 3D Measure, 2D Measure, @@ -384,6 +417,11 @@ last over. On a flat 2D sheet it pans and zooms and does not turn. ### Settings +Settings and other modal windows keep keyboard focus inside: **Tab** and +**Shift+Tab** cycle through their controls, and **Escape** closes only the +top window. Focus returns to the control that opened it; the work behind +the window stays in place. **Enter** activates the focused button. + **⚙ Settings** in the top bar holds what is set once and left, all of it remembered per browser and none of it saved with a project: diff --git a/index.html b/index.html index c24a7858..b238cd87 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,13 @@ - + - + ScanRuler by CNC Kitchen - - - diff --git a/plugins/mesh-tools/scripts/e2e-mesh-edits.mjs b/plugins/mesh-tools/scripts/e2e-mesh-edits.mjs index 17a83387..5cd5868e 100644 --- a/plugins/mesh-tools/scripts/e2e-mesh-edits.mjs +++ b/plugins/mesh-tools/scripts/e2e-mesh-edits.mjs @@ -1,4 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { clickTool } from '../../../scripts/e2e-lib.mjs' +import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs' // End-to-end test for the Mesh Tools edits beyond Remove islands: drives the // real app in headless Chrome on a generated scan — a noisy box standing on // the XY plane with two holes in its top — and fills one hole by clicking its @@ -156,7 +158,7 @@ const step = async (key, word) => { } await setViewTheme(page, 'studio') -await page.click('[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) await page.click('[data-test=workspace-mesh]') await page.waitForSelector('[data-test=start-pane]') await loadScan(page, SCAN, { inputSelector: '[data-test=start-scan] input[type=file]', timeout: 120_000, settle: 600 }) @@ -168,7 +170,7 @@ for (const id of ['reduce', 'islands', 'delete', 'cut', 'holes', 'smooth']) { // ---- Fill holes: the holes in the top are found and ringed; one is closed by // a click on its ring, the other with the button --------------------------------- -await page.click('[data-test=mesh-tool-holes]') +await clickTool(page, '[data-test=mesh-tool-holes]') await page.waitForSelector('[data-test=holes-count]', { timeout: 60_000 }) console.log(`holes: ${await text('[data-test=holes-rims]')} · ${await text('[data-test=holes-count]')}`) check(count(await text('[data-test=holes-rims] b')) === 2, 'the scan has two open rims') @@ -212,7 +214,7 @@ check(/Filled 1 hole under/.test(await text('[data-test=pending-edit]')), 'the f await page.screenshot({ path: shotPath('mesh-edits-filled.png') }) // ---- Smooth: the scan's scatter taken out, within the limit ------------------- -await page.click('[data-test=mesh-tool-smooth]') +await clickTool(page, '[data-test=mesh-tool-smooth]') await page.waitForSelector('[data-test=smooth-tool]') const limit = Number(await page.$eval('[data-test=smooth-limit]', (el) => el.value)) check(limit > 0 && limit < 0.2, `a limit is suggested for the part (${limit} mm)`) @@ -230,10 +232,10 @@ check(rms > 0.005, `and moves it by about the scatter it was given (RMS ${rms} m check((await copyCount()) === filled, 'and keeps every triangle') // ---- Cut: shown before it is made, then made ---------------------------------- -await page.click('[data-test=mesh-tool-reduce]') +await clickTool(page, '[data-test=mesh-tool-reduce]') await sleep(400) const whole = await stage() -await page.click('[data-test=mesh-tool-cut]') +await clickTool(page, '[data-test=mesh-tool-cut]') await page.waitForSelector('[data-test=cut-tool]') await sleep(400) const middle = Number(await page.$eval('[data-test=cut-offset]', (el) => el.value)) @@ -272,13 +274,13 @@ check(/Cut at XY plane, Z 5 mm/.test(cutStatus), 'the status line says where it check(/closed flat/.test(cutStatus), 'and that the part was closed flat') const cut = await copyCount() check(cut < filled, 'the cut takes triangles off') -await page.click('[data-test=mesh-tool-holes]') +await clickTool(page, '[data-test=mesh-tool-holes]') await page.waitForSelector('[data-test=holes-closed]', { timeout: 60_000 }) check(true, 'the cut part is still closed') await page.screenshot({ path: shotPath('mesh-edits-cut.png') }) // ---- Delete marked: a window over the part, asked, deleted -------------------- -await page.click('[data-test=mesh-tool-delete]') +await clickTool(page, '[data-test=mesh-tool-delete]') await page.waitForSelector('[data-test=delete-tool]') check(await page.$eval('[data-test=delete-marked]', (el) => el.disabled), 'nothing to delete before anything is marked') await page.click('[data-test=mark-window]') @@ -361,7 +363,7 @@ check((await copyCount()) === beforeDelete, 'the next puts the deleted surface b check((await edits()) === 4, 'and takes it off the list') await step('KeyZ', 'Undone') check((await copyCount()) === filled, 'the next puts back what the cut took off') -await page.click('[data-test=mesh-tool-cut]') +await clickTool(page, '[data-test=mesh-tool-cut]') await page.waitForSelector('[data-test=cut-offset]') check(Number(await page.$eval('[data-test=cut-offset]', (el) => el.value)) === 5, 'the offset typed for the cut is still set') await step('KeyZ', 'Undone') @@ -372,7 +374,7 @@ await step('KeyZ', 'Undone') await step('KeyZ', 'Undone') check(!(await page.$('[data-test=edited-copy]')), 'and three more leave no copy') check((await scanCount()) === opened && (await inUse()) === 0, 'the scan as it was opened, with nothing in use') -await page.click('[data-test=mesh-tool-holes]') +await clickTool(page, '[data-test=mesh-tool-holes]') await page.waitForFunction(() => /^2$/.test(document.querySelector('[data-test=holes-count] b')?.textContent ?? ''), { timeout: 60_000 }) check(true, 'both holes are open again') await step('KeyY', 'Redone') @@ -382,7 +384,7 @@ check((await edits()) === 1, 'and lists it') // ---- the cut preview across a hole the plane runs into --------------------------- // A plane across X through the hole still open is bridged under it; across // the one filled, it closes as it is. -await page.click('[data-test=mesh-tool-cut]') +await clickTool(page, '[data-test=mesh-tool-cut]') await page.waitForSelector('[data-test=cut-ref]') await page.select('[data-test=cut-ref]', 'x') await sleep(300) @@ -402,7 +404,7 @@ await across(at[0].includes('bridging') ? 7 : -7) await page.screenshot({ path: shotPath('mesh-edits-cut-bridged.png') }) // ---- Smooth a marked region alone ------------------------------------------------ -await page.click('[data-test=mesh-tool-smooth]') +await clickTool(page, '[data-test=mesh-tool-smooth]') await page.waitForSelector('[data-test=smooth-scope-marked]') await page.click('[data-test=smooth-scope-marked]') await page.waitForSelector('[data-test=mark-window]') @@ -442,7 +444,7 @@ await page.click('[data-test=mark-window]') await sleep(300) check(count(await text('[data-test=mark-count]')) > 0, 'a new region can be marked') await page.keyboard.press('Escape') -await page.click('[data-test=mesh-tool-delete]') +await clickTool(page, '[data-test=mesh-tool-delete]') await page.waitForSelector('[data-test=delete-tool]') await sleep(300) check(count(await text('[data-test=mark-count]')) === 0, 'Delete marked starts with nothing marked') diff --git a/plugins/mesh-tools/scripts/e2e-mesh-flip.mjs b/plugins/mesh-tools/scripts/e2e-mesh-flip.mjs index dfd12cba..21f93d31 100644 --- a/plugins/mesh-tools/scripts/e2e-mesh-flip.mjs +++ b/plugins/mesh-tools/scripts/e2e-mesh-flip.mjs @@ -9,6 +9,7 @@ // Prereqs: dev server running (npm run dev), Chrome installed. // node plugins/mesh-tools/scripts/e2e-mesh-flip.mjs // Env: CHROME (chrome.exe path), APP_URL, SHOT_DIR. +import { clickTool } from '../../../scripts/e2e-lib.mjs' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { canvasRect, check, drag, finish, launchApp, loadScan, OUT_DIR, shotPath, sleep } from '../../../scripts/e2e-lib.mjs' @@ -63,7 +64,7 @@ const ctrl = async (key) => { await loadScan(page, SCAN) await page.click('[data-test=workspace-mesh]') await page.waitForSelector('[data-test=mesh-tool-flip]') -await page.click('[data-test=mesh-tool-flip]') +await clickTool(page, '[data-test=mesh-tool-flip]') await page.waitForSelector('[data-test=flip-tool]') check(!(await page.$('[data-test=flip-backfaces-hint]')), 'Backfaces is on to start, so no hint asks for it') check(await page.$eval('[data-test=flip-marked]', (el) => el.disabled), 'Flip marked waits on a marking') diff --git a/plugins/mesh-tools/scripts/e2e-mesh-shell.mjs b/plugins/mesh-tools/scripts/e2e-mesh-shell.mjs index 73efbd55..366fc60c 100644 --- a/plugins/mesh-tools/scripts/e2e-mesh-shell.mjs +++ b/plugins/mesh-tools/scripts/e2e-mesh-shell.mjs @@ -1,4 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { clickTool } from '../../../scripts/e2e-lib.mjs' +import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs' // End-to-end test for Shrink / expand and Shell: drives the real app in // headless Chrome on a generated scan — a closed noisy box standing on the XY // plane — shrinks it and reads off the Deviation view that the surface moved @@ -66,14 +68,14 @@ const markMiddle = async (half) => { } /** Whether the mesh is closed, as Fill holes says. */ const closedNow = async () => { - await page.click('[data-test=mesh-tool-holes]') + await clickTool(page, '[data-test=mesh-tool-holes]') await page.waitForSelector('[data-test=holes-closed], [data-test=holes-rims]', { timeout: 60_000 }) await sleep(300) return Boolean(await page.$('[data-test=holes-closed]')) } await setViewTheme(page, 'studio') -await page.click('[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) await page.click('[data-test=workspace-mesh]') await page.waitForSelector('[data-test=start-pane]') await loadScan(page, SCAN, { inputSelector: '[data-test=start-scan] input[type=file]', timeout: 120_000, settle: 600 }) @@ -82,7 +84,7 @@ check(opened === fixture.triangles, `the scan opens with its ${fixture.triangles for (const id of ['offset', 'shell']) check(Boolean(await page.$(`[data-test=mesh-tool-${id}]`)), `the ${id} tool has a key`) // ---- Shrink the whole part: the surface 0.2 mm in, every triangle kept ------------ -await page.click('[data-test=mesh-tool-offset]') +await clickTool(page, '[data-test=mesh-tool-offset]') await page.waitForSelector('[data-test=offset-tool]') await page.click('[data-test=offset-shrink]') await setField('[data-test=offset-distance]', 0.2) @@ -129,7 +131,7 @@ await page.screenshot({ path: shotPath('mesh-shell-expanded.png') }) check(await closedNow(), 'the part is still closed') // ---- Hollow: a sealed cavity, seen in the Cut tool's preview ------------------------ -await page.click('[data-test=mesh-tool-shell]') +await clickTool(page, '[data-test=mesh-tool-shell]') await page.waitForSelector('[data-test=shell-closed]', { timeout: 60_000 }) check(true, 'Shell sees the part is closed, and offers to hollow it') await setField('[data-test=shell-thickness]', 2) @@ -147,7 +149,7 @@ const cavity = 25.6 * 25.6 * 15.6 check(Math.abs(out / cavity - 1) < 0.03, `the cavity is the box 2 mm in all round (${out} of ${cavity.toFixed(0)} mm³)`) check((await copyCount()) > beforeHollow, 'the inner wall brings triangles of its own') check(await closedNow(), 'the hollow part is closed') -await page.click('[data-test=mesh-tool-cut]') +await clickTool(page, '[data-test=mesh-tool-cut]') await page.waitForSelector('[data-test=cut-closes]', { timeout: 60_000 }) check(true, 'cut across the middle it closes flat — a ring round the cavity') await page.screenshot({ path: shotPath('mesh-shell-hollow-cut.png') }) @@ -155,7 +157,7 @@ await step('KeyZ', 'Undone') check((await copyCount()) === beforeHollow && (await edits()) === 2, 'a step back fills the part again') // ---- A small hole: Shell asks for it to be filled first --------------------------------- -await page.click('[data-test=mesh-tool-delete]') +await clickTool(page, '[data-test=mesh-tool-delete]') await page.waitForSelector('[data-test=delete-tool]') check((await markMiddle(10)) > 0, 'a small patch is marked to delete') await page.keyboard.press('Escape') @@ -163,7 +165,7 @@ await page.click('[data-test=delete-marked]') await page.waitForSelector('[data-test=delete-confirm]') await page.click('[data-test=delete-marked-confirm]') await editsReach(3) -await page.click('[data-test=mesh-tool-shell]') +await clickTool(page, '[data-test=mesh-tool-shell]') await page.waitForSelector('[data-test=shell-holes]', { timeout: 60_000 }) console.log('shell:', await text('[data-test=shell-holes]')) check(/closed but for 1 hole/.test(await text('[data-test=shell-holes]')), 'Shell says the part is closed but for a hole') @@ -174,13 +176,13 @@ await page.waitForSelector('[data-test=holes-tool]') check(true, 'Fill holes first takes the tool there') // ---- Cut open and thickened into a solid -------------------------------------------------- -await page.click('[data-test=mesh-tool-cut]') +await clickTool(page, '[data-test=mesh-tool-cut]') await page.waitForSelector('[data-test=cut-tool]') await page.click('[data-test=cut-cap]') await page.waitForSelector('[data-test=cut-scan]:not([disabled])') await page.click('[data-test=cut-scan]') await editsReach(4) -await page.click('[data-test=mesh-tool-shell]') +await clickTool(page, '[data-test=mesh-tool-shell]') await page.waitForSelector('[data-test=shell-open]', { timeout: 60_000 }) console.log('shell:', await text('[data-test=shell-open]')) check(true, 'Shell sees an open surface, and offers to thicken it') diff --git a/plugins/mesh-tools/scripts/e2e-mesh-trim.mjs b/plugins/mesh-tools/scripts/e2e-mesh-trim.mjs index 5b3f1262..7cdea25f 100644 --- a/plugins/mesh-tools/scripts/e2e-mesh-trim.mjs +++ b/plugins/mesh-tools/scripts/e2e-mesh-trim.mjs @@ -317,6 +317,6 @@ check(d.scan && d.piece && d.bodies === 0, 'and the box goes again, the scan bac check(Boolean(await page.$('[data-test=parts-export-trimmed]:not([disabled])')), 'the piece can be exported from the list') await click(page, '[data-test=mesh-tool-trim]') await sleep(300) -check(!(await page.$('[data-test=mesh-parts]')), 'a tool in hand puts the list away') +check(Boolean(await page.$('.measure-project [data-test=mesh-parts]')) && Boolean(await page.$('.measure-context [data-test=trim-tool]')), 'opening Trim keeps the parts tree in Project and the active tool in context') await finish(browser, consoleErrors) diff --git a/plugins/mesh-tools/scripts/e2e-mesh.mjs b/plugins/mesh-tools/scripts/e2e-mesh.mjs index 25a37374..c6879c4b 100644 --- a/plugins/mesh-tools/scripts/e2e-mesh.mjs +++ b/plugins/mesh-tools/scripts/e2e-mesh.mjs @@ -1,4 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { clickTool } from '../../../scripts/e2e-lib.mjs' +import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs' // End-to-end test for the Mesh Tools workspace: drives the real app in // headless Chrome — switches workspace, loads a scan from the stage, reduces // the mesh, orbits about the reduced mesh, looks at the result three ways, @@ -66,7 +68,7 @@ const setField = fieldSetter(page) // The grey scheme, so bare scan and a painted map are told apart by colour, // and the support card out of the corner of the stage it would be counted in. await setViewTheme(page, 'studio') -await page.click('[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) // ---- workspace first, then the scan ----------------------------------------- await page.click('[data-test=workspace-mesh]') @@ -363,7 +365,7 @@ if (spot) { await sleep(120) } } -await page.click('[data-test=mesh-tool-islands]') +await clickTool(page, '[data-test=mesh-tool-islands]') await page.waitForSelector('[data-test=islands-count]', { timeout: 120_000 }) check(!(await page.$('[data-test=mesh-view-reduced]')), 'the reduction view keys go with the reduce tool') check(!(await page.$('[data-test=reduce-legend]')), 'and so does its map') @@ -416,7 +418,7 @@ await page.screenshot({ path: shotPath('mesh-islands-removed.png') }) // The reduction on hand went with the mesh it was made of; made again, it is // made of the copy. -await page.click('[data-test=mesh-tool-reduce]') +await clickTool(page, '[data-test=mesh-tool-reduce]') await sleep(300) check(!(await page.$('[data-test=reduce-result]')), 'the reduction of the mesh before is gone') await page.click('[data-test=reduce-mesh]') @@ -478,7 +480,7 @@ check(Boolean(await page.$('[data-test=scan-edit]')), 'with the edit listed as i await page.screenshot({ path: shotPath('mesh-back.png') }) // ---- an edit waiting on the copy, saved and opened again ---------------------- -await page.click('[data-test=mesh-tool-smooth]') +await clickTool(page, '[data-test=mesh-tool-smooth]') await page.waitForSelector('[data-test=smooth-scan]') await page.click('[data-test=smooth-scan]') await page.waitForSelector('[data-test=pending-edit]', { timeout: 300_000 }) diff --git a/plugins/mesh-tools/ui/MeshPanel.tsx b/plugins/mesh-tools/ui/MeshPanel.tsx index 13643e23..0ca00f9b 100644 --- a/plugins/mesh-tools/ui/MeshPanel.tsx +++ b/plugins/mesh-tools/ui/MeshPanel.tsx @@ -10,6 +10,8 @@ // thickness panel — what shapes the job, then what came of it — because each // is the same kind of instrument: set it, run it, read it. +import { ToolPalette } from '../../../src/ui/ToolPalette' +import { WorkspacePanels } from '../../../src/ui/WorkspacePanels' import { type ReactNode } from 'react' import { scanEditFigure, @@ -164,7 +166,8 @@ export function MeshPanel({ const busy = scanBusy || running return ( - + ) } diff --git a/plugins/mesh-tools/ui/mesh-tools.css b/plugins/mesh-tools/ui/mesh-tools.css index ad745b50..836bd0c0 100644 --- a/plugins/mesh-tools/ui/mesh-tools.css +++ b/plugins/mesh-tools/ui/mesh-tools.css @@ -20,7 +20,7 @@ } .confirmbox p.hint { - font-size: 11.5px; + font-size: 12px; margin-bottom: 8px; } diff --git a/plugins/mesh-tools/ui/tools/BooleanTool.tsx b/plugins/mesh-tools/ui/tools/BooleanTool.tsx index e3bb3bfe..ac8bf05e 100644 --- a/plugins/mesh-tools/ui/tools/BooleanTool.tsx +++ b/plugins/mesh-tools/ui/tools/BooleanTool.tsx @@ -2,6 +2,7 @@ // Boolean: the scan joined with, cut by or intersected with the solids the // workspace has. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useSolids } from '../../../../src/app/solids' import { useStore } from '../../../../src/state/store' import { InfoDot } from '../../../../src/ui/InfoDot' @@ -144,7 +145,7 @@ export function BooleanTool({ ready, busy, onBoolean }: { ready: boolean; busy:

)} - + ) } diff --git a/plugins/mesh-tools/ui/tools/CutTool.tsx b/plugins/mesh-tools/ui/tools/CutTool.tsx index c0c80e74..5d135b46 100644 --- a/plugins/mesh-tools/ui/tools/CutTool.tsx +++ b/plugins/mesh-tools/ui/tools/CutTool.tsx @@ -2,6 +2,7 @@ // Cut: the scan cut by a plane, one side taken off and the cut capped flat // where the part was closed. +import { ToolActions } from '../../../../src/ui/ToolActions' import { isWorldAxis, SECTION_REF_KINDS, type SectionRef } from '../../../../src/core/section/frame' import { useStore } from '../../../../src/state/store' import { InfoDot } from '../../../../src/ui/InfoDot' @@ -121,7 +122,7 @@ export function CutTool({ ready, busy, onCut }: { ready: boolean; busy: boolean; - {!plane &&

Choose what to cut across.

} {plane && ready && } diff --git a/plugins/mesh-tools/ui/tools/DeleteTool.tsx b/plugins/mesh-tools/ui/tools/DeleteTool.tsx index 409c80f8..6f261c11 100644 --- a/plugins/mesh-tools/ui/tools/DeleteTool.tsx +++ b/plugins/mesh-tools/ui/tools/DeleteTool.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only // Delete marked: the surface marked by hand taken off the scan. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useMark } from '../../../../src/state/markStore' import { InfoDot } from '../../../../src/ui/InfoDot' import { MarkTools } from '../../../../src/ui/MarkTools' @@ -56,7 +57,7 @@ export function DeleteTool({ - + ) : ( - )} {ready && marked === 0 && !editing &&

Pick Window, Brush or Lasso and mark what is to go.

} diff --git a/plugins/mesh-tools/ui/tools/FlipTool.tsx b/plugins/mesh-tools/ui/tools/FlipTool.tsx index 2c399810..bf74ec2e 100644 --- a/plugins/mesh-tools/ui/tools/FlipTool.tsx +++ b/plugins/mesh-tools/ui/tools/FlipTool.tsx @@ -2,6 +2,7 @@ // Flip normals: the triangles turned over, so they face the other way — the // whole part, or the surface marked on it. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useState } from 'react' import { useMark } from '../../../../src/state/markStore' import { useStore } from '../../../../src/state/store' @@ -71,7 +72,7 @@ export function FlipTool({
- - +
{ready && marked === 0 && !editing && (

Mark the surface that faces the wrong way — pick Window, Brush or Lasso — or flip the whole part.

diff --git a/plugins/mesh-tools/ui/tools/HolesTool.tsx b/plugins/mesh-tools/ui/tools/HolesTool.tsx index 3969823b..98bc9104 100644 --- a/plugins/mesh-tools/ui/tools/HolesTool.tsx +++ b/plugins/mesh-tools/ui/tools/HolesTool.tsx @@ -2,6 +2,7 @@ // Fill holes: the gaps in the scan under a size closed, flat or following // the surface round them. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useLength } from '../../../../src/app/useUnits' import { InfoDot } from '../../../../src/ui/InfoDot' import { NumberField } from '../../../../src/ui/NumberField' @@ -116,7 +117,7 @@ export function HolesTool({ ready, busy, onFill }: { ready: boolean; busy: boole )} - ) } diff --git a/plugins/mesh-tools/ui/tools/IslandsTool.tsx b/plugins/mesh-tools/ui/tools/IslandsTool.tsx index ea922584..80620813 100644 --- a/plugins/mesh-tools/ui/tools/IslandsTool.tsx +++ b/plugins/mesh-tools/ui/tools/IslandsTool.tsx @@ -2,6 +2,7 @@ // Remove islands: the specks floating off the scan, under a size, found, // marked on the part and deleted once asked. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useLength } from '../../../../src/app/useUnits' import { InfoDot } from '../../../../src/ui/InfoDot' import { NumberField } from '../../../../src/ui/NumberField' @@ -107,7 +108,7 @@ export function IslandsTool({ ready, busy, onRemove }: { ready: boolean; busy: b - + ) : ( - )} ) diff --git a/plugins/mesh-tools/ui/tools/OffsetTool.tsx b/plugins/mesh-tools/ui/tools/OffsetTool.tsx index 9fe98119..e699172c 100644 --- a/plugins/mesh-tools/ui/tools/OffsetTool.tsx +++ b/plugins/mesh-tools/ui/tools/OffsetTool.tsx @@ -2,6 +2,7 @@ // Offset: the surface moved along its normals, the whole part or a marked // region, by a distance either way. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useLength } from '../../../../src/app/useUnits' import { useMark } from '../../../../src/state/markStore' import { InfoDot } from '../../../../src/ui/InfoDot' @@ -112,7 +113,7 @@ export function OffsetTool({ hint="How far the surface moves along its normal. Meant for corrections small against the part's features: a slot narrower than twice the distance closes over itself when the part is expanded, as a rib thinner than it does when it is shrunk." /> - {regional && marked === 0 && (

Mark the surface to move — pick Window, Brush or Lasso above.

)} diff --git a/plugins/mesh-tools/ui/tools/ReduceTool.tsx b/plugins/mesh-tools/ui/tools/ReduceTool.tsx index 85cf5a9c..78bb63de 100644 --- a/plugins/mesh-tools/ui/tools/ReduceTool.tsx +++ b/plugins/mesh-tools/ui/tools/ReduceTool.tsx @@ -2,6 +2,7 @@ // Reduce mesh: a light copy of the scan to model over in CAD, held within a // tolerance, its sharp edges kept. +import { ToolActions } from '../../../../src/ui/ToolActions' import { usePulse } from '../../../../src/app/useHints' import { InfoDot } from '../../../../src/ui/InfoDot' import { NumberField } from '../../../../src/ui/NumberField' @@ -98,7 +99,7 @@ export function ReduceTool({ } /> -

Both figures shape the reduction, so changing them means reducing again.

{m.message &&

{m.message}

} diff --git a/plugins/mesh-tools/ui/tools/ShellTool.tsx b/plugins/mesh-tools/ui/tools/ShellTool.tsx index 6718e921..a5461559 100644 --- a/plugins/mesh-tools/ui/tools/ShellTool.tsx +++ b/plugins/mesh-tools/ui/tools/ShellTool.tsx @@ -2,6 +2,7 @@ // Shell: a closed part hollowed to a wall, or an open surface thickened to // one, on the side chosen. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useLength } from '../../../../src/app/useUnits' import { InfoDot } from '../../../../src/ui/InfoDot' import { NumberField } from '../../../../src/ui/NumberField' @@ -116,12 +117,12 @@ export function ShellTool({ ready, busy, onShell }: { ready: boolean; busy: bool - + ) : ( - )} ) diff --git a/plugins/mesh-tools/ui/tools/SmoothTool.tsx b/plugins/mesh-tools/ui/tools/SmoothTool.tsx index eb702be7..7634886b 100644 --- a/plugins/mesh-tools/ui/tools/SmoothTool.tsx +++ b/plugins/mesh-tools/ui/tools/SmoothTool.tsx @@ -2,6 +2,7 @@ // Smooth: the scan's noise taken out, over the whole part or a marked // region, its edges kept or let soften. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useMark } from '../../../../src/state/markStore' import { InfoDot } from '../../../../src/ui/InfoDot' import { MarkTools } from '../../../../src/ui/MarkTools' @@ -112,7 +113,7 @@ export function SmoothTool({ Keep sharp edges - {regional && marked === 0 && (

Mark the surface to smooth — pick Window, Brush or Lasso above.

)} diff --git a/plugins/mesh-tools/ui/tools/TrimTool.tsx b/plugins/mesh-tools/ui/tools/TrimTool.tsx index 028c6550..8b81bf81 100644 --- a/plugins/mesh-tools/ui/tools/TrimTool.tsx +++ b/plugins/mesh-tools/ui/tools/TrimTool.tsx @@ -2,6 +2,7 @@ // Trim to scan: a solid trimmed where the scan breaks off, the piece kept // and handed on. +import { ToolActions } from '../../../../src/ui/ToolActions' import { useSolids } from '../../../../src/app/solids' import { useLength } from '../../../../src/app/useUnits' import { useMark } from '../../../../src/state/markStore' @@ -182,7 +183,7 @@ export function TrimTool({ /> )} - {m.trimMessage && (

{m.trimMessage} diff --git a/plugins/reverse/ci.json b/plugins/reverse/ci.json index 7f0151c3..ef9a090e 100644 --- a/plugins/reverse/ci.json +++ b/plugins/reverse/ci.json @@ -1 +1 @@ -{ "e2e": ["scripts/e2e-reverse-undo.mjs", "scripts/e2e-agent-reverse.mjs", "scripts/e2e-reverse-import-move.mjs", "scripts/e2e-reverse-body-tree.mjs", "scripts/e2e-reverse-notes.mjs", "scripts/e2e-note-from-file.mjs", "scripts/e2e-patch-network.mjs"] } +{"e2e": ["scripts/e2e-reverse-undo.mjs", "scripts/e2e-modal-focus.mjs", "scripts/e2e-agent-reverse.mjs", "scripts/e2e-reverse-import-move.mjs", "scripts/e2e-reverse-body-tree.mjs", "scripts/e2e-reverse-notes.mjs", "scripts/e2e-note-from-file.mjs", "scripts/e2e-patch-network.mjs"]} diff --git a/plugins/reverse/scripts/e2e-modal-focus.mjs b/plugins/reverse/scripts/e2e-modal-focus.mjs new file mode 100644 index 00000000..8cd9680d --- /dev/null +++ b/plugins/reverse/scripts/e2e-modal-focus.mjs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// A native modal must not dismiss a nonmodal tool window underneath it. +import { launchApp, click, check, fail, finish } from '../../../scripts/e2e-lib.mjs' +import { openReverse } from './lib.mjs' + +const { browser, page, consoleErrors } = await launchApp() +try { + await openReverse(page) + await page.evaluate(() => window.__re.store.getState().startSketchOn({ kind: 'world', axis: 'z', offset: 0 })) + await page.waitForSelector('[data-test=sketch-editor]') + await click(page, '[data-test=sketch-fit-options]') + await page.waitForSelector('[data-test=fit-options]') + check(!(await page.$('.modalback')), 'Fit options remains a nonmodal tool window') + await click(page, '[data-test=open-settings]') + await page.waitForSelector('[data-test=settings-modal]') + await page.keyboard.press('Escape') + await page.waitForSelector('[data-test=settings-modal]', { hidden: true }) + check(!!(await page.$('[data-test=fit-options]')), 'Escape in Settings leaves the underlying Fit options window open') + check(await page.evaluate(() => !!window.__re.store.getState().sketchDraft), 'the sketch remains open after Settings closes') + await page.keyboard.press('Escape') + await page.waitForSelector('[data-test=fit-options]', { hidden: true }) + check(await page.evaluate(() => !!window.__re.store.getState().sketchDraft), 'Escape then closes Fit options without discarding the sketch') +} catch (e) { fail(e.stack ?? String(e)) } +finally { await finish(browser, consoleErrors) } diff --git a/plugins/reverse/scripts/e2e-patch-network.mjs b/plugins/reverse/scripts/e2e-patch-network.mjs index 7fd9330e..5128db38 100644 --- a/plugins/reverse/scripts/e2e-patch-network.mjs +++ b/plugins/reverse/scripts/e2e-patch-network.mjs @@ -25,6 +25,7 @@ // Prereqs: dev server running, Chrome. // node plugins/reverse/scripts/e2e-patch-network.mjs // Env: APP_URL, CHROME, OUT_DIR, SCOOTER_SCAN. +import { clickTool } from '../../../scripts/e2e-lib.mjs' import { existsSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -56,7 +57,7 @@ const { re, clickAt, screen } = reverseHelpers(page) try { await loadScan(page, scanPath, { timeout: 300_000 }) await openReverse(page) - await page.click('[data-test=feature-network]') + await clickTool(page, '[data-test=feature-network]') await page.waitForSelector('[data-test=network-editor]') await re(() => window.__re.scene().viewFrom('top')) await sleep(800) @@ -69,7 +70,7 @@ try { await sleep(300) check(await re(() => window.__re.store.getState().featureDraft === null), 'another workspace closed the box') await page.click('[data-test=workspace-reverse]') - await page.click('[data-test=feature-network]') + await clickTool(page, '[data-test=feature-network]') await page.waitForSelector('[data-test=network-editor]') check(await re(() => window.__re.store.getState().featureDraft?.nodes.length === 0 && !window.__re.store.getState().featureDraft.placing), 'the box opened again empty') const state = () => diff --git a/plugins/reverse/scripts/e2e-reverse-body-tree.mjs b/plugins/reverse/scripts/e2e-reverse-body-tree.mjs index fe301022..fcabc4d2 100644 --- a/plugins/reverse/scripts/e2e-reverse-body-tree.mjs +++ b/plugins/reverse/scripts/e2e-reverse-body-tree.mjs @@ -83,6 +83,16 @@ let st = await bodies() check(JSON.stringify(st.map((b) => b.name)) === JSON.stringify(['Base', 'Shaft', 'Cap']), `three parts, named as the file names them (${st.map((b) => b.name)})`) const id = Object.fromEntries(st.map((b) => [b.name, b.id])) +// Keyboard selection reaches the same real body state as pointer selection. +await page.focus(`[data-test=body-row][data-body="${id.Base}"]`) +await page.keyboard.press('ArrowDown') +check(await page.evaluate(() => document.activeElement?.querySelector('[data-test=body-name]')?.textContent === 'Shaft'), 'body-tree arrow key moves focus to Shaft') +await page.keyboard.press('Space') +check(await re((id) => window.__re.store.getState().listSelection.bodies.includes(id), id.Shaft), 'keyboard selection updates the existing body selection') +check(await page.$$eval('[data-test=body-list] [role=treeitem]', rows => rows.filter(r => r.tabIndex === 0).length === 1), 'body tree has one roving Tab stop') +await click(page, '[data-test=body-selection-clear]') + + // ---- a component made, named, and a part dragged into it --------------------------------- await click(page, '[data-test=body-folder-new]') await page.waitForSelector('[data-test=body-folder-name-input]') diff --git a/plugins/reverse/scripts/e2e-sketch-box.mjs b/plugins/reverse/scripts/e2e-sketch-box.mjs index 3466b736..6c3994a0 100644 --- a/plugins/reverse/scripts/e2e-sketch-box.mjs +++ b/plugins/reverse/scripts/e2e-sketch-box.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs' // End-to-end test of the selection box on the sketch sheet, with the mouse, // on a blank stage: a rectangle and a lone line beside it; with the Select // tool a left-drag over the bare sheet draws a box instead of panning — @@ -21,7 +22,7 @@ const { re } = reverseHelpers(page) await openReverse(page) await sleep(300) // The support card sits over the stage's lower left, where the boxes start. -if (await page.$('[data-test=support-card] .sc-x')) await click(page, '[data-test=support-card] .sc-x') +await dismissSupportCard(page) // ---- a rectangle and a lone line beside it -------------------------------------------- const ids = await re(() => { diff --git a/plugins/reverse/scripts/e2e-sketch-spline.mjs b/plugins/reverse/scripts/e2e-sketch-spline.mjs index c47448cc..be15ad5d 100644 --- a/plugins/reverse/scripts/e2e-sketch-spline.mjs +++ b/plugins/reverse/scripts/e2e-sketch-spline.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs' // End-to-end test of the sketch's Spline tool, with the mouse, on a blank // stage: the key in the tool row and on S takes it in hand; clicks lay its // points with the curve following the cursor; a second click on the last @@ -17,7 +18,7 @@ const { re, built } = reverseHelpers(page) await openReverse(page) await sleep(300) -if (await page.$('[data-test=support-card] .sc-x')) await click(page, '[data-test=support-card] .sc-x') +await dismissSupportCard(page) // A sketch with two lines laid above and below where the curves go, so // the sheet is framed over them. diff --git a/plugins/reverse/ui/BodyList.tsx b/plugins/reverse/ui/BodyList.tsx index 0b12aa28..3d912d34 100644 --- a/plugins/reverse/ui/BodyList.tsx +++ b/plugins/reverse/ui/BodyList.tsx @@ -14,7 +14,7 @@ // standing for what it holds. While a Move is open, a click on a row puts // the body into the Move or takes it out. -import { useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' +import { useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react' import { bodiesOfPick, bodiesWithin, commonFolder, folderOf, folderPath, foldersWithin, moveInto, NO_PICK, outermost, type BodyFolder, type FolderMove, type ListPick } from '../core/bodyFolders' import { BODY_SWATCHES, bodyColor, reEditorOpen, selectionItems, useReverse, type BodyInfo } from '../state/reStore' import { useLength } from '../../../src/app/useUnits' @@ -110,6 +110,12 @@ export function BodyList({ bodies, children }: { bodies: BodyInfo[]; children?: /** Where a drop would put what is dragged: a component, the head of the * list (null), or nowhere (undefined). */ const [dropAt, setDropAt] = useState(undefined) + const tree = useRef(null) + useEffect(() => { + const rows = Array.from(tree.current?.querySelectorAll('[role=treeitem]') ?? []) + const active = rows.find(r => r === document.activeElement) ?? rows.find(r => r.tabIndex === 0) ?? rows[0] + rows.forEach(r => { r.tabIndex = r === active ? 0 : -1 }) + }) const [menu, setMenu] = useState<{ x: number; y: number; pick: ListPick } | null>(null) const [naming, setNaming] = useState(null) /** The row a Shift+click picks every row up to. */ @@ -285,6 +291,20 @@ export function BodyList({ bodies, children }: { bodies: BodyInfo[]; children?:

{ const row = (e.target as HTMLElement).closest('[role=treeitem]'); if (row) tree.current?.querySelectorAll('[role=treeitem]').forEach(r => { r.tabIndex = r === row ? 0 : -1 }) }} + onKeyDown={e => { + if ((e.target as HTMLElement).closest('input,button')) return + const rows = Array.from(e.currentTarget.querySelectorAll('[role=treeitem]')) + const at = rows.indexOf(e.target as HTMLElement) + if (['ArrowDown','ArrowUp','Home','End'].includes(e.key)) { + e.preventDefault(); e.stopPropagation() + rows[e.key === 'Home' ? 0 : e.key === 'End' ? rows.length - 1 : Math.max(0, Math.min(rows.length - 1, at + (e.key === 'ArrowDown' ? 1 : -1)))]?.focus() + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); e.stopPropagation() + rows[at]?.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: e.shiftKey, ctrlKey: e.ctrlKey, metaKey: e.metaKey })) + } + }} // A click on the list where there is no row lets the rows picked go. onClick={(e) => { if (pickCount > 0 && !move && !(e.target as Element).closest('[data-test=body-row], [data-test=body-folder], button, input, .bodysel, .re-menu')) re.getState().setListSelection(NO_PICK) @@ -409,6 +429,8 @@ function FolderRow({ className={'kv folderrow' + (inside.length > 0 && !visible ? ' ghost' : '') + marks(m) + (drop ? ' drop' : '')} style={{ paddingLeft: 1 + depth * INDENT_PX }} data-test="body-folder" + role="treeitem" tabIndex={-1} aria-level={depth + 1} aria-expanded={open} + onKeyDown={e => { if ((e.target as HTMLElement).closest('input,button')) return; if (e.key === 'F2') { e.preventDefault(); setNaming(true) } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); re.getState().setFolderOpen(f.id, e.key === 'ArrowRight') } }} data-folder={f.id} aria-selected={m.selected} title={`${f.name} — ${inside.length} bod${inside.length === 1 ? 'y' : 'ies'}. Drag bodies onto it to put them in; right-click for more`} @@ -481,6 +503,8 @@ function BodyRow({ body: b, depth, onPick, onMenu, rowEvents, ...m }: RowMarks & className={'kv bodyrow' + (b.visible ? '' : ' ghost') + marks(m)} style={{ paddingLeft: 1 + depth * INDENT_PX }} data-test="body-row" + role="treeitem" tabIndex={-1} aria-level={depth + 1} + onKeyDown={e => { if (e.key === 'F2' && !(e.target as HTMLElement).closest('input,button')) { e.preventDefault(); setNaming(true) } }} data-body={b.id} aria-selected={m.selected} onPointerEnter={() => hoverRows([b.id])} diff --git a/plugins/reverse/ui/ExtrudeEditor.tsx b/plugins/reverse/ui/ExtrudeEditor.tsx index dd6ac764..33841620 100644 --- a/plugins/reverse/ui/ExtrudeEditor.tsx +++ b/plugins/reverse/ui/ExtrudeEditor.tsx @@ -3,6 +3,7 @@ // plane or a face, the taper, the direction and the body operation, with // the preview and the live map. +import { ToolActions } from '../../../src/ui/ToolActions' import { useLength } from '../../../src/app/useUnits' import { Icon } from '../../../src/ui/icons' import { InfoDot } from '../../../src/ui/InfoDot' @@ -247,13 +248,13 @@ export function ExtrudeEditor() { /> set({ direction })} /> 0} testPrefix="extrude" onOp={(op) => re.getState().setExtrudeOp(op)} /> - +
- +
) diff --git a/plugins/reverse/ui/FeatureEditor.tsx b/plugins/reverse/ui/FeatureEditor.tsx index cbe1ae1e..96e6e429 100644 --- a/plugins/reverse/ui/FeatureEditor.tsx +++ b/plugins/reverse/ui/FeatureEditor.tsx @@ -4,6 +4,7 @@ // the bodies — with the footer the kinds share. Lengths are shown in the // display units; what the draft holds is millimetres. +import { ToolActions } from '../../../src/ui/ToolActions' import { useLength } from '../../../src/app/useUnits' import { round3 } from '../../../src/core/round' import { WORLD_AXES } from '../../../src/core/section/frame' @@ -1190,7 +1191,7 @@ export function FeatureEditor() { {fields} {!planned && !leaning && } {!planned && !leaning && } - +
- +
) diff --git a/plugins/reverse/ui/FitOptionsModal.tsx b/plugins/reverse/ui/FitOptionsModal.tsx index 3904096f..c8b261cf 100644 --- a/plugins/reverse/ui/FitOptionsModal.tsx +++ b/plugins/reverse/ui/FitOptionsModal.tsx @@ -43,7 +43,7 @@ export function FitOptionsModal({ open, onClose, chainsOf }: { open: boolean; on useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return + if (e.key !== 'Escape' || document.querySelector('dialog:modal')) return e.stopPropagation() onClose() } diff --git a/plugins/reverse/ui/ReversePanel.tsx b/plugins/reverse/ui/ReversePanel.tsx index f4243eec..a901fbde 100644 --- a/plugins/reverse/ui/ReversePanel.tsx +++ b/plugins/reverse/ui/ReversePanel.tsx @@ -6,6 +6,8 @@ // clicked in the viewport, the body shows as a ghost with grips to drag, // and the numbers in the box follow the grips as the grips follow them. +import { ToolPalette } from '../../../src/ui/ToolPalette' +import { WorkspacePanels } from '../../../src/ui/WorkspacePanels' import { Fragment, useEffect, useRef, useState } from 'react' import { bodyMaker, dependentsOf, type Feature, type PlaneRef, type SketchFeature } from '../core/features' import { describePlaneRef } from '../core/resolve' @@ -205,7 +207,8 @@ export function ReversePanel({ onOpenScan, onExportStep, onCompare, chainsOf }: const featureMenu = useFeatureMenu(featureListRef, editorOpen) return ( - + + }> + {!editorOpen && ( +
+
+ Create feature + +

+ Sketch on a plane: the slice the plane cuts through the scan is fitted with + lines and arcs, corners are inferred and the profile closed. The sketch then shows + on the part. Extrude picks the sketch's regions in the viewport and pulls + them by a distance, to a plane, between two planes or symmetrically; Revolve{' '} + turns them about a line, a cylinder's axis or a sketch line. Both show the body + as a ghost with grips to drag while they are set up. +

+

+ Every extrude or revolve makes a new body, or joins or cuts{' '} + one made before. Features rebuild in order whenever one changes; one whose plane or + sketch has gone says so and the rest still build. +

+
+
+ + + + + + re.getState().startFeature('import', { file, name: file.name.replace(/\.(step|stp)$/i, '') })}> + + Import STEP + + + + + + + + + + + + + + + + + + + + + + + + + {sketches.length === 0 && fileName &&

Start with a sketch: choose a plane above, or cut a section in the 3D Measure workspace.

} + {sketches.length === 0 && !fileName &&

Start with a sketch: choose a coordinate plane above. No scan is needed to model.

} +
+ )} + + +
+ + + + +
+ + ) } diff --git a/plugins/reverse/ui/RevolveEditor.tsx b/plugins/reverse/ui/RevolveEditor.tsx index f692fd06..c0663da1 100644 --- a/plugins/reverse/ui/RevolveEditor.tsx +++ b/plugins/reverse/ui/RevolveEditor.tsx @@ -2,6 +2,7 @@ // The Revolve box: the profiles picked, the axis, the angle or a coil's // pitch and turns, the body operation, with the preview and the live map. +import { ToolActions } from '../../../src/ui/ToolActions' import { WORLD_AXES } from '../../../src/core/section/frame' import { useStore } from '../../../src/state/store' import { Icon } from '../../../src/ui/icons' @@ -132,13 +133,13 @@ export function RevolveEditor() { )} 0} testPrefix="revolve" onOp={(op) => set({ op })} /> - +
- +
) diff --git a/plugins/reverse/ui/SketchEditor.tsx b/plugins/reverse/ui/SketchEditor.tsx index 7c1f3f87..924540a5 100644 --- a/plugins/reverse/ui/SketchEditor.tsx +++ b/plugins/reverse/ui/SketchEditor.tsx @@ -12,6 +12,7 @@ // here. Renders nothing without a sketch draft. The stage beside it is the // 2D sheet — see SketchViewer. +import { ToolActions } from '../../../src/ui/ToolActions' import { useEffect, useRef, useState } from 'react' import { datumName } from '../core/datums' import { planeOffset, planeTakesOffset } from '../core/features' @@ -900,7 +901,7 @@ export function SketchEditor({ chainsOf }: { chainsOf: () => SketchChains | null )} - {finishKey('sketch-finish')} + {finishKey('sketch-finish')} ) } diff --git a/plugins/reverse/ui/reverse.css b/plugins/reverse/ui/reverse.css index 2f4168af..e83afe97 100644 --- a/plugins/reverse/ui/reverse.css +++ b/plugins/reverse/ui/reverse.css @@ -6,7 +6,7 @@ .picked .clearpicks { margin-left: 8px; padding: 1px 7px; - font-size: 11px; + font-size: 12px; line-height: 16px; vertical-align: baseline; } @@ -193,7 +193,7 @@ .rollbar button { padding: 1px 8px; - font-size: 11px; + font-size: 12px; white-space: nowrap; } @@ -278,7 +278,7 @@ .playstep { font-family: var(--cond); - font-size: 11px; + font-size: 12px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; @@ -330,7 +330,7 @@ .viewport-label.note-label.glyph { margin: -7px 0 0 6px; padding: 1px 5px; - font-size: 11.5px; + font-size: 12px; cursor: pointer; } @@ -403,7 +403,7 @@ .relations .kv input[type='number'] { width: 78px; font-family: var(--mono); - font-size: 11px; + font-size: 12px; padding: 2px 4px; } @@ -412,7 +412,7 @@ click on the part. Small and round, so they read as offers, not fields. */ .chip.nominal { font-family: var(--mono); - font-size: 10.5px; + font-size: 12px; line-height: 1.4; padding: 1px 7px; border-radius: 10px; @@ -440,7 +440,7 @@ display: block; width: 100%; text-align: left; - font-size: 11px; + font-size: 12px; padding: 2px 6px; margin: 1px 0; border: 1px solid transparent; @@ -470,7 +470,7 @@ display: inline-flex; align-items: center; gap: 2px; - font-size: 11px; + font-size: 12px; padding: 1px 3px 1px 8px; border-radius: 10px; border: 1px dashed var(--line); @@ -530,7 +530,7 @@ } .chipinfo b { - font-size: 11.5px; + font-size: 12px; color: var(--dim); font-weight: 600; padding-bottom: 1px; @@ -549,14 +549,14 @@ .chipinfo-value { font-family: var(--mono); - font-size: 11.5px; + font-size: 12px; text-align: right; user-select: text; } .chipinfo-tip { padding-top: 3px; - font-size: 10.5px; + font-size: 12px; color: var(--faint); } @@ -616,7 +616,7 @@ } .modal.fitoptions > small { - font-size: 11.5px; + font-size: 12px; line-height: 1.4; } @@ -654,12 +654,12 @@ } .parameters .sec-head .fold span { - font-size: 10px; + font-size: 12px; color: var(--dim); } .parameters .sec-head > button:not(.fold) { - font-size: 11px; + font-size: 12px; padding: 1px 7px; } @@ -695,7 +695,7 @@ .kv.parameter input.mono { font-family: var(--mono); - font-size: 11px; + font-size: 12px; } .kv.parameter input[data-test='parameter-name'] { @@ -707,7 +707,7 @@ } .kv.parameter select { - font-size: 11px; + font-size: 12px; padding: 1px 2px; } @@ -780,7 +780,7 @@ .relations .kv .unitfield input[type='text'] { font-family: var(--mono); - font-size: 11px; + font-size: 12px; padding: 2px 4px; padding-right: 26px; } @@ -791,7 +791,7 @@ .relations .kv .unitfield i { right: 5px; - font-size: 10px; + font-size: 12px; max-width: 50px; } @@ -859,7 +859,7 @@ .swatches button.auto { width: auto; padding: 0 6px; - font-size: 11px; + font-size: 12px; background: var(--well); color: var(--dim); } @@ -930,7 +930,7 @@ align-items: center; gap: 4px; padding: 2px 8px; - font-size: 11.5px; + font-size: 12px; } /* The picture several rows are dragged as: how many. */ @@ -972,7 +972,7 @@ .kv.folderrow button.x.fold { width: 14px; padding: 0; - font-size: 10px; + font-size: 12px; color: var(--dim); } @@ -992,7 +992,7 @@ padding: 5px 8px; border: 1px dashed var(--line-strong); border-radius: 3px; - font-size: 11px; + font-size: 12px; color: var(--dim); text-align: center; } @@ -1004,7 +1004,7 @@ align-items: center; gap: 4px; font-family: var(--cond); - font-size: 10px; + font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; line-height: 16px; @@ -1050,7 +1050,7 @@ } .chipbody-name small { - font-size: 10.5px; + font-size: 12px; font-weight: 400; color: var(--dim); } @@ -1064,7 +1064,7 @@ .chipbar .chipbody-keys button { padding: 1px 7px; - font-size: 11px; + font-size: 12px; } /* A suppressed feature: left out of the build, kept in the list — greyed @@ -1095,7 +1095,7 @@ .re-menu-head { padding: 3px 12px 5px; font-family: var(--cond); - font-size: 10px; + font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--dim); @@ -1142,7 +1142,7 @@ min-width: 0; padding: 5px 4px; font-family: var(--mono); - font-size: 11px; + font-size: 12px; } /* A Move's distances and angles: each of x, y and z underlined in its @@ -1190,7 +1190,7 @@ border: 1.5px solid #1d2026; color: #1d2026; font-family: var(--mono); - font-size: 10.5px; + font-size: 12px; font-weight: 700; line-height: 1; } @@ -1249,7 +1249,7 @@ } .noterow small { - font-size: 10.5px; + font-size: 12px; color: var(--dim); } @@ -1289,7 +1289,7 @@ display: flex; align-items: center; gap: 6px; - font-size: 11.5px; + font-size: 12px; color: var(--dim); min-width: 0; } @@ -1324,7 +1324,7 @@ .notecard-reply { margin: 0; - font-size: 11.5px; + font-size: 12px; line-height: 1.4; color: var(--accent-d); } @@ -1337,7 +1337,7 @@ /* A note's text beside its pin in the viewport, right of the badge; the whole of it under the cursor, the start of it otherwise. */ -.note-label { +.re-note-label { margin: -17px 0 0 34px; max-width: 220px; padding: 2px 7px; @@ -1346,40 +1346,40 @@ border: 1px solid var(--line); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14); color: var(--ink); - font-size: 11px; + font-size: 12px; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.note-label:empty, -.nolabels .note-label { +.re-note-label:empty, +.nolabels .re-note-label { display: none; } -.note-label.lit { +.re-note-label.lit { max-width: 300px; white-space: normal; } -.note-label.agent { +.re-note-label.agent { border-left: 3px solid #1877c0; } -.note-label.done { +.re-note-label.done { color: var(--dim); text-decoration: line-through; } -.note-label.away { +.re-note-label.away { opacity: 0.5; } /* A feature row whose number an agent had fitted to the scan: a quiet tag, the reading in its tooltip. */ .kv b.fitted { - font-size: 11px; + font-size: 12px; font-weight: 600; padding: 0 4px; border-radius: 3px; diff --git a/plugins/reverse/viewer/notePins.ts b/plugins/reverse/viewer/notePins.ts index ea77d4d2..b7b9cdb6 100644 --- a/plugins/reverse/viewer/notePins.ts +++ b/plugins/reverse/viewer/notePins.ts @@ -191,7 +191,7 @@ export class NotePins { const dot = new THREE.Sprite(spriteMaterial(this.dot)) dot.renderOrder = 10 const el = document.createElement('div') - el.className = 'note-label' + el.className = 're-note-label' el.dataset.test = 'note-label' const label = new CSS2DObject(el) label.center.set(0, 0.5) diff --git a/scripts/e2e-align-after-deviation.mjs b/scripts/e2e-align-after-deviation.mjs index d3bb4c9d..6c6c7f7d 100644 --- a/scripts/e2e-align-after-deviation.mjs +++ b/scripts/e2e-align-after-deviation.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end regression test for a datum alignment set up after a deviation // best fit: drives the real app in headless Chrome — loads the scan, loads // the reference in the deviation workspace and runs the automatic fit, goes @@ -145,7 +146,7 @@ const awaitStatus = (text, what) => await loadScan(page, SCAN, { timeout: 300_000 }) // The support card's coloured buttons would count as part. -await page.$('[data-test=support-card] .sc-x').then((b) => b?.click()) +await dismissSupportCard(page) // ---- best fit in the deviation workspace ---------------------------------- await page.click('[data-test=workspace-deviation]') diff --git a/scripts/e2e-ci.mjs b/scripts/e2e-ci.mjs index 238e8766..a177a806 100644 --- a/scripts/e2e-ci.mjs +++ b/scripts/e2e-ci.mjs @@ -73,11 +73,14 @@ try { }) try { await dev.listen() - for (const check of ['e2e-import', 'e2e-sample', 'e2e-small-feature', 'e2e-recovery', 'e2e-history', 'e2e-agent', 'e2e-agent-warning', 'e2e-units', ...pluginChecks()]) await run(check, dev.resolvedUrls.local[0]) + for (const check of ['e2e-measure-design', 'e2e-workspace-design', 'e2e-note-style', 'e2e-flat', 'e2e-import', 'e2e-modal', 'e2e-sample', 'e2e-small-feature', 'e2e-recovery', 'e2e-history', 'e2e-agent', 'e2e-agent-warning', 'e2e-units', ...pluginChecks()]) await run(check, dev.resolvedUrls.local[0]) } finally { await dev.close() } const production = await preview({ root, preview: { host: '127.0.0.1', port: 5194, strictPort: false, open: false } }) - try { await run('e2e-production', production.resolvedUrls.local[0]) } + try { + await run('e2e-production', production.resolvedUrls.local[0]) + await run('e2e-measure-production', production.resolvedUrls.local[0]) + } finally { await new Promise((resolve, reject) => { production.httpServer.close((error) => error ? reject(error) : resolve()) diff --git a/scripts/e2e-flat.mjs b/scripts/e2e-flat.mjs index dff771c9..46b99366 100644 --- a/scripts/e2e-flat.mjs +++ b/scripts/e2e-flat.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end smoke test for the 2D Measure workspace: drives the real app in // headless Chrome — generates a synthetic flatbed scan (no binary fixtures in // this repo), loads it through the panel, waits for edge detection, fits a @@ -63,7 +64,7 @@ await browser await click(page, '[data-test=workspace-flat]') // The support card overlays the lower stage, exactly where picks on the // fixture's rectangle land — close it before measuring through it. -await click(page, '[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) const input = await page.$('input[type=file][accept*=".png"]') await input.uploadFile(fixture) await page.waitForFunction( @@ -464,6 +465,8 @@ check( /^3 counted/.test(await page.$eval('[data-test=flat-count-status]', (el) => el.textContent)), 'undo takes one back', ) +// The store's count updates before the scene-sync effect paints its pins. +await page.waitForFunction(() => document.querySelectorAll('.pick-pin').length >= 3) check((await page.$$('.pick-pin')).length >= 3, 'the tally wears its numbers on the sheet') // Back on the sheet for a fourth; Enter then creates it, the way it does in // the 3D workspace. diff --git a/scripts/e2e-history.mjs b/scripts/e2e-history.mjs index cacdfef8..e6e5e92a 100644 --- a/scripts/e2e-history.mjs +++ b/scripts/e2e-history.mjs @@ -135,6 +135,8 @@ try { let workerTransforms = 0 const transform = MeshWorkerClient.prototype.transform MeshWorkerClient.prototype.transform = async function (...args) { workerTransforms++; return transform.apply(this, args) } + click('measure-align') + await new Promise((r) => setTimeout(r, 100)) click('start-manual') await pause() const field = document.querySelector('[data-test="manual-mx"]') diff --git a/scripts/e2e-lib.mjs b/scripts/e2e-lib.mjs index 683bf737..dce86c9c 100644 --- a/scripts/e2e-lib.mjs +++ b/scripts/e2e-lib.mjs @@ -189,7 +189,8 @@ export function sheetToScreen(rect, w, h) { /** Click a selector, waiting for it to exist first. */ export async function click(page, sel, { timeout = 10_000 } = {}) { await page.waitForSelector(sel, { timeout }) - await page.click(sel) + if (/data-test=["']?(mesh-tool-|feature-|flat-fit-)/.test(sel)) await clickTool(page, sel) + else await page.click(sel) } /** A switch in the menu over a part's key on the view bar (ui/PartKey), by @@ -245,6 +246,15 @@ export async function selectByLabel(page, sel, label) { await page.select(sel, value) } +/** Dismiss attribution through its visible controls. Measure nests the card + * in a disclosure; other workspaces display it directly. */ +export async function dismissSupportCard(page) { + if (!(await page.$('[data-test=support-card]'))) return + if (await page.$('.measure-support:not([open])')) await page.click('.measure-support > summary') + await page.click('[data-test=support-card] .sc-x') + await page.waitForSelector('[data-test=support-card]', { hidden: true }) +} + /** The settings dialog: open it, do something in it, close it again — so the * stage underneath is free for whatever the script does next. */ export async function withSettings(page, fn) { @@ -352,9 +362,9 @@ export async function fitBall(page, rect, farEnd, want, { ready } = {}) { * and left; then `settle` ms for the app to take it. */ export const fieldSetter = (page, { settle = 300 } = {}) => async (sel, value) => { await page.click(sel) - await page.keyboard.down(MOD) - await page.keyboard.press('KeyA') - await page.keyboard.up(MOD) + // Headless Chrome does not reliably apply the host's select-all binding + // to number inputs. Send its native editing command through the keyboard. + await page.keyboard.press('KeyA', { commands: ['selectAll'] }) await page.keyboard.press('Backspace') await page.type(sel, String(value)) await page.$eval(sel, (el) => el.blur()) @@ -490,3 +500,20 @@ export async function colouredFraction(page) { return painted / total }, shot) } + +/** Follow the visible More-tools disclosure before clicking a catalog tool. + * Only navigation changes; the real button and original assertions remain. */ +export async function clickTool(page, selector) { + await page.waitForSelector(selector) + for (;;) { + const summary = await page.$eval(selector, (el) => { + let parent = el.parentElement, outer = null + while (parent) { if (parent.tagName === 'DETAILS' && !parent.open) outer = parent; parent = parent.parentElement } + return outer ? Array.from(document.querySelectorAll('details > summary')).indexOf(outer.querySelector(':scope > summary')) : -1 + }) + if (summary < 0) break + const controls = await page.$$('details > summary') + await controls[summary].click() + } + await page.click(selector) +} diff --git a/scripts/e2e-measure-design.mjs b/scripts/e2e-measure-design.mjs new file mode 100644 index 00000000..41841e52 --- /dev/null +++ b/scripts/e2e-measure-design.mjs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Exercise the redesigned shell through real controls. Store reads only verify +// results; no geometry, drafts or selection are seeded behind the interface. +import assert from 'node:assert/strict' +import puppeteer from 'puppeteer-core' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { CHROME, APP_URL, OUT_DIR, fieldSetter } from './e2e-lib.mjs' +const out = join(OUT_DIR, 'measure-design') +mkdirSync(out, {recursive:true}) +const browser = await puppeteer.launch({executablePath:CHROME,headless:true,defaultViewport:{width:1440,height:900}}) +const page = await browser.newPage() +const errors=[] +page.on('pageerror',e=>errors.push(e.message)) +const pause = ms=>new Promise(r=>setTimeout(r,ms)) +const click = async selector=>{await page.waitForSelector(selector,{visible:true});await page.click(selector);await pause(150)} +const read = fn=>page.evaluate(async source=>{const s=(await import('/src/state/store.ts')).useStore.getState();return (new Function('s',`return (${source})(s)`))(s)},fn.toString()) +const record=[] +const check=(condition,label)=>{assert.ok(condition,label);record.push(label);console.log('PASS:',label)} +const screenshot=name=>page.screenshot({path:join(out,`${name}.png`)}) +async function rect(selector){return page.$eval(selector,el=>{const r=el.getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}})} +async function fitPlane(x,y){ + await click('[data-test=fit-plane]') + const r=await rect('.viewport canvas') + await page.mouse.click(r.x+r.w*x,r.y+r.h*y) + await page.waitForFunction(()=>document.querySelector('[data-test=create-element]')?.disabled===false) + await click('[data-test=create-element]');await page.keyboard.press('Escape');await pause(150) +} +async function noOverflow(label){check(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),label)} +try { + await page.goto(APP_URL,{waitUntil:'networkidle0'}) + await page.waitForSelector('[data-test=start-pane]') + await screenshot('01-desktop-start') + await click('[data-test=measure-search]') + await page.type('.measure-search input','hole') + await page.keyboard.press('ArrowDown') + check(await page.evaluate(()=>document.activeElement.textContent.includes('Open a scan first')), 'search keyboard focus explains missing prerequisites') + await page.keyboard.press('Enter') + check(await read(s=>s.draft===null),'unavailable search command cannot start a draft') + await page.keyboard.press('Escape') + await click('[data-test=open-measure-sample]') + await page.waitForFunction(()=>/3,072 triangles/.test(document.querySelector('.file-info')?.textContent??'')) + check(await read(s=>s.fileName==='measurement-sample-30x20x12mm.obj' && s.triangleCount===3072),'sample loads through the real import pipeline') + const stage=await rect('.stage') + check(stage.w*stage.h/(1440*900)>=.70,'canvas occupies at least 70% of desktop with context closed') + await noOverflow('desktop has no page overflow') + await screenshot('sample-before-fitting') + await fitPlane(.5,.42) + await screenshot('sample-after-first-plane') + await fitPlane(.5,.17) + console.log('Sample plane normals:', await read(s=>s.elements.map(e=>e.fit?.normal))) + check(await read(s=>s.elements.length===2 && s.elements.every(e=>e.fit?.kind==='plane')),'two measured planes created from canvas clicks') + const first='[role=treeitem]:first-child', second='[role=treeitem]:nth-child(2)' + const canvasPicks = await page.evaluate(async () => { + const {useStore} = await import('/src/state/store.ts') + const scene = window.__scanruler.scene() + const r = document.querySelector('.viewport canvas').getBoundingClientRect() + return useStore.getState().elements.map(({fit}) => { + const p = scene.camera.position.clone().set(...fit.center) + // Stay away from the center pin; this is a click on the fitted surface. + p.addScaledVector(p.clone().set(...fit.basisU), fit.extentU * .2) + p.project(scene.camera) + return [r.x + (p.x + 1) * r.width / 2, r.y + (1 - p.y) * r.height / 2] + }) + }) + await page.mouse.click(...canvasPicks[0]);await pause(150) + check(await page.$eval(first,el=>el.getAttribute('aria-selected')==='true'),'viewport click selects the matching project row') + await page.keyboard.down('Shift');await page.mouse.click(...canvasPicks[1]);await page.keyboard.up('Shift');await pause(150) + check(await read(s=>s.selectedElementIds.length===2),'viewport Shift-click shares additive selection with the tree') + await click(`${first} .name`) + check(await read(s=>s.selectedElementIds.length===1 && s.selectedElementIds[0]===s.elements[0].id),'tree selection uses measurement state') + await page.focus(first);await page.keyboard.press('ArrowDown') + check(await page.evaluate(()=>document.activeElement===document.querySelector('[role=treeitem]:nth-child(2)')),'tree arrows move focus') + await page.keyboard.down('Shift');await page.keyboard.press('Space');await page.keyboard.up('Shift') + check(await read(s=>s.selectedElementIds.length===2),'keyboard additive selection retains both planes') + check(await page.$$eval('[role=treeitem]',els=>els.filter(el=>el.tabIndex===0).length===1),'tree has one roving Tab stop') + await page.keyboard.press('Home');await page.keyboard.press('F2') + await page.keyboard.press('KeyA',{commands:['selectAll']});await page.keyboard.type('Top datum');await page.keyboard.press('Enter') + check(await read(s=>s.elements[0].name==='Top datum'),'F2 renames without changing geometry') + check(await page.evaluate(()=>document.activeElement?.getAttribute('role')==='treeitem'),'rename returns focus to the tree') + await click('[data-test=undo]');check(await read(s=>s.elements[0].name!=='Top datum'),'rename is undoable') + await click('[data-test=redo]');check(await read(s=>s.elements[0].name==='Top datum'),'rename is redoable') + await click(`${first} .eye`);check(await read(s=>!s.elements[0].visible),'visibility button hides the measured element') + await click(`${first} .eye`) + await click(`${first} [data-test=edit-element]`) + await page.waitForFunction(()=>document.querySelector('[data-test=create-element]')?.disabled===false) + await fieldSetter(page)('[data-test=extend-uMax]', '2.5') + const draftBefore=await read(s=>JSON.stringify(s.draft)) + await click('[data-test=measure-export]') + check(await page.$eval('[data-test=export-step]',el=>!el.disabled),'export remains available during an edit') + await page.keyboard.press('Escape') + check(await read(s=>JSON.stringify(s.draft))===draftBefore,'closing Export preserves the exact unfinished draft') + await click('[data-test=measure-search]') + await page.keyboard.press('Escape') + check(await read(s=>JSON.stringify(s.draft))===draftBefore,'closing search preserves the exact unfinished draft') + await click('[aria-label="Hide Edit element"]') + await click('.measure-actions .primary') + check(await read(s=>JSON.stringify(s.draft))===draftBefore,'hide and reopen preserves typed precision and draft') + await screenshot('02-desktop-edit') + await click('[data-test=create-element]') + check(await read(s=>s.elements[0].extend?.uMax===2.5),'exact 2.5 mm extension commits') + await click('[data-test=undo]');check(await read(s=>s.elements[0].extend?.uMax!==2.5),'extension undo restores previous geometry') + await click('[data-test=redo]');check(await read(s=>s.elements[0].extend?.uMax===2.5),'extension redo restores exact value') + await click('[data-test=new-dimension]') + await page.select('[data-test=dim-type]','angle-plane-plane') + const ids=await read(s=>s.elements.map(e=>String(e.id))) + await page.select('[data-test=dim-ref-0]',ids[0]);await page.select('[data-test=dim-ref-1]',ids[1]) + await click('[data-test=add-dimension]');await click('[data-test=cancel-dimension]') + check(await page.$eval('.project-measurements',el=>/90\.00(?:0)?/.test(el.textContent)),'orthogonal sample faces measure 90 degrees') + await page.focus('.dock-resize.project');await page.keyboard.press('ArrowRight') + check((await rect('.measure-project')).w===256,'keyboard splitter changes width by 8 px') + await page.keyboard.press('Home');check((await rect('.measure-project')).w===200,'splitter Home respects its minimum') + await page.keyboard.press('End');check((await rect('.measure-project')).w===360,'splitter End respects its maximum') + await page.keyboard.press('Home') + await click('[data-test=fit-plane]') + await page.setViewport({width:900,height:800});await pause(200) + await noOverflow('tablet has no page overflow') + check(await page.$eval('.measure-project',el=>getComputedStyle(el).display==='none'),'tablet shows one panel at a time') + await screenshot('03-tablet-tool') + await page.setViewport({width:390,height:844});await pause(200) + await noOverflow('phone has no page overflow') + const footer=await rect('.dock-actions'), commit=await rect('[data-test=create-element]') + check(footer.y>=0&&footer.y+footer.h<=844&&commit.h>=44,'phone commit footer remains reachable with 44 px target') + check(await page.$$eval('.dock-actions button',els=>els.every(el=>el.getBoundingClientRect().height>=44)), 'all phone footer actions meet the 44 px touch target') + await screenshot('04-phone-tool') + await page.setViewport({width:720,height:450});await pause(200) + await noOverflow('200% desktop-equivalent CSS viewport has no page overflow') + const smallFooter=await rect('.dock-actions') + check(smallFooter.y>=0&&smallFooter.y+smallFooter.h<=450,'commit remains reachable at 200% equivalent layout') + await screenshot('05-zoom-layout') + await page.emulateMediaFeatures([{name:'prefers-reduced-motion',value:'reduce'}]) + check(await page.$eval('.measure-actions button',el=>getComputedStyle(el).transitionDuration==='0s'),'reduced motion removes UI transitions') + await click('[data-test=cancel-draft]') + await page.setViewport({width:390,height:844});await pause(200) + await click('.measure-file-menu summary');await page.keyboard.press('Escape') + check(await page.$eval('.measure-file-menu',el=>!el.open),'File menu closes with Escape') + check(await page.evaluate(()=>document.activeElement===document.querySelector('.measure-file-menu summary')),'File menu restores trigger focus') + await screenshot('06-phone-project') + const fresh = await browser.createBrowserContext() + const phone = await fresh.newPage() + await phone.setViewport({width:390,height:844}) + await phone.goto(APP_URL,{waitUntil:'networkidle0'}) + await phone.waitForSelector('[data-test=open-measure-sample]') + check(await phone.$eval('[data-test=open-measure-sample]',el=>{const r=el.getBoundingClientRect();return r.x>=0&&r.right<=innerWidth&&r.y>=0&&r.bottom<=innerHeight}), 'fresh phone offers a reachable sample without opening a panel') + await phone.screenshot({path:join(out,'07-phone-start.png')}) + await fresh.close() + check(errors.length===0,`no uncaught browser errors: ${errors.join(', ')}`) + writeFileSync(join(out,'checks.json'),JSON.stringify(record,null,2)) +} catch(error){ + await screenshot('failure') + writeFileSync(join(out,'failure-layout.json'),JSON.stringify(await page.$$eval('body *',els=>els.filter(el=>el.getBoundingClientRect().right>innerWidth+1).map(el=>({tag:el.tagName,cls:el.className,test:el.dataset.test,x:el.getBoundingClientRect().x,width:el.getBoundingClientRect().width}))),null,2)) + throw error +} +finally {await browser.close()} diff --git a/scripts/e2e-measure-production.mjs b/scripts/e2e-measure-production.mjs new file mode 100644 index 00000000..5f7cc9fb --- /dev/null +++ b/scripts/e2e-measure-production.mjs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Public UI only: verify the bundled sample chunk and measurement path. +import assert from 'node:assert/strict' +import puppeteer from 'puppeteer-core' +import { mkdirSync } from 'node:fs' +import { CHROME, APP_URL, OUT_DIR } from './e2e-lib.mjs' +const out=OUT_DIR +mkdirSync(out,{recursive:true}) +const browser=await puppeteer.launch({executablePath:CHROME,headless:true}) +try { + for (const [name,width,height] of [['desktop',1440,900],['phone',390,844],['small-phone',320,568],['short-window',720,450]]) { + const context=await browser.createBrowserContext(),page=await context.newPage(),errors=[] + page.on('pageerror',e=>errors.push(e.message)) + await page.setViewport({width,height}) + await page.goto(APP_URL,{waitUntil:'networkidle0'}) + await page.waitForSelector('[data-test=open-measure-sample]') + await page.screenshot({path:`${out}/${name}-start.png`}) + await page.click('[data-test=open-measure-sample]') + await page.waitForFunction(()=>/3,072 triangles/.test(document.querySelector('.file-info')?.textContent??'')) + await page.click('[data-test=fit-plane]') + if (name==='desktop') { + const r=await page.$eval('.viewport canvas',el=>{const r=el.getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}}) + await page.mouse.click(r.x+r.w*.5,r.y+r.h*.42) + await page.waitForFunction(()=>document.querySelector('[data-test=create-element]')?.disabled===false) + await page.screenshot({path:`${out}/desktop-preview.png`}) + await page.click('[data-test=create-element]') + await page.keyboard.press('Escape') + await page.waitForSelector('[role=treeitem]') + assert.equal(await page.$$eval('[role=treeitem]',els=>els.length),1) + } + await page.screenshot({path:`${out}/${name}-sample.png`}) + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true) + assert.deepEqual(errors,[]) + console.log(`PASS: production ${name}: sample chunk loads, real mesh renders, visible controls and no browser errors`) + await context.close() + } +} finally {await browser.close()} diff --git a/scripts/e2e-modal.mjs b/scripts/e2e-modal.mjs new file mode 100644 index 00000000..263a08fc --- /dev/null +++ b/scripts/e2e-modal.mjs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Real keyboard focus across the app's modal windows, including a warning +// nested above Settings. Nonmodal tool windows remain outside this contract. +import { launchApp, check, click, fail, finish, shotPath } from './e2e-lib.mjs' + +const { browser, page, consoleErrors } = await launchApp({ agentDataOk: false }) +const settings = '[data-test=settings-modal]' +const warning = '[data-test=agent-data-warning]' +const units = '[data-test=units-modal]' +const inside = (selector) => page.$eval(selector, (el) => el.contains(document.activeElement)) +const activeIs = (selector) => page.$eval(selector, (el) => el === document.activeElement) +const turn = () => page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + +async function tabEdges(selector, name) { + const focusEdge = (last) => page.$eval(selector, (el, last) => { + const controls = [...el.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')] + .filter((c) => c.tabIndex >= 0 && !c.disabled && c.getClientRects().length > 0) + const target = last ? controls.at(-1) : controls[0] + if (!target) throw new Error('No focusable control in the test dialog') + target.focus() + }, last) + await focusEdge(true) + await page.keyboard.press('Tab') + check(await inside(selector), `${name}: Tab stays in the dialog after its last control`) + await focusEdge(false) + await page.keyboard.down('Shift') + try { await page.keyboard.press('Tab') } finally { await page.keyboard.up('Shift') } + check(await inside(selector), `${name}: Shift+Tab stays in the dialog before its first control`) + await page.focus('[data-test=open-settings]') + check(await inside(selector), `${name}: background controls cannot take focus`) +} + +async function askUnits() { + await page.evaluate(async () => { + const { askMeshUnits } = await import('/src/state/unitsPromptStore.ts') + window.__modalUnitsResult = 'pending' + void askMeshUnits('keyboard.stl').then((value) => { window.__modalUnitsResult = value }) + }) + await page.waitForSelector(units) + await turn() +} + +try { + await page.evaluate(async () => { + const { useStore } = await import('/src/state/store.ts') + useStore.getState().startDraft('plane') + useStore.getState().setDraftPicks([[1, 2, 3]]) + window.__modalDraft = useStore.getState().draft + }) + await click(page, '[data-test=open-settings]') + await page.waitForSelector(settings) + await turn() + check(await inside(settings), 'Settings receives focus when opened') + await page.screenshot({ path: shotPath('modal-settings.png') }) + await tabEdges(settings, 'Settings') + + await click(page, '[data-test=toggle-agent-link]') + await page.waitForSelector(warning) + await turn() + check(await inside(warning), 'the nested agent warning receives focus') + await tabEdges(warning, 'Agent warning') + await page.focus('[data-test=toggle-agent-link]') + check(await inside(warning), 'the underlying Settings dialog is also inactive') + await page.keyboard.press('Escape') + await page.waitForSelector(warning, { hidden: true }) + check(!!(await page.$(settings)), 'Escape closes only the top dialog') + check(await activeIs('[data-test=toggle-agent-link]'), 'nested dismissal restores focus to its Settings control') + await page.keyboard.press('Escape') + await page.waitForSelector(settings, { hidden: true }) + check(await activeIs('[data-test=open-settings]'), 'Settings restores focus to its opener') + check(await page.evaluate(async () => (await import('/src/state/store.ts')).useStore.getState().draft === window.__modalDraft), + 'closing nested dialogs leaves the underlying picked draft unchanged') + + await askUnits() + check(await inside(units), 'the units question receives focus') + await tabEdges(units, 'Units') + await page.focus('[data-test=units-cancel]') + await page.keyboard.press('Enter') + await page.waitForSelector(units, { hidden: true }) + check(await page.evaluate(() => window.__modalUnitsResult === null), 'Enter on Cancel cancels the units question') + await askUnits() + await page.focus('[data-test=units-in]') + await page.keyboard.press('Enter') + check(await page.evaluate(() => window.__modalUnitsResult === 'pending'), 'Enter on a unit selects it without opening the file') + check(await page.$eval('[data-test=units-in]', (el) => el.getAttribute('aria-checked') === 'true'), 'the focused unit is selected') + await page.focus('[data-test=units-open]') + await page.keyboard.press('Enter') + await page.waitForSelector(units, { hidden: true }) + check(await page.evaluate(() => window.__modalUnitsResult === 'in'), 'Enter on Open answers with the selected units') + + await click(page, '.stripimprint') + await page.waitForSelector('.modal.imprint') + await turn() + check(await inside('.modal.imprint'), 'Imprint receives focus') + await tabEdges('.modal.imprint', 'Imprint') + await page.keyboard.press('Escape') + await page.waitForSelector('.modal.imprint', { hidden: true }) + check(await activeIs('.stripimprint'), 'Imprint restores focus to its opener') + + await page.focus('[data-test=open-settings]') + await page.evaluate(async () => { + const { usePrefs } = await import('/src/state/prefsStore.ts') + const { useSupportAsk } = await import('/src/state/supportAskStore.ts') + usePrefs.setState({ supportCard: true, supportAsk: true, supportAskOffUntil: 0 }) + useSupportAsk.getState().ask() + }) + await page.waitForSelector('[data-test=support-ask]') + await turn() + check(await inside('[data-test=support-ask]'), 'the support dialog receives focus') + await tabEdges('[data-test=support-ask]', 'Support') + await page.keyboard.press('Escape') + await page.waitForSelector('[data-test=support-ask]', { hidden: true }) + check(await activeIs('[data-test=open-settings]'), 'the support dialog restores prior focus') + check(await page.evaluate(async () => (await import('/src/state/store.ts')).useStore.getState().draft === window.__modalDraft), + 'all modal interactions preserve the underlying draft') + + await click(page, '.panel .infodot') + await page.waitForSelector('.infocard') + await askUnits() + await page.keyboard.press('Escape') + await page.waitForSelector(units, { hidden: true }) + check(!!(await page.$('.infocard')), 'Escape in a modal leaves a background help popover open') + await page.keyboard.press('Escape') + await page.waitForSelector('.infocard', { hidden: true }) + check(await page.evaluate(async () => (await import('/src/state/store.ts')).useStore.getState().draft === window.__modalDraft), + 'the help popover still owns Escape after the modal closes') + + await page.evaluate(async () => { + const { useStore } = await import('/src/state/store.ts') + useStore.getState().cancelDraft() + const dataTransfer = new DataTransfer() + dataTransfer.items.add(new File(['v 0 0 0\nv 10 0 0\nv 0 10 0\nf 1 2 3\n'], 'modal-menu.obj')) + window.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true, cancelable: true })) + }) + await page.waitForFunction(() => /[1-9][\d,]* triangles/.test(document.querySelector('.file-info')?.textContent ?? '')) +await click(page, '[data-test=measure-export]') + await click(page, '[data-test=export-mesh]') + await page.waitForSelector('[data-test=export-mesh-stl]') + await askUnits() + await page.keyboard.press('Escape') + await page.waitForSelector(units, { hidden: true }) + check(await page.$eval('[data-test=export-mesh]', (el) => el.getAttribute('aria-expanded') === 'true'), 'Escape in a modal leaves a background export menu open') + await page.keyboard.press('Escape') + await page.waitForSelector('[data-test=export-mesh-stl]', { hidden: true }) + check(true, 'the export menu still owns Escape after the modal closes') +} catch (e) { fail(e.stack ?? String(e)) } +finally { await finish(browser, consoleErrors) } diff --git a/scripts/e2e-note-style.mjs b/scripts/e2e-note-style.mjs new file mode 100644 index 00000000..21586fad --- /dev/null +++ b/scripts/e2e-note-style.mjs @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Plugin note styles must not move a 2D note away from its clicked anchor. +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { buildPng } from './e2e-flat-fixture.mjs' +import { launchApp, check, finish, sleep, click } from './e2e-lib.mjs' +const fixture = join(mkdtempSync(join(tmpdir(), 'scanruler-note-style-')), 'sheet.png') +writeFileSync(fixture, buildPng()) +const { browser, page, consoleErrors } = await launchApp({ width: 1440, height: 900 }) +try { + await page.click('[data-test=workspace-flat]') + await (await page.$('input[type=file][accept*=".png"]')).uploadFile(fixture) + await page.waitForFunction(() => /chains found/.test(document.querySelector('[data-test=flat-edge-status]')?.textContent ?? '')) + await click(page, '[data-test=flat-fit-text]') + const at = await page.$eval('.viewslot:not([hidden]) .viewport canvas:not(.loupe)', el => { + const r = el.getBoundingClientRect() + return [r.x + r.width * .55, r.y + r.height * .4] + }) + await page.mouse.click(...at) + await page.waitForSelector('[data-test=flat-note-text]') + await page.type('[data-test=flat-note-text]', 'Placed here') + await sleep(150) + const anchor = await page.$eval('[data-test=flat-note-1]', el => { + const r = el.getBoundingClientRect() + return [r.left, r.bottom] + }) + const delta = anchor.map((v, i) => v - at[i]) + console.log(`Note anchor offset: x=${delta[0].toFixed(3)} px, y=${delta[1].toFixed(3)} px`) + check(delta.every(v => Math.abs(v) < 1), '2D note stays at its clicked lower-left anchor with reverse plugin styles loaded') +} finally { await finish(browser, consoleErrors) } diff --git a/scripts/e2e-pick-fit.mjs b/scripts/e2e-pick-fit.mjs index 7a0cc20f..11c41f28 100644 --- a/scripts/e2e-pick-fit.mjs +++ b/scripts/e2e-pick-fit.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end test for the three things that make a best fit recoverable when it // goes wrong: stopping one that is running, fitting the view back onto a model // that has been orbited off screen, and selecting the surface a picked-point fit @@ -116,7 +117,7 @@ const countText = () => // The support card sits over the top-right of the stage; closed up front, // because the framing checks below are read off the pixels underneath it. -await page.click('[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) // ---- both models ------------------------------------------------------------ await page.click('[data-test=workspace-deviation]') diff --git a/scripts/e2e-production.mjs b/scripts/e2e-production.mjs index 739b3ee6..e13c18ae 100644 --- a/scripts/e2e-production.mjs +++ b/scripts/e2e-production.mjs @@ -67,6 +67,7 @@ try { } archive['project.json'] = strToU8(JSON.stringify(manifest)) await drop(Array.from(zipSync(archive)), 'export-fixture.scanruler') + await page.click('[data-test="measure-export"]') await page.waitForFunction(() => document.querySelector('[data-test="export-step"]')?.disabled === false) await page.waitForFunction(() => !document.querySelector('[data-test="save-project"]').disabled) await page.evaluate(() => { diff --git a/scripts/e2e-section-shadow.mjs b/scripts/e2e-section-shadow.mjs index 7558140d..cbce3f33 100644 --- a/scripts/e2e-section-shadow.mjs +++ b/scripts/e2e-section-shadow.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end test of a section's shadow: on a generated block notched top, // front and bottom — the label printer of issue 13 — a shadow across the XY // plane is the block's whole 40 × 30 mm outline, where every cut through a @@ -107,7 +108,7 @@ await page.screenshot({ path: shotPath('e2e-shadow-3d.png') }) // ---- the 2D sheet and the DXF ------------------------------------------------ await click(page, '[data-test="workspace-flat"]') -await click(page, '[data-test="support-card"] .sc-x').catch(() => {}) +await dismissSupportCard(page) await page.waitForSelector('[data-test="flat-source"]', { timeout: 5_000 }) const source = await page.$eval('[data-test="flat-source"]', (el) => el.options[el.selectedIndex].text) check(/shadow onto XY plane/.test(source), `the shadow is the sheet’s source (${source})`) diff --git a/scripts/e2e-section-view.mjs b/scripts/e2e-section-view.mjs index 03ab0b50..a7294864 100644 --- a/scripts/e2e-section-view.mjs +++ b/scripts/e2e-section-view.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end test of the section view: the Section key cuts the part open // at a plane through its middle, square to the view, the half toward the // camera taken away. A closed part is closed where it is cut — a cap in the @@ -162,7 +163,7 @@ await browser.close() // ---- the reference, and the split view ------------------------------------------ ;({ browser, page, consoleErrors } = await launchApp()) -await page.click('[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) await page.click('[data-test=workspace-deviation]') await loadScan(page, CLOSED, { inputSelector: '[data-test=start-scan] input[type=file]', settle: 0 }) await (await page.$('[data-test=slot-reference] input[type=file]')).uploadFile(REFERENCE) diff --git a/scripts/e2e-section.mjs b/scripts/e2e-section.mjs index 997ba7b6..851f2fec 100644 --- a/scripts/e2e-section.mjs +++ b/scripts/e2e-section.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end test of sections: fits the two balls of the ball bar, runs a // line through their centres, cuts the scan with a plane across that line at // the far ball's centre, then measures the cut in the 2D workspace — the @@ -175,7 +176,7 @@ check((await page.$('[data-test="section-editor"]')) === null, 'the box closes o // ---- measured in the 2D workspace ------------------------------------------ await click(page, '[data-test="workspace-flat"]') -await click(page, '[data-test="support-card"] .sc-x').catch(() => {}) +await dismissSupportCard(page) await page.waitForSelector('[data-test="flat-source"]', { timeout: 5_000 }) const source = await page.$eval('[data-test="flat-source"]', (el) => el.options[el.selectedIndex].text) check(/^Section 1/.test(source), `the new section is the sheet’s source (${source})`) diff --git a/scripts/e2e-small-feature.mjs b/scripts/e2e-small-feature.mjs index d08d479d..63dc1af4 100644 --- a/scripts/e2e-small-feature.mjs +++ b/scripts/e2e-small-feature.mjs @@ -115,8 +115,20 @@ try { await page.screenshot({ path: shotPath(`${prefix}-0-bore.png`) }) // ---- a cylinder being made, its grips on the stage ------------------------------- - const onWall = [rect.x + rect.w * 0.5 + rect.h * 0.19, rect.y + rect.h * 0.5] + // Keep the same world-space ray when opening the tool resizes the canvas. + // A screen position captured before the panel opens points elsewhere after it. + const wallRay = await page.evaluate(({ w, h }) => { + const camera = window.__scanruler.scene().viewport.camera + return camera.position.clone().set(2 * h * 0.19 / w, 0, 0).unproject(camera).toArray() + }, rect) await click(page, '[data-test="fit-cylinder"]') + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + const onWall = await page.evaluate(ray => { + const scene = window.__scanruler.scene() + const p = scene.viewport.camera.position.clone().set(...ray).project(scene.viewport.camera) + const r = scene.viewport.renderer.domElement.getBoundingClientRect() + return [r.x + (p.x + 1) * r.width / 2, r.y + (1 - p.y) * r.height / 2] + }, wallRay) await page.mouse.move(onWall[0], onWall[1]) await sleep(100) await page.mouse.click(onWall[0], onWall[1]) diff --git a/scripts/e2e-spacemouse.mjs b/scripts/e2e-spacemouse.mjs index c4c09a28..ecd707d1 100644 --- a/scripts/e2e-spacemouse.mjs +++ b/scripts/e2e-spacemouse.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end check of the SpaceMouse: drives the real app in headless Chrome // with a stand-in puck behind navigator.getGamepads, and confirms the puck // pans, zooms, turns and rolls the main view the way the part should follow @@ -214,7 +215,7 @@ await closeSettings() // ---- the 2D workspace takes the puck, and gives it back --------------------- await click(page, '[data-test=workspace-flat]') await sleep(600) -await click(page, '[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) // A flatbed scan on the sheet, so a pan shows. const fixture = join(OUT_DIR, 'spacemouse-sheet.png') writeFileSync(fixture, buildPng()) diff --git a/scripts/e2e-spline.mjs b/scripts/e2e-spline.mjs index 4ea2c826..6b552429 100644 --- a/scripts/e2e-spline.mjs +++ b/scripts/e2e-spline.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end test of the spline in 2D Measure: drives the real app in // headless Chrome on the synthetic flatbed scan — clicks fit points along the // disc's edge (each snapping onto it), inserts one by clicking on the curve, @@ -39,7 +40,7 @@ await cdp.send('Browser.setDownloadBehavior', { }) await click(page, '[data-test=workspace-flat]') -await click(page, '[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) const input = await page.$('input[type=file][accept*=".png"]') await input.uploadFile(fixture) await page.waitForFunction( diff --git a/scripts/e2e-split.mjs b/scripts/e2e-split.mjs index 86feba19..8fdfb4bf 100644 --- a/scripts/e2e-split.mjs +++ b/scripts/e2e-split.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end smoke test for the two ways of *looking* at a deviation map: the // scan and the reference side by side in two viewports held in one pose, and the // colour plot switched off to leave the bare surface. @@ -141,7 +142,7 @@ async function bothHalves(when) { // The support card sits over the top-right of the stage — which is the reference // half once the split view is open. Closed up front, because everything below is // read off the pixels underneath it. -await page.click('[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) // ---- both switches are on the view bar, and dead until they would do something await page.click('[data-test=workspace-deviation]') diff --git a/scripts/e2e-surface.mjs b/scripts/e2e-surface.mjs index 6f6227d3..6f4c1169 100644 --- a/scripts/e2e-surface.mjs +++ b/scripts/e2e-surface.mjs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-only +import { dismissSupportCard } from './e2e-lib.mjs' // End-to-end check of the two surface switches in the status strip: TRANSPARENT // and MESH. Loads ballbar.stl, flips each switch, and reads the part back from // screenshots — it has to change when a switch goes on, change back when it @@ -34,7 +35,7 @@ const { browser, page, consoleErrors } = await launchApp() await loadScan(page, STL) // The support card sits over the lower-left of the stage, blue buttons and // all, right where the part is looked for. Closed up front. -await click(page, '[data-test=support-card] .sc-x').catch(() => {}) +await dismissSupportCard(page) await sleep(200) const rect = await canvasRect(page) const canvasClip = { x: rect.x, y: rect.y, width: rect.w, height: rect.h } diff --git a/scripts/e2e-units.mjs b/scripts/e2e-units.mjs index 09d0e4b7..19447a5d 100644 --- a/scripts/e2e-units.mjs +++ b/scripts/e2e-units.mjs @@ -174,6 +174,7 @@ await page.screenshot({ path: shotPath('e2e-units-inches.png') }) // ---- the scan as an STL in inches ------------------------------------------------ const downloads = mkdtempSync(join(tmpdir(), 'scanruler-units-stl-')) await downloadsTo(page, downloads) +await click(page, '[data-test=measure-export]') await click(page, '[data-test="export-mesh"]') await click(page, '[data-test="export-mesh-stl-in"]') const file = await awaitDownload(downloads, '.stl') diff --git a/scripts/e2e-workspace-design.mjs b/scripts/e2e-workspace-design.mjs new file mode 100644 index 00000000..c961513d --- /dev/null +++ b/scripts/e2e-workspace-design.mjs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Cross-workspace shell and scanner guidance through real controls. +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { launchApp, finish, check, canvasRect, OUT_DIR, clickTool, sleep, downloadsTo, awaitDownload, fieldSetter } from './e2e-lib.mjs' +const out=join(OUT_DIR,'workspace-design');mkdirSync(out,{recursive:true}) +const {browser,page,consoleErrors}=await launchApp({width:1440,height:900}) +const downloads=mkdtempSync(join(out,'downloads-'));await downloadsTo(page,downloads) +const records=[] +const click=async s=>{await page.waitForSelector(s,{visible:true});await page.click(s);await sleep(100)} +const guide=()=>page.$eval('[data-test=measurement-guide]',e=>e.textContent) +const metrics=()=>page.evaluate(()=>({workspace:document.querySelector('.app').dataset.workspace,theme:document.documentElement.dataset.theme,design:document.documentElement.dataset.design,top:document.querySelector('.top').getBoundingClientRect().height,rail:document.querySelector('.modeswitch').getBoundingClientRect().width,stage:getComputedStyle(document.documentElement).getPropertyValue('--stage').trim(),overflow:document.documentElement.scrollWidth>innerWidth})) +try{ + await click('[data-test=open-measure-guide]');check((await guide()).includes('Step 1 of 7'),'guide starts with the real import step') + await click('[data-test=open-measure-sample]');await page.waitForFunction(()=>/3,072 triangles/.test(document.querySelector('.file-info')?.textContent??'')) + check((await guide()).includes('Step 2 of 7'),'guide detects imported scan and asks for a scale check') + await click('[data-test=guide-scale-checked]');await click('[data-test=guide-task-0]') + await page.screenshot({path:join(out,'guided-fitting.png')}) + for(const y of [.42,.17]){ + await click('[data-test=guide-start-fit]');const r=await canvasRect(page);await page.mouse.click(r.x+r.w*.5,r.y+r.h*y) + await page.waitForFunction(()=>document.querySelector('[data-test=create-element]')?.disabled===false) + check((await guide()).includes('not a guarantee'),'guide explains that fit scatter is not scanner accuracy') + await click('[data-test=create-element]');await page.keyboard.press('Escape') + } + check((await guide()).includes('Step 5 of 7'),'two real fits advance to the dimension step') + await click('[data-test=guide-start-dimension]') + const ids=await page.$$eval('[data-test=dim-ref-0] option',es=>es.map(e=>e.value).filter(Boolean)) + await page.select('[data-test=dim-ref-0]',ids[0]);await page.select('[data-test=dim-ref-1]',ids[1]);await click('[data-test=add-dimension]');await page.keyboard.press('Escape') + check((await guide()).includes('Step 6 of 7'),'committed measurement advances to recovery practice') + await click('[data-test=undo]');check((await guide()).includes('Now use Redo'),'guide follows real undo') + await click('[data-test=redo]');await sleep(100);check((await guide()).includes('Step 7 of 7'),'guide follows real redo and explains saving') + await click('[data-test=save-project]');await page.waitForFunction(()=>document.querySelector('[data-test=measurement-guide]')?.textContent.includes('Project download started')) + const downloaded=await awaitDownload(downloads,'.scanruler') + check(Boolean(downloaded),'guide saves an actual editable project download') + if(await page.$('dialog[open]'))await page.keyboard.press('Escape') + page.once('dialog', dialog => dialog.accept()) + await (await page.$('[data-test=project-input]')).uploadFile(join(downloads,downloaded)) + await page.waitForFunction(()=>document.querySelector('.strip')?.textContent.includes('Project opened')) + check((await guide()).includes('Step 2 of 7'),'reopening a source with the same filename requires a fresh scale check') + await click('[data-test=guide-scale-checked]');await click('[data-test=guide-task-0]') + check((await guide()).includes('no undo history yet'),'guide explains recovery practice for a reopened project without history') + + const saved=await page.$eval('.project-measurements',e=>e.textContent) + for(const id of ['deviation','thickness','flat','reverse','mesh','elements']){ + await click(`[data-test=workspace-${id}]`);const m=await metrics();records.push(m) + check(m.design==='measure'&&m.top===48&&m.rail===56&&!m.overflow,`${id}: shared desktop shell remains in place`) + } + check((await page.$eval('.project-measurements',e=>e.textContent))===saved,'workspace round trip preserves the measured result') + await click('[data-test=workspace-thickness]') + await fieldSetter(page)('[data-test=thickness-max]', '40') + await click('[data-test=measure-thickness]') + await page.waitForSelector('[data-test=thickness-low]',{visible:true}) + const thickness=await page.$$eval('[data-test=thickness-low], [data-test=thickness-high]',es=>es.map(e=>Number(e.value))) + check(thickness.length===2&&thickness.every(v=>Number.isFinite(v)&&v>0&&v<=40),'thickness controls produce a finite wall map from the sample') + await click('[data-test=workspace-deviation]');await click('[data-test=source-element]') + const target=await page.$eval('[data-test=target-select]',e=>Array.from(e.options).find(o=>o.value)?.value) + await page.select('[data-test=target-select]',target) + await page.waitForSelector('[data-test=range-slider]',{visible:true}) + check(await page.$eval('[data-test=target-select]',e=>Boolean(e.value)),'deviation controls map the scan against its real fitted plane') + await click('[data-test=open-settings]');await click('[data-test=ui-theme-dark]');await click('[data-test=settings-close]') + for(const id of ['elements','deviation','thickness','flat','reverse','mesh']){ + await click(`[data-test=workspace-${id}]`);const m=await metrics();records.push(m) + check(m.stage==='#080c12'&&m.theme==='dark',`${id}: darker canvas theme persists`) + await page.screenshot({path:join(out,`dark-${id}.png`)}) + } + await clickTool(page,'[data-test=mesh-tool-smooth]') + check(await page.$eval('.dock-actions [data-test=smooth-scan]',e=>!e.disabled),'mesh action is in the persistent footer') + const setting=await page.$eval('[data-test=smooth-tool] input[type=number]',e=>e.value) + await click('[aria-label="Hide Mesh tools"]');await click('[data-test=workspace-tools]') + check(await page.$eval('[data-test=smooth-tool] input[type=number]',e=>e.value)===setting,'hiding the context panel preserves mesh tool settings') + await click('[data-test=measure-search]');await page.type('.command-search-dialog input','Keep sharp edges');await page.keyboard.press('ArrowDown');await page.keyboard.press('Enter');await sleep(100) + check(await page.evaluate(()=>document.activeElement?.getAttribute('data-test')==='smooth-edges'),'search opens and focuses the existing tool control') + for(const [width,height]of [[900,800],[390,844],[320,568],[720,450]]){ + await page.setViewport({width,height});await sleep(100) + for(const id of ['deviation','thickness','flat','reverse','mesh']){ + await click(`[data-test=workspace-${id}]`);const m=await metrics();records.push({width,height,...m});check(!m.overflow&&m.top===48,`${id}: no page overflow at ${width}×${height}`) + await page.screenshot({path:join(out,`${width}-${id}.png`)}) + } + } + assert.equal(consoleErrors.length,0) + writeFileSync(join(out,'metrics.json'),JSON.stringify(records,null,2)) +}finally{await finish(browser,consoleErrors)} diff --git a/src/App.tsx b/src/App.tsx index 0128fbee..650479fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,8 @@ import { dimensionRefsOf, isShadow } from './state/sectionModel' import type { GripSide, SceneManager, PickHit } from './viewer/SceneManager' import { isCoreSide } from './viewer/extendGrips' import { schemeById } from './viewer/navSchemes' +import { useMeasureShell } from './ui/MeasurePanels' +import { CommandSearch } from './ui/CommandSearch' import { sceneTheme } from './viewer/viewThemes' import { Viewer } from './ui/Viewer' import { Panel } from './ui/Panel' @@ -813,7 +815,7 @@ export default function App() { /** A viewport click that landed on an existing element: hand it to whichever * editor is collecting references — the dimension draft, or a construction * draft's slots. Clicking an element that is already used takes it out. */ - const handleElementPick = (id: number, clientX = 0, clientY = 0) => { + const handleElementPick = (id: number, clientX = 0, clientY = 0, additive = false) => { if (commandRunning()) return const store = useStore.getState() // A section's cut, clicked while a dimension takes it as a plane. @@ -893,6 +895,7 @@ export default function App() { return } if (store.dimDraft) store.selectDimensionElement(id) + else store.selectElement(id, additive) } /** Re-open an element in the box it was created in. Everything it was made @@ -1228,6 +1231,8 @@ export default function App() { const marking = useDeviation((s) => s.marking) const markGesture = useMark((s) => s.gesture) const workspace = useShell((s) => s.workspace) + useMeasureShell(true) + const searchOpen = useShell((s) => s.searchOpen) const range = useDeviation((s) => s.range) const maxDistance = useDeviation((s) => s.maxDistance) const bands = useDeviation((s) => s.bands) @@ -1545,7 +1550,7 @@ export default function App() { .catch((e: unknown) => useStore.setState({ errorText: e instanceof Error ? e.message : String(e) })) return ( -
+
void newProject()} @@ -1555,6 +1560,7 @@ export default function App() { onOpenImage={openImage} savable={(fileName !== null && vertexCountLoaded) || flatImageName !== null || runtimes.some((r) => r.canSave)} /> + {searchOpen && }
@@ -1762,6 +1768,10 @@ export default function App() { {needsModels && !picking && !onFlat && active?.startPane !== null && ( { + // units-ok: the filename records the sample's fixed source units, not display units. + void import('./core/sampleScan').then(({ measurementSample }) => openFile(new File([measurementSample()], 'measurement-sample-30x20x12mm.obj'))) + } } : undefined} title={ active?.startPane ? active.startPane.title @@ -1771,7 +1781,7 @@ export default function App() { : 'Deviation from a nominal part' : onThickness ? 'Wall thickness' - : 'Fitting elements' + : 'Measure your scan' } blurb={ active?.startPane @@ -1782,7 +1792,7 @@ export default function App() { : 'Load both, then best-fit the scan onto the reference and read the difference off the part. Scan and reference as STL, PLY, OBJ, 3MF in millimetres or a STEP file straight from CAD — everything stays in this browser.' : onThickness ? 'Load a scan and measure how thick its walls are, everywhere at once. No reference model, no alignment. STL, PLY, OBJ, 3MF or STEP, in millimetres — everything stays in this browser.' - : 'Load a scan, then pick features on it to fit spheres, cylinders and planes and measure between them. STL, PLY, OBJ, 3MF or STEP, in millimetres — everything stays in this browser.' + : 'Open a scan to measure holes, flat faces and distances. Select the shape that matches a feature, check its fit, then create a dimension. Your files stay in this browser.' } slots={startSlots} onOpenSample={openSample} @@ -1797,7 +1807,7 @@ export default function App() { the stage and both go with it; the 2D sheet has no surface for the bar's switches to act on. */}
- {!picking && } + {!picking && } {!picking && !onFlat && sceneRef.current?.viewDirection() ?? [0, 0, -1]} stageCentre={() => sceneRef.current?.stageCentre() ?? [0, 0, 0]} />}
{/* With no card on the stage any more, the step that is still diff --git a/src/app/flatSheet.ts b/src/app/flatSheet.ts index f22780b8..732fca3e 100644 --- a/src/app/flatSheet.ts +++ b/src/app/flatSheet.ts @@ -70,6 +70,7 @@ export interface SheetElement { color: string name: string value: string + width?: number } export interface SheetDraft { @@ -193,7 +194,7 @@ export function sheetDatumPreview( * `units` on a sheet in millimetres. An element open for editing is drawn * by its draft instead. */ export function sheetElements( - s: Pick, + s: Pick & { selectedElementIds?: number[] }, units: DisplayUnits = 'mm', ): SheetElement[] { // units-ok: the sheet's document unit, an id formatFlatPrimary takes @@ -203,7 +204,8 @@ export function sheetElements( .filter((e) => e.visible && e.fit && e.id !== s.draft?.editId) .map((e) => ({ fit: e.fit!, - color: e.color, + color: s.selectedElementIds?.includes(e.id) ? '#246bfd' : e.color, + width: s.selectedElementIds?.includes(e.id) ? 3 : undefined, name: e.name, value: formatFlatPrimary(fitInFrame(e.fit!, frame), unit, units), })) diff --git a/src/app/useFlatSceneSync.ts b/src/app/useFlatSceneSync.ts index 8ddf262c..a2c5f779 100644 --- a/src/app/useFlatSceneSync.ts +++ b/src/app/useFlatSceneSync.ts @@ -133,9 +133,10 @@ export function useFlatSceneSync({ // The readings on the pins follow the alignment, and its handedness // follows the mirror. + const selectedElementIds = useFlat(s => s.selectedElementIds) const draft = useFlat((s) => s.draft) const mirror = useFlat((s) => s.mirror) - useEffect(pushElements, [elements, draft, scale, datum, mirror, units]) + useEffect(pushElements, [elements, selectedElementIds, draft, scale, datum, mirror, units]) // The sheet aligned to the part, mirrored, or turned on the stage — the // camera moves, nothing is redrawn. The alignment's direction is read at diff --git a/src/app/useGlobalShortcuts.ts b/src/app/useGlobalShortcuts.ts index 406db47f..4f1f9af0 100644 --- a/src/app/useGlobalShortcuts.ts +++ b/src/app/useGlobalShortcuts.ts @@ -118,6 +118,7 @@ export function useGlobalShortcuts({ // from standing the gesture down to discarding the draft, marking and // all, which is exactly the trap the two steps exist to avoid. if (e.repeat) return + if (e.defaultPrevented) return if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) { e.preventDefault() void (e.key.toLowerCase() === 'y' || e.shiftKey ? redo() : undo()) @@ -150,6 +151,13 @@ export function useGlobalShortcuts({ } // A plugin's workspace has the keys first. if (pluginKeys()?.keydown?.(e, { confirmButton })) return + // Browsing tools or export never discards the measurement being edited. + const shell = useShell.getState() + if (shell.workspace === 'elements' && (shell.measurePanel === 'search' || shell.measurePanel === 'export')) { + if (e.key === 'Escape') { e.preventDefault(); shell.setMeasurePanel(null) } + return + } + // A best fit that is running owns Escape, wherever it was started from: // it is the one thing on screen that cannot be waited out, and stopping // it costs nothing — the alignment in hand is kept and no measurement is @@ -229,6 +237,10 @@ export function useGlobalShortcuts({ else if (e.key === 'Enter' && dimensionReady()) store.commitDimension() return } + if (e.key === 'Escape' && useShell.getState().workspace === 'elements') { + store.selectElement(null) + useShell.getState().setMeasurePanel(null) + } if (e.key === 'Enter') confirmButton()?.click() } /** What a confirm would land on right now, if anything. */ diff --git a/src/app/useProject.ts b/src/app/useProject.ts index b6f6dcb0..4c0e82cc 100644 --- a/src/app/useProject.ts +++ b/src/app/useProject.ts @@ -10,6 +10,7 @@ import type { ElementKind } from '../core/types' import type { MeshWorkerClient } from '../core/workerClient' import type { SceneManager } from '../viewer/SceneManager' import { useStore } from '../state/store' +import { useShell } from '../state/shellStore' import { creaseSetting } from '../core/geometry/crease' import { useDeviation } from '../state/deviationStore' import { useThickness } from '../state/thicknessStore' @@ -97,7 +98,10 @@ export function useProject({ }) const saveProject = () => packProject().then((packed) => { - if ('bytes' in packed) saveFile(packed.name, new Blob([packed.bytes as BlobPart], { type: 'application/zip' })) + if ('bytes' in packed) { + saveFile(packed.name, new Blob([packed.bytes as BlobPart], { type: 'application/zip' })) + useShell.getState().noteSaveDownload() + } }) /** Open a project in place of the session — after asking, when there is diff --git a/src/app/useSceneSync.ts b/src/app/useSceneSync.ts index bb7b4cf4..0f4ed1a3 100644 --- a/src/app/useSceneSync.ts +++ b/src/app/useSceneSync.ts @@ -297,9 +297,11 @@ export function useSceneSync({ const dimDraft = useStore((s) => s.dimDraft) const alignDraft = useStore((s) => s.alignDraft) const sectionDraft = useStore((s) => s.sectionDraft) + const selectedElementIds = useStore((s) => s.selectedElementIds) // Elements referenced by the dimension, construction, alignment or section // being built read as selected in the viewport, however they were chosen. const highlightIds = [ + ...(elementsWorkspace ? selectedElementIds : []), ...(dimDraft?.refs ?? []), ...(draft?.refs ?? []), ...(alignDraft ? [alignDraft.primary, alignDraft.secondary, alignDraft.origin] : []), @@ -654,15 +656,19 @@ export function useSceneSync({ // outside the measure workspace, and a hidden region owns no clicks. const wantsElementPicks = candidates || + (elementsWorkspace && !draft && !dimDraft && !alignDraft && !sectionDraft) || (elementsWorkspace && (((dimDraft !== null || sectionDraft !== null || (alignDraft !== null && alignDraft.pickSlot === null)) && draft === null) || (draft !== null && creationMethod(draft.kind, draft.method).mode === 'construct'))) + // A plugin with an element-pick handler controls its own pick modes. The + // core effect must not overwrite those when leaving an idle Measure view. + const pluginPicksElements = Boolean(plugin?.elementPick) useEffect(() => { - sceneRef.current?.setElementPickEnabled(wantsElementPicks) - }, [wantsElementPicks]) + if (!pluginPicksElements) sceneRef.current?.setElementPickEnabled(wantsElementPicks) + }, [wantsElementPicks, pluginPicksElements]) // A section being made lights the element under the cursor, as it lights // the coordinate planes on offer: either is what a click would cut along. const sectionLights = elementsWorkspace && sectionDraft !== null && draft === null diff --git a/src/core/flat/snap.ts b/src/core/flat/snap.ts index 639c2b82..65016737 100644 --- a/src/core/flat/snap.ts +++ b/src/core/flat/snap.ts @@ -134,6 +134,8 @@ export class EdgeIndex { /** What the stage knows about a click besides where it landed. */ export interface PickMeta { + shift?: boolean + ctrl?: boolean /** Alt held — inverts the snap setting for this one pick. */ alt: boolean /** Document units per screen pixel: how far zoomed in the sheet is. */ diff --git a/src/core/sampleScan.ts b/src/core/sampleScan.ts new file mode 100644 index 00000000..d4c3775c --- /dev/null +++ b/src/core/sampleScan.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/** An original, deterministic 30 × 20 × 12 mm inspection block. Dense planar + * triangles exercise the normal scan/fit pipeline, with no CAD dependency. + * This ideal geometry is a learning sample, not scanner-accuracy evidence. */ +export function measurementSample(): string { + // units-ok: OBJ source metadata declares the generated coordinates' fixed units. + const lines = ['# ScanRuler measurement sample: 30 x 20 x 12 mm; ideal geometry'] + const faces = [ + { origin: [0,0,0], u: [0,20,0], v: [30,0,0] }, + { origin: [0,0,12], u: [30,0,0], v: [0,20,0] }, + { origin: [0,0,0], u: [30,0,0], v: [0,0,12] }, + { origin: [0,20,0], u: [0,0,12], v: [30,0,0] }, + { origin: [0,0,0], u: [0,0,12], v: [0,20,0] }, + { origin: [30,0,0], u: [0,20,0], v: [0,0,12] }, + ] + const cells = 16, stride = cells + 1 + faces.forEach(({origin,u,v}, face) => { + const base = face * stride * stride + 1 + for (let y = 0; y <= cells; y++) for (let x = 0; x <= cells; x++) { + lines.push(`v ${origin.map((a,i) => a + u[i] * x / cells + v[i] * y / cells).join(' ')}`) + } + for (let y = 0; y < cells; y++) for (let x = 0; x < cells; x++) { + const a = base + y * stride + x + lines.push(`f ${a} ${a+1} ${a+stride}`, `f ${a+1} ${a+stride+1} ${a+stride}`) + } + }) + return lines.join('\n') + '\n' +} diff --git a/src/main.tsx b/src/main.tsx index 5cb9fcce..70a3c65d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ // The app's stylesheet first: a plugin's own stylesheet, loaded with the // plugin, builds on it and has to come after it. import './styles.css' +import './measure.css' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' diff --git a/src/measure.css b/src/measure.css new file mode 100644 index 00000000..dae6fa3a --- /dev/null +++ b/src/measure.css @@ -0,0 +1,249 @@ +/* SPDX-License-Identifier: AGPL-3.0-only */ +/* Shared workspace shell. Tokens follow the supplied new-design-system + contract; every workspace supplies its own project and active-tool content. */ +:root[data-design='measure'] { + --chassis: #f5f7fa; --panel: #fff; --well: #fff; --well-deep: #edf1f6; + --line: #d4dce5; --line-strong: #a7b3c2; --ink: #182230; --dim: #526071; + --faint: #647386; --accent: #246bfd; --accent-d: #1754d1; --accent-soft: #e8f0ff; + --green: #16794c; --red: #c73535; --amber: #9a5b00; --stage: #e9edf2; + --strip: #f5f7fa; --glass: 255,255,255; + --ui: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --cond: var(--ui); --mono: 'JetBrains Mono', SFMono-Regular, Consolas, monospace; + --r: 7px; --inset: none; color-scheme: light; +} +:root[data-design='measure'][data-theme='dark'] { + --chassis: #0c1017; --panel: #121820; --well: #0d1219; --well-deep: #1a2430; + --line: #364454; --line-strong: #718399; --ink: #f5f7fa; --dim: #c0cbd8; + --faint: #acb8c8; --accent: #8bb4ff; --accent-d: #b4ceff; --accent-soft: #283f62; + --green: #80d6aa; --red: #ffaaa5; --amber: #f5ca7d; --stage: #080c12; + --strip: #0c1017; --glass: 18,24,32; color-scheme: dark; +} +:root[data-design='measure'] body { font: 14px/1.45 var(--ui); } +:root[data-design='measure'] :is(button,input,select,textarea,summary) { font-family: var(--ui); font-size: 14px; } +:root[data-design='measure'] :is(button,input,select,textarea) { min-height: 36px; border-radius: 7px; box-shadow: none; } +:root[data-design='measure'] :is(button,input,select,textarea,summary,[tabindex]):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +:root[data-design='measure'] button { background-image: none; } +:root[data-design='measure'] button.primary { background: var(--accent); border-color: var(--accent); color: white; } +:root[data-design='measure'][data-theme='dark'] button.primary { color: #182230; } +:root[data-design='measure'] :is(.sec-head,.g-label,.dro-label,.dro-note,.hint,.field > span,.starttile-role,.startfoot,.strip,.kv,.kindrow button,.setting,.settingsnav button) { font-family: var(--ui); text-transform: none; letter-spacing: normal; font-size: 12px; } +:root[data-design='measure'] :is(.sec-head,.g-label) { font-size: 13px; font-weight: 600; } +:root[data-design='measure'] .field { gap: 8px; } +:root[data-design='measure'] .field > span { font-size: 13px; } +:root[data-design='measure'] .infodot { min-width: 24px; width: 24px; height: 24px; min-height: 24px; font-size: 12px; } +:root[data-design='measure'] .hint { color: var(--dim); line-height: 1.5; } +:root[data-design='measure'] .pulse { animation: none; } +:root[data-design='measure'] .dro-window { background: var(--well-deep); border: 1px solid var(--line); padding: 12px; box-shadow: none; } +:root[data-design='measure'] .dro-window b { font-variant-numeric: tabular-nums; } +.design-measure .top { height: 48px; min-height: 48px; padding: 0 12px; gap: 8px; background: var(--panel); border-bottom: 1px solid var(--line); overflow: visible; flex-wrap: nowrap; } +.design-measure .top .brandmark { width: 28px; height: 28px; } +.design-measure .top .brand { min-width: auto; } +.design-measure .top .brand b { font-size: 15px; } +.design-measure .top .brand > span, .design-measure .top .partchip { display: none; } +.design-measure .top .projectname { display: block; width: 180px; min-width: 70px; max-width: 24vw; font-size: 13px; font-style: normal; background: transparent; } +.design-measure .top .modeswitch { position: fixed; z-index: 15; left: 0; top: 48px; bottom: 28px; width: 56px; padding: 12px 4px; gap: 8px; background: var(--chassis); border: 0; border-right: 1px solid var(--line); border-radius: 0; display: flex; flex-direction: column; justify-content: flex-start; overflow-y: auto; } +.design-measure .modeswitch button { width: 44px; min-height: 44px; border: 1px solid transparent; border-radius: 7px; padding: 10px; flex: 0 0 44px; } +.design-measure .modeswitch button svg { width: 20px; height: 20px; } +.design-measure .modeswitch button .wtxt { display: none; } +.design-measure .modeswitch button.on { color: var(--accent); background: var(--accent-soft); border-color: var(--accent); } +.design-measure .top .btxt { display: inline; } +.design-measure .top .ptxt { display: none; } +.design-measure .top .project-save-status { display: flex; font-size: 12px; max-width: 140px; color: var(--dim); } +.design-measure .top .project-save-status small { font-size: 12px; } +.measure-search-key { display: flex; align-items: center; gap: 8px; white-space: nowrap; } +.measure-search-key kbd { font: 12px var(--ui); color: var(--dim); border: 1px solid var(--line); border-radius: 4px; padding: 1px 4px; } +.measure-file-menu { position: relative; } +.measure-file-menu summary { list-style: none; cursor: pointer; padding: 8px; border-radius: 7px; } +.measure-file-menu > div { position: absolute; z-index: 50; top: 44px; right: 0; width: 220px; padding: 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 6px 20px rgb(24 34 48 / 12%); display: grid; gap: 4px; } +.measure-file-menu button { text-align: left; } +.design-measure .mid { display: grid; grid-template-columns: 56px auto minmax(0,1fr) auto; grid-template-rows: 56px minmax(0,1fr); position: relative; min-height: 0; } +.design-measure .stage { grid-column: 3; grid-row: 1 / -1; min-width: 0; min-height: 0; width: 100%; height: 100%; } +.design-measure .measure-project { grid-column: 2; grid-row: 1 / -1; border-right: 1px solid var(--line); } +.design-measure .measure-context { grid-column: 4; grid-row: 1 / -1; border-left: 1px solid var(--line); } +.design-measure .panel { position: relative; width: var(--dock-width); min-width: 0; padding: 0; gap: 0; display: flex; flex-direction: column; overflow: visible; background: var(--panel); } +.design-measure [hidden] { display: none !important; } +.dock-heading { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; gap: 8px; border-bottom: 1px solid var(--line); flex-shrink: 0; } +.dock-heading h2 { font: 600 15px/22px var(--ui); margin: 0; } +.dock-heading .dock-close { border: 0; min-width: 32px; min-height: 32px; display: grid; place-items: center; padding: 4px; } +.dock-scroll { padding: 16px; min-height: 0; overflow-y: auto; overflow-x: hidden; flex: 1; scrollbar-gutter: stable; } +.dock-scroll .group { padding: 0; margin: 0 0 20px; border: 0; } +.dock-actions:empty { display: none; } +.dock-actions { padding: 12px 16px; flex-shrink: 0; border-top: 1px solid var(--line); background: var(--panel); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.dock-actions > .primary { order: 2; flex: 1; width: auto; margin: 0; } +.dock-actions .toolrow { order: 1; display: contents; } +.dock-actions button { margin: 0; } +.dock-actions .toolrow button { flex: 0 1 auto; } +.dock-resize { position: absolute; top: 0; bottom: 0; width: 8px; cursor: col-resize; touch-action: none; z-index: 12; } +.dock-resize.project { right: -4px; } +.dock-resize.context { left: -4px; } +.dock-resize:hover { background: var(--accent-soft); } +.dock-resize:focus-visible { background: var(--accent-soft); } +.measure-actions { grid-column: 3; grid-row: 1; z-index: 10; display: flex; align-self: start; justify-self: start; align-items: center; gap: 4px; margin: 12px; padding: 4px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; max-width: calc(100% - 24px); overflow-x: auto; } +.measure-actions button { display: flex; align-items: center; gap: 6px; border-color: transparent; white-space: nowrap; padding: 4px 8px; } +.measure-actions button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); } +.project-commands { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 20px; } +.project-commands button { flex: 1; font-size: 12px; padding: 4px 6px; } +.section-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 20px 0 8px; } +.section-title h3 { font-size: 13px; margin: 0; font-weight: 600; } +.section-title button { min-height: 28px; padding: 2px 8px; font-size: 12px; } +.measure-tree .kv { display: grid; grid-template-columns: 20px minmax(0,1fr) 28px; gap: 4px; padding: 8px; margin: 0 -8px 4px; border: 1px solid transparent; border-radius: 7px; cursor: pointer; min-height: 48px; background: transparent; } +.measure-tree .kv.sel { border-color: var(--accent); background: var(--accent-soft); } +.measure-tree .kv .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; } +.measure-tree .kv > b { grid-column: 2; grid-row: 2; font: 12px var(--mono); color: var(--dim); } +.measure-tree .kv .kind { grid-column: 1; grid-row: 1; } +.measure-tree .kv .eye { grid-column: 3; grid-row: 1; } +.measure-tree .kv .edit { grid-column: 1; grid-row: 2; } +.measure-tree .kv > .x:last-child { grid-column: 3; grid-row: 2; } +.measure-tree .kv .x { min-height: 24px; width: 24px; min-width: 24px; } +.measure-tree .kv input { grid-column: 2; min-width: 0; width: 100%; } +.design-measure .draftbox { border: 0; border-radius: 0; padding: 0; margin: 0 0 20px; background: transparent; } +.design-measure .draftbox .sec-head { margin-bottom: 12px; } +.design-measure .featurekeys { gap: 8px; } +.design-measure .featurekeys button { min-height: 64px; } +.design-measure .dro.hero { background: var(--chassis); border: 1px solid var(--line); border-radius: 7px; padding: 12px; margin: 0 0 8px; } +.design-measure .dro.hero .dro-label { flex-wrap: wrap; gap: 4px; } +.design-measure .dro.hero .dro-tools { width: 100%; } +.design-measure .dro.hero .dro-title { flex: 1; } +.design-measure .dro.hero .dro-window { padding: 8px 0; border: 0; background: transparent; } +.tolerance-disclosure { border-top: 1px solid var(--line); padding-top: 12px; margin-top: 16px; } +.tolerance-disclosure summary { font-size: 12px; color: var(--dim); cursor: pointer; min-height: 32px; } +.selection-actions { border-top: 1px solid var(--line); padding-top: 12px; display: grid; gap: 8px; } +.selection-actions p { font-size: 12px; margin: 0; color: var(--dim); } +.measure-search .field { display: grid; } +.search-results { display: grid; gap: 4px; margin-top: 12px; } +.search-results button { text-align: left; padding: 12px; display: grid; gap: 4px; } +.search-results small { font-size: 12px; color: var(--dim); } +.search-results .search-active { border-color: var(--accent); background: var(--accent-soft); } +.design-measure .strip { min-height: 28px; height: 28px; background: var(--panel); color: var(--dim); border-top: 1px solid var(--line); font: 12px var(--ui); } +.design-measure .strip > div { border-color: var(--line); } +.design-measure .strip a, .design-measure .strip button { color: var(--dim); font: 12px var(--ui); } +.design-measure .strip button { min-height: 24px; } +.design-measure .stagecorner .supportcard { max-width: 260px; } +.design-measure .hintchip { top: 76px; bottom: auto; border: 1px solid var(--line); border-radius: 7px; font: 12px/18px var(--ui); max-width: calc(100% - 24px); } +.design-measure .startpane { max-width: none; justify-content: safe center; overflow-y: auto; padding: 76px 24px 112px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: none; } +.design-measure .startpane > * { flex-shrink: 0; } +.design-measure .starthead b { font: 600 28px/36px var(--ui); text-transform: none; } +.design-measure .starthead span { font: 14px/20px var(--ui); color: var(--dim); } +.design-measure .starttile { border-radius: 7px; background: var(--chassis); } +.design-measure .starttile-what { font-size: 14px; } +.design-measure .startfoot { line-height: 18px; } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +@media (max-width: 1199px) { + .design-measure .top .brand, .design-measure .top .project-save-status, .measure-search-key span, .measure-search-key kbd { display: none; } + .design-measure .measure-context { position: absolute; grid-column: 1 / -1; grid-row: 1 / -1; right: 0; top: 0; bottom: 0; z-index: 20; box-shadow: -6px 0 20px rgb(24 34 48 / 10%); } + .design-measure .mid:has(.measure-context:not([hidden])) .measure-project { display: none; } + .design-measure .mid { grid-template-columns: 56px auto minmax(0,1fr) 0; } + .design-measure .dock-resize { display: none; } +} +@media (max-width: 767px) { + :root[data-design='measure'] :is(button,input,select,summary) { min-height: 44px; } + .design-measure .top { padding: 0 8px; gap: 4px; height: 48px; min-height: 48px; } + .design-measure .top .brandmark { width: 24px; height: 24px; } + .design-measure .top .projectname { width: 90px; max-width: none; flex: 1; min-width: 40px; padding: 4px; } + .design-measure .top :is(.btxt,.iconbtn,.measure-search-key,[data-test='new-project'],[data-test='load-project']) { display: none; } + .design-measure .top .undokeys button { min-width: 44px; min-height: 44px; } + .design-measure .top > button { min-width: 44px; padding: 8px; } + .design-measure .top .modeswitch { width: 48px; padding: 8px 2px; } + .design-measure .mid { grid-template-columns: 48px 0 minmax(0,1fr) 0; } + .design-measure .measure-project, .design-measure .measure-context { position: absolute; grid-column: 1 / -1; grid-row: 1 / -1; top: auto; bottom: 0; left: 48px; right: 0; width: auto; height: min(54%, 440px); min-height: 180px; z-index: 20; border: 1px solid var(--line); border-bottom: 0; border-radius: 10px 10px 0 0; box-shadow: 0 -6px 20px rgb(24 34 48 / 10%); } + .design-measure .measure-context { z-index: 21; } + .dock-heading { padding: 4px 12px; } + .dock-scroll { padding: 12px; } + .dock-actions { padding: 8px 12px max(8px, env(safe-area-inset-bottom)); gap: 4px; } + .dock-actions button { padding: 4px 8px; } + .dock-actions > .primary { flex: 1 0 100%; } + .dock-actions .toolrow button { flex: 1; } + .measure-actions { margin: 8px; max-width: calc(100% - 16px); border-radius: 7px; padding: 2px; gap: 2px; } + .measure-actions button { min-width: 44px; } + .measure-actions button svg { display: none; } + .design-measure .strip > div:not(.msg), .design-measure .strip a, .design-measure .stripimprint { display: none; } + .design-measure .strip .msg { font-size: 12px; padding: 0 8px; } + .design-measure .startpane { padding: 68px 16px 112px; scroll-padding-block: 68px 112px; max-height: calc(100% - 88px); overflow-y: auto; } + .design-measure .starthead b { font-size: 20px; line-height: 28px; } + .design-measure .stagecorner { max-width: calc(100% - 16px); } + .design-measure .hintchip { top: 68px; } + .measure-tree .kv .x { width: 44px; min-height: 44px; } + .measure-tree .kv { grid-template-columns: 44px minmax(0,1fr) 44px; } +} +@media (prefers-reduced-motion: reduce) { + :root[data-design='measure'] *, :root[data-design='measure'] *::before, :root[data-design='measure'] *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } +} +.design-measure .projectname::placeholder { font-style: normal; } +.design-measure .slot { display: grid; grid-template-columns: 12px minmax(0,1fr); gap: 8px; } +.design-measure .slot > button { grid-column: 2; justify-self: start; } +.design-measure .slotname b { font-size: 13px; } +.design-measure .slotname span { font-size: 12px; } +.design-measure .project-save-status { flex-direction: column; align-items: flex-start; white-space: nowrap; } +.design-measure .dock-actions .toolrow button:first-child:not(:last-child) { flex-basis: 100%; min-height: 28px; } +.design-measure .dock-actions > .primary { white-space: nowrap; } +.measure-support { position: relative; font-size: 12px; } +.measure-support summary { display: inline-block; padding: 8px 12px; color: var(--dim); background: var(--panel); border: 1px solid var(--line); border-radius: 7px; cursor: pointer; } +.measure-support .supportcard { position: absolute; bottom: 40px; left: 0; min-width: 260px; box-shadow: 0 6px 20px rgb(24 34 48 / 12%); } +.start-sample { border-top: 1px solid var(--line); margin-top: 16px; padding-top: 16px; display: grid; gap: 8px; text-align: center; } +.start-sample span { color: var(--dim); font-size: 12px; } + +.search-results button[aria-disabled="true"] { color: var(--dim); cursor: not-allowed; } + +.file-save-status { margin: 4px; color: var(--dim); font-size: 12px; } +.file-save-status small { display: block; font-size: 12px; } +.design-measure [data-test='save-project'][data-dirty='true'] { position: relative; } +.design-measure [data-test='save-project'][data-dirty='true']::after { content: ''; position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } + +@media (min-width: 480px) and (max-width: 767px) { + .design-measure .dock-actions > .primary { flex: 1; } + .design-measure .dock-actions .toolrow button { flex: 0 1 auto; } +} +@media (max-width: 767px) and (max-height: 550px) { + .design-measure .measure-project, .design-measure .measure-context { height: 80%; min-height: 0; } +} + +.design-measure .starthead b { letter-spacing: normal; } +.design-measure .keys { border-color: var(--line); box-shadow: none; border-radius: 7px; } +.design-measure .keys button.on, .design-measure .keys button.on:hover:not(:disabled) { color: var(--accent); background: var(--accent-soft); } +.design-measure .kv, .design-measure .g-label button { text-transform: none; letter-spacing: normal; } +@media (max-width: 767px) { + .design-measure .dock-actions .toolrow button:first-child:not(:last-child) { min-height: 44px; } +} + +/* Shared tools, dialogs and plugin controls use the same readable scale. */ +.design-measure .sourcerow { display: flex; flex-wrap: wrap; gap: 8px; } +.design-measure .sourcerow button { flex: 1 1 100px; } +.design-measure .dock-scroll .featurekeys { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.design-measure .dock-scroll :is(.group,.draftbox) { min-width: 0; } +.design-measure .dock-scroll :is(input,select,textarea) { max-width: 100%; min-width: 0; } +.design-measure .dock-scroll .toolrow { flex-wrap: wrap; } +.design-measure .dock-scroll .kv { min-width: 0; } +.design-measure .dock-scroll .kv .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.design-measure .dock-scroll .tailrow { margin-top: 16px; } +.design-measure .dock-scroll .group:focus { outline: 2px solid var(--accent); } +:root[data-design='measure'] :is(.modal,.settingsbox,.unitbox,.agent-warning) { background: var(--panel); color: var(--ink); border-color: var(--line); border-radius: 10px; } +.command-search-dialog { width: min(640px, calc(100vw - 32px)); max-height: min(720px, calc(100dvh - 32px)); padding: 16px; display: flex; flex-direction: column; gap: 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 18px 60px rgb(0 0 0 / 25%); } +.command-search-dialog .dock-heading { padding: 0 0 12px; } +.command-search-dialog .field { display: grid; } +.command-search-dialog .search-results { overflow-y: auto; min-height: 0; } +.command-search-dialog h2 { color: var(--ink); } +@media (max-width: 479px) { + .design-measure .top .brandmark { display: none; } + .design-measure .top .projectname { width: 40px; min-width: 24px; } + .design-measure .top > .grow { display: none; } + .design-measure .measure-actions { scrollbar-width: thin; } + .design-measure .measure-actions button { flex-shrink: 0; } +} + +.tool-palette .more-tools { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 8px; } +.tool-palette summary { cursor: pointer; padding: 8px; font-size: 14px; } +.tool-palette .featurekeys button { min-width: 0; } + +.guide-open { width: 100%; margin-bottom: 16px; } +.measurement-guide { padding: 12px; margin: 0 0 20px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); } +.measurement-guide h3 { margin: 0 0 8px; font-size: 15px; } +.measurement-guide p { font-size: 13px; line-height: 1.5; } +.measurement-guide .guide-step { color: var(--dim); margin: 0 0 4px; font-size: 12px; } +.measurement-guide .guide-tasks { display: grid; gap: 8px; } +.measurement-guide button { white-space: normal; } +.guide-target { outline: 2px solid var(--accent); outline-offset: 3px; } + +/* Empty-state content and the wrapped view controls need separate rows. + Padding alone cannot keep a scrolled import/sample button from being covered. */ +.design-measure .stage:has(> .startpane) { display: grid; grid-template-rows: minmax(0, 1fr) auto; } +.design-measure .stage > .startpane { position: relative; inset: auto; grid-area: 1 / 1; min-height: 0; max-height: none; padding-bottom: 24px; scroll-padding-bottom: 24px; } +.design-measure .stage:has(> .startpane) > .stagecorner { position: relative; inset: auto; grid-area: 2 / 1; margin: 12px; } diff --git a/src/state/flatModel.ts b/src/state/flatModel.ts index 44f5f204..3f2d3771 100644 --- a/src/state/flatModel.ts +++ b/src/state/flatModel.ts @@ -326,6 +326,7 @@ export const freshSectionSheet = () => freshSheet({ x: 1, y: 1 }, 'section') /** What putting a sheet on the stage clears besides the sheet itself: the * editors and the tool, which belonged to the sheet that was there. */ export const STAGE_CLEARED = { + selectedElementIds: [] as number[], draft: null, dimDraft: null, tool: NO_TOOL, diff --git a/src/state/flatStore.ts b/src/state/flatStore.ts index 402e6b3e..11bf0941 100644 --- a/src/state/flatStore.ts +++ b/src/state/flatStore.ts @@ -83,6 +83,9 @@ function centerNear( } interface FlatState extends SheetState { + selectedElementIds: number[] + selectElement: (id: number | null, additive?: boolean) => void + renameElement: (id: number, name: string) => void imageName: string | null /** Pixel dimensions of the loaded image. */ imageWidth: number @@ -418,7 +421,11 @@ export const useFlat = create()((set, get) => ({ s.clickSlotElement(spot, meta) return } - if (!s.draft) return + if (!s.draft) { + const hit = elementNear(spot, s.elements.filter(e => e.visible), 10 * meta.unitsPerScreenPx) + s.selectElement(hit?.id ?? null, Boolean(meta.shift || meta.ctrl)) + return + } const mode = flatMethod(s.draft.method).mode if (mode === 'seed') { // A fit-to-edge tool grows the line or circle along the detected edge @@ -570,6 +577,7 @@ export const useFlat = create()((set, get) => ({ s.cancelDimDraft() return true } + if (s.selectedElementIds.length) { s.selectElement(null); return true } return false }, @@ -778,6 +786,17 @@ export const useFlat = create()((set, get) => ({ profiles: loadProfiles(), elements: [], + selectedElementIds: [], + selectElement: (id, additive = false) => set(s => { + if (id === null) return additive ? {} : { selectedElementIds: [] } + if (!s.elements.some(e => e.id === id && e.visible)) return {} + return { selectedElementIds: additive ? s.selectedElementIds.includes(id) ? s.selectedElementIds.filter(x => x !== id) : [...s.selectedElementIds, id] : [id] } + }), + renameElement: (id, name) => set(s => { + const text = name.trim() + if (!text || !s.elements.some(e => e.id === id && e.name !== text)) return {} + return { elements: s.elements.map(e => e.id === id ? { ...e, name: text } : e) } + }), draft: null, nextId: 1, nameCounts: {}, @@ -990,6 +1009,7 @@ export const useFlat = create()((set, get) => ({ } return { elements, + selectedElementIds: s.selectedElementIds.filter(x => x !== id), draft, dimDraft: s.dimDraft ? { ...s.dimDraft, refs: s.dimDraft.refs.map(dropRef) } : null, } diff --git a/src/state/historyStore.ts b/src/state/historyStore.ts index 9bf87194..3971855b 100644 --- a/src/state/historyStore.ts +++ b/src/state/historyStore.ts @@ -21,8 +21,8 @@ export const pick = (state: T, keys: readonly K[]): Pick void { } } } - wrap(useStore, ['commitDraft', 'pickDraftPoint', 'removeElement', 'toggleElementVisible', 'setAllElementsVisible', 'commitDimension', 'removeDimension', 'toggleDimensionVisible', 'setAllDimensionsVisible', 'commitSection', 'removeSection', 'toggleSectionVisible', 'setAllSectionsVisible'], '3D Measure') - wrap(useFlat, ['commitDraft', 'deleteElement', 'toggleElementVisible', 'setAllElementsVisible', 'commitDim', 'deleteDimension', 'toggleDimensionVisible', 'setAllDimensionsVisible', 'finishCount', 'deleteCount', 'toggleCountVisible', 'addNote', 'setNoteText', 'moveNote', 'finishNote', 'deleteNote', 'toggleNoteVisible', 'addDatumPick', 'clearDatum', 'turnSheet', 'mirrorSheet', 'applyCalibration', 'applyProfile', 'setSplitAxes', 'setEdgeSensitivity', 'setShowEdges', 'setSnapToEdge', 'setShowGrid'], '2D Measure') + wrap(useStore, ['commitDraft', 'pickDraftPoint', 'renameElement', 'removeElement', 'toggleElementVisible', 'setAllElementsVisible', 'commitDimension', 'removeDimension', 'toggleDimensionVisible', 'setAllDimensionsVisible', 'commitSection', 'removeSection', 'toggleSectionVisible', 'setAllSectionsVisible'], '3D Measure') + wrap(useFlat, ['renameElement', 'renameCount', 'commitDraft', 'deleteElement', 'toggleElementVisible', 'setAllElementsVisible', 'commitDim', 'deleteDimension', 'toggleDimensionVisible', 'setAllDimensionsVisible', 'finishCount', 'deleteCount', 'toggleCountVisible', 'addNote', 'setNoteText', 'moveNote', 'finishNote', 'deleteNote', 'toggleNoteVisible', 'addDatumPick', 'clearDatum', 'turnSheet', 'mirrorSheet', 'applyCalibration', 'applyProfile', 'setSplitAxes', 'setEdgeSensitivity', 'setShowEdges', 'setSnapToEdge', 'setShowGrid'], '2D Measure') wrap(useDeviation, ['setSource', 'setTarget', 'flipTargetSide', 'setTargetFacing', 'setMapFacing', 'setShowElement', 'setTargetScope', 'markScope', 'clearScope', 'resolveAlign', 'revertToGlobal', 'clearAlign', 'setLocalMaxDistance', 'setRange', 'setMaxDistance', 'setBands', 'setTolerance', 'setShowHistogram', 'setShowNominal', 'setShowScan', 'setShowMap', 'setMapOn', 'setSplit', 'addProbe', 'removeProbe', 'clearProbes', 'addPickPoint', 'undoPair', 'clearPairs'], 'Deviation') wrap(useThickness, ['setMethod', 'setMaxThickness', 'setConeRays', 'setConeAngle', 'setNormalDeviation', 'setLow', 'setHigh', 'setBands', 'setLimit', 'setShowHistogram', 'addProbe', 'removeProbe', 'clearProbes'], 'Thickness') // The participants' actions: those registered already, and those that diff --git a/src/state/prefsStore.ts b/src/state/prefsStore.ts index 6d05afec..c5e0fd12 100644 --- a/src/state/prefsStore.ts +++ b/src/state/prefsStore.ts @@ -87,12 +87,11 @@ const write = (key: string, value: string): void => { } } -/** System unless told otherwise: an instrument that comes up in the chassis - * the rest of the desktop wears. index.html reads the same key the same way - * for the first paint. */ +/** Start in the supplied light design; retain explicit Dark/System choices. + * index.html reads the same key for the first paint. */ const storedUiTheme = (): UiTheme => { const v = read(UI_THEME_KEY) - return v === 'dark' || v === 'light' ? v : 'system' + return v === 'dark' || v === 'system' ? v : 'light' } /** The operating system's preference, watched so that "System" follows it diff --git a/src/state/shellStore.ts b/src/state/shellStore.ts index 1aaaf129..21c284f0 100644 --- a/src/state/shellStore.ts +++ b/src/state/shellStore.ts @@ -22,9 +22,35 @@ export type Workspace = CoreWorkspace | (string & {}) interface ShellState { workspace: Workspace setWorkspace: (w: Workspace) => void + measureActions: { name: string; keywords: string; run: () => void; reason?: string }[] + setMeasureActions: (actions: ShellState['measureActions']) => void + saveDownloads: number + noteSaveDownload: () => void + searchQuery: string + searchOpen: boolean + setSearchOpen: (open: boolean, query?: string) => void + measurePanel: 'create' | 'alignment' | 'export' | 'search' | null + setMeasurePanel: (panel: ShellState['measurePanel']) => void + projectOpen: boolean + setProjectOpen: (open: boolean) => void + contextCollapsed: boolean + setContextCollapsed: (collapsed: boolean) => void } export const useShell = create()((set) => ({ workspace: 'elements', setWorkspace: (workspace) => set({ workspace }), + measureActions: [], + setMeasureActions: (measureActions) => set({ measureActions }), + saveDownloads: 0, + noteSaveDownload: () => set((s) => ({ saveDownloads: s.saveDownloads + 1 })), + searchQuery: '', + searchOpen: false, + setSearchOpen: (searchOpen, searchQuery = '') => set({ searchOpen, searchQuery }), + measurePanel: null, + setMeasurePanel: (measurePanel) => set({ measurePanel }), + projectOpen: typeof matchMedia !== 'function' || matchMedia('(min-width: 768px)').matches, + setProjectOpen: (projectOpen) => set({ projectOpen }), + contextCollapsed: false, + setContextCollapsed: (contextCollapsed) => set({ contextCollapsed }), })) diff --git a/src/state/store.ts b/src/state/store.ts index 1bdd74f4..c09ab278 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -221,6 +221,10 @@ interface AppState { resolveFit: (id: number, r: FitOutput) => void failFit: (id: number, message: string) => void removeElement: (id: number) => void + /** One selection shared by the project tree and the 3D viewport. */ + selectedElementIds: number[] + selectElement: (id: number | null, additive?: boolean) => void + renameElement: (id: number, name: string) => void toggleElementVisible: (id: number) => void /** Show or hide every element at once. */ setAllElementsVisible: (visible: boolean) => void @@ -467,6 +471,7 @@ const storedCreaseAngle = (): number => { * mesh's size. Element and dimension ids run on across scans. */ function freshScanState() { return { + selectedElementIds: [], elements: [], draft: null, // A marking indexes the vertices of the scan it was made on. @@ -515,6 +520,7 @@ function withoutRefs(s: AppState, doomed: ReadonlySet): Partial !doomed.has(id)), dimensions, // A section keeps its frozen plane whatever happens to the element // it was cut along; only the name in the record goes. @@ -644,6 +650,15 @@ export const useStore = create()((set, get) => ({ // Deleting an element takes every construction built on it along, and every // dimension that references anything removed. removeElement: (id) => set((s) => withoutRefs(s, dependentsOf(id, s.elements))), + selectElement: (id, additive = false) => set((s) => ({ + selectedElementIds: id === null ? [] : !s.elements.some((e) => e.id === id) ? s.selectedElementIds + : additive ? s.selectedElementIds.includes(id) ? s.selectedElementIds.filter((v) => v !== id) : [...s.selectedElementIds, id] : [id], + })), + renameElement: (id, name) => set((s) => { + const trimmed = name.trim() + if (!trimmed || !s.elements.some((e) => e.id === id && e.name !== trimmed)) return s + return { elements: s.elements.map((e) => e.id === id ? { ...e, name: trimmed } : e) } + }), toggleElementVisible: (id) => set((s) => ({ diff --git a/src/styles.css b/src/styles.css index 3fc56c68..0d2d989b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -167,7 +167,7 @@ body { } .project-save-status { - font-size: 11px; + font-size: 12px; max-width: 170px; } @@ -176,7 +176,7 @@ body { display: flex; align-items: center; gap: 6px; - font-size: 11px; + font-size: 12px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line); @@ -299,7 +299,7 @@ body { } .brand span { - font-size: 9.5px; + font-size: 12px; color: var(--dim); letter-spacing: 0.14em; text-transform: uppercase; @@ -375,7 +375,7 @@ body { .partchip span { font-family: var(--mono); - font-size: 10px; + font-size: 12px; color: var(--dim); white-space: nowrap; } @@ -491,7 +491,7 @@ button.infodot { background: transparent; color: var(--dim); font-family: var(--cond); - font-size: 11px; + font-size: 12px; font-style: italic; font-weight: 700; line-height: 1; @@ -747,7 +747,7 @@ select:disabled { /* Small-caps rule between sections — the panel's only headings. */ .g-label { font-family: var(--cond); - font-size: 10px; + font-size: 12px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; @@ -765,7 +765,7 @@ select:disabled { .g-label b { font-family: var(--mono); - font-size: 11.5px; + font-size: 12px; color: var(--ink); letter-spacing: 0; font-weight: 400; @@ -774,7 +774,7 @@ select:disabled { .sec-head { font-family: var(--cond); - font-size: 11.5px; + font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; @@ -790,7 +790,7 @@ select:disabled { .sec-head > b { margin-left: auto; font-family: var(--mono); - font-size: 11.5px; + font-size: 12px; font-weight: 400; letter-spacing: 0; text-transform: none; @@ -798,7 +798,7 @@ select:disabled { } .hint { - font-size: 11.5px; + font-size: 12px; color: var(--dim); line-height: 1.45; margin: 0; @@ -980,7 +980,7 @@ button.block.on { .sidepick b { flex: 1; font-family: var(--mono); - font-size: 11px; + font-size: 12px; font-weight: 500; line-height: 1.25; color: var(--dim); @@ -1149,7 +1149,7 @@ button.primary svg.icon .i-adt, min-width: 0; padding: 8px 2px 6px; font-family: var(--cond); - font-size: 11.5px; + font-size: 12px; line-height: 1.1; } @@ -1181,7 +1181,7 @@ button.withicon { } .dro-label { - font-size: 11.5px; + font-size: 12px; color: var(--dim); margin-bottom: 6px; display: flex; @@ -1212,7 +1212,7 @@ button.withicon { .dro-window span { font-family: var(--mono); - font-size: 9.5px; + font-size: 12px; color: var(--dim); letter-spacing: 0.06em; text-align: right; @@ -1254,7 +1254,7 @@ button.withicon { /* Caveat under a measurement — worth reading, not an alarm. */ .warnnote { margin: 4px 0 0; - font-size: 11px; + font-size: 12px; line-height: 1.45; color: var(--amber); } @@ -1276,7 +1276,7 @@ button.withicon { } button.x.eye { - font-size: 10px; + font-size: 12px; padding: 0 5px; } @@ -1287,7 +1287,7 @@ button.x.eye:hover:not(:disabled) { /* The whole-list eye in a label row: reads like the label, acts like the eye. */ .g-label button.x.eye.all { font-family: var(--cond); - font-size: 10px; + font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; line-height: 16px; @@ -1296,7 +1296,7 @@ button.x.eye:hover:not(:disabled) { /* Re-open an element or a dimension. Sits with the hide key, and greys out while another editor already has the panel. */ button.x.edit { - font-size: 11px; + font-size: 12px; padding: 0 5px; } @@ -1376,7 +1376,7 @@ button.x:disabled { .dro-note { margin-top: 6px; - font-size: 10.5px; + font-size: 12px; line-height: 1.35; color: var(--dim); } @@ -1436,7 +1436,7 @@ button.x:disabled { .kv b { font-family: var(--mono); - font-size: 11px; + font-size: 12px; color: var(--ink); font-weight: 400; text-align: right; @@ -1491,7 +1491,7 @@ button.x:disabled { .stephead .stepno { font-family: var(--mono); - font-size: 10px; + font-size: 12px; color: #fff; background: var(--accent); width: 16px; @@ -1505,7 +1505,7 @@ button.x:disabled { .stephead .step-opt { margin-left: auto; - font-size: 10.5px; + font-size: 12px; font-weight: 400; color: var(--dim); } @@ -1540,7 +1540,7 @@ button.x:disabled { faceplate, and never wider than the figure it belongs to. */ .extend-field > span { font-family: var(--cond); - font-size: 10.5px; + font-size: 12px; font-weight: 600; letter-spacing: 0.08em; color: var(--dim); @@ -1830,7 +1830,7 @@ button.x:disabled { .busynote { font-family: var(--ui); - font-size: 11.5px; + font-size: 12px; letter-spacing: 0; line-height: 1.4; color: var(--dim); @@ -2335,7 +2335,7 @@ button.x:disabled { border: 1px solid var(--line); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.14); color: var(--ink); - font-size: 11px; + font-size: 12px; line-height: 1.35; text-align: center; white-space: nowrap; @@ -2356,7 +2356,7 @@ button.x:disabled { .viewport-label .label-title { font-family: var(--cond); - font-size: 9.5px; + font-size: 12px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; @@ -2383,7 +2383,7 @@ button.x:disabled { .viewport-label .label-value { font-family: var(--mono); - font-size: 11px; + font-size: 12px; font-weight: 700; } @@ -2394,7 +2394,7 @@ button.x:disabled { /* The small lines under a pin's value: the elements a tolerance is about, the limit and how the value stands to it. */ .viewport-label .label-note { - font-size: 9.5px; + font-size: 12px; line-height: 1.3; color: var(--dim); white-space: nowrap; @@ -2416,7 +2416,7 @@ button.x:disabled { } .stage-label .label-title { - font-size: 10px; + font-size: 12px; letter-spacing: 0.08em; } @@ -2429,7 +2429,7 @@ button.x:disabled { display: flex; align-items: center; font-family: var(--mono); - font-size: 10px; + font-size: 12px; flex: none; overflow: hidden; } @@ -2473,7 +2473,7 @@ button.x:disabled { border-left: 1px solid #3a3c3e; border-radius: 0; font-family: var(--mono); - font-size: 10px; + font-size: 12px; height: 100%; padding: 0 13px; /* One row, same as the link above. */ @@ -2589,6 +2589,21 @@ button.x:disabled { z-index: 50; } +/* Keep the existing overlay geometry when the browser promotes a modal to + its top layer. Closed dialogs must stay hidden until showModal runs. */ +dialog.modalback { + width: 100%; + height: 100%; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + color: inherit; +} +dialog.modalback:not([open]) { display: none; } +dialog.modalback::backdrop { background: transparent; } + .modal { width: 560px; max-width: 92vw; @@ -2618,7 +2633,7 @@ button.x:disabled { .modal h3 { margin: 8px 0 0; font-family: var(--cond); - font-size: 11px; + font-size: 12px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; @@ -2717,12 +2732,12 @@ button.x:disabled { .setting > small code { font-family: var(--mono); - font-size: 11px; + font-size: 12px; } .setting > small { grid-column: 2; - font-size: 11.5px; + font-size: 12px; line-height: 1.4; color: var(--faint); } @@ -2764,7 +2779,7 @@ button.x:disabled { align-items: stretch; gap: 4px; padding: 5px 6px 4px; - font-size: 11.5px; + font-size: 12px; font-weight: 600; line-height: 1.2; text-align: left; @@ -2817,7 +2832,7 @@ button.x:disabled { .puckaxes button { padding: 4px 6px; - font-size: 11.5px; + font-size: 12px; font-weight: 600; color: var(--dim); background: var(--well); @@ -2986,7 +3001,7 @@ button.x:disabled { background: none; box-shadow: none; font-family: var(--cond); - font-size: 11.5px; + font-size: 12px; font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; @@ -3080,7 +3095,7 @@ button.x:disabled { .splitcap b { font-family: var(--cond); - font-size: 10.5px; + font-size: 12px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; @@ -3088,7 +3103,7 @@ button.x:disabled { } .splitcap span { - font-size: 11px; + font-size: 12px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; @@ -3106,7 +3121,7 @@ button.x:disabled { into the status strip. */ .alarmtext { margin: 0; - font-size: 11.5px; + font-size: 12px; line-height: 1.45; color: var(--red); background: #fbeeea; @@ -3161,7 +3176,7 @@ button.x:disabled { .devlegend-unit { font-family: var(--cond); - font-size: 10px; + font-size: 12px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; @@ -3174,7 +3189,7 @@ button.x:disabled { before the panel does — so each end says in words what it means. */ .devlegend-end { font-family: var(--cond); - font-size: 10.5px; + font-size: 12px; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; @@ -3263,7 +3278,7 @@ button.x:disabled { align-items: flex-end; gap: 1px; padding: 4px 7px; - font-size: 10.5px; + font-size: 12px; line-height: 1.25; white-space: nowrap; color: var(--ink); @@ -3302,7 +3317,7 @@ button.x:disabled { .devticks { position: relative; width: 54px; - font-size: 10.5px; + font-size: 12px; color: var(--ink); } @@ -3401,7 +3416,7 @@ button.x:disabled { border: 1px solid var(--line); border-radius: var(--r); padding: 7px 9px; - font-size: 11px; + font-size: 12px; } .devstats > div { @@ -3469,7 +3484,7 @@ button.x:disabled { .splithead b { font-family: var(--cond); - font-size: 11px; + font-size: 12px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; @@ -3477,7 +3492,7 @@ button.x:disabled { } .splithead span { - font-size: 11px; + font-size: 12px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; @@ -3517,12 +3532,12 @@ button.x:disabled { } .picktools .moderow button { - font-size: 11px; + font-size: 12px; padding: 3px 8px; } .picktools > button { - font-size: 11px; + font-size: 12px; padding: 3px 8px; } @@ -3530,7 +3545,7 @@ button.x:disabled { display: flex; align-items: center; gap: 6px; - font-size: 11px; + font-size: 12px; color: var(--dim); } @@ -3544,7 +3559,7 @@ button.x:disabled { } .pickcount { - font-size: 11px; + font-size: 12px; color: var(--dim); margin-left: auto; } @@ -3592,7 +3607,7 @@ button.x:disabled { } .splitstep span { - font-size: 11px; + font-size: 12px; color: var(--dim); } @@ -3605,7 +3620,7 @@ button.x:disabled { .pinchip { font-family: var(--mono); - font-size: 10px; + font-size: 12px; color: #fff; min-width: 17px; height: 17px; @@ -3623,7 +3638,7 @@ button.x:disabled { } .splitwarn { - font-size: 11.5px; + font-size: 12px; color: var(--red); max-width: 300px; } @@ -3633,7 +3648,7 @@ button.x:disabled { final alignment, which ICP still has to do. */ .splitfit { font-family: var(--mono); - font-size: 11px; + font-size: 12px; color: var(--dim); white-space: nowrap; } @@ -3642,7 +3657,7 @@ button.x:disabled { tying a point on the scan to its counterpart on the reference. */ .pick-pin { font-family: var(--mono); - font-size: 10px; + font-size: 12px; font-weight: 600; color: #fff; padding: 1px 5px; @@ -3706,7 +3721,7 @@ button.x:disabled { } .slotname span { - font-size: 10.5px; + font-size: 12px; color: var(--faint); overflow: hidden; text-overflow: ellipsis; @@ -3716,7 +3731,7 @@ button.x:disabled { .slot button { flex: none; padding: 4px 9px; - font-size: 11.5px; + font-size: 12px; } /* The unit belongs to the number, not to the label — a field that does not say @@ -3740,7 +3755,7 @@ button.x:disabled { transform: translateY(-50%); font-style: normal; font-family: var(--cond); - font-size: 11px; + font-size: 12px; letter-spacing: 0.06em; color: var(--faint); pointer-events: none; @@ -3748,7 +3763,7 @@ button.x:disabled { .probeno { font-family: var(--mono); - font-size: 10px; + font-size: 12px; color: #fff; background: var(--ink); min-width: 16px; @@ -3840,18 +3855,18 @@ button.x:disabled { } .starttile-what { - font-size: 11.5px; + font-size: 12px; color: var(--faint); } .starttile-drop { - font-size: 11.5px; + font-size: 12px; color: var(--dim); margin: 7px 0 4px; } .starttile-name { - font-size: 11.5px; + font-size: 12px; color: var(--green); margin: 7px 0 4px; max-width: 100%; @@ -3861,7 +3876,7 @@ button.x:disabled { } .startfoot { - font-size: 11.5px; + font-size: 12px; color: var(--faint); } @@ -3962,7 +3977,7 @@ button.x:disabled { background: var(--ink); color: var(--panel, var(--stage)); font-family: var(--ui); - font-size: 11px; + font-size: 12px; font-weight: 600; white-space: nowrap; pointer-events: none; @@ -3970,7 +3985,7 @@ button.x:disabled { .note-row-kind { font-family: var(--cond); - font-size: 9.5px; + font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--dim); @@ -3998,7 +4013,7 @@ button.x:disabled { .viewport-label.grip-field input { width: 64px; font-family: var(--mono); - font-size: 11px; + font-size: 12px; padding: 1px 4px; } @@ -4008,13 +4023,13 @@ button.x:disabled { .viewport-label.grip-field i { font-style: normal; - font-size: 10px; + font-size: 12px; color: var(--dim); } .g-label .working { font-family: var(--mono); - font-size: 10.5px; + font-size: 12px; text-transform: none; letter-spacing: 0; display: inline-flex; @@ -4105,7 +4120,7 @@ button.x:disabled { } .pickgroup-label { - font-size: 10.5px; + font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--dim); diff --git a/src/ui/AgentDataWarning.tsx b/src/ui/AgentDataWarning.tsx index 8d8d82ce..ee13438d 100644 --- a/src/ui/AgentDataWarning.tsx +++ b/src/ui/AgentDataWarning.tsx @@ -6,22 +6,18 @@ // answer "yes" is remembered in this browser. import { usePrefs } from '../state/prefsStore' -import { useDismiss } from './useDismiss' +import { Modal } from './Modal' export function AgentDataWarning() { const asking = usePrefs((s) => s.agentDataAsking) const answer = usePrefs((s) => s.answerAgentData) - useDismiss(asking, () => answer(false)) if (!asking) return null return ( -
answer(false)}> + answer(false)}>
e.stopPropagation()} >
@@ -54,6 +50,6 @@ export function AgentDataWarning() {
-
+ ) } diff --git a/src/ui/AlignmentSection.tsx b/src/ui/AlignmentSection.tsx index 1557f883..44c7e7d8 100644 --- a/src/ui/AlignmentSection.tsx +++ b/src/ui/AlignmentSection.tsx @@ -2,6 +2,7 @@ // The alignment group: the 3-2-1 datum editor, the manual move / rotate box, // and the buttons that open them. +import { ToolActions } from './ToolActions' import { useMemo, useState } from 'react' import { ALIGN_PICK_COUNT, @@ -137,6 +138,7 @@ function AlignSelect({ } export function AlignmentSection({ + dockActions = true, onAutoAlign, onAlignSymmetry, onStartAlignment, @@ -144,6 +146,7 @@ export function AlignmentSection({ onApplyManual, onResetAlignment, }: { + dockActions?: boolean /** Ask the scan for a proposal and open the editor on it. */ onAutoAlign: () => void /** Settle the pose being set up on the scan's own symmetry plane. */ @@ -262,7 +265,7 @@ export function AlignmentSection({ /> ))} - -
+
) : alignDraft === null ? ( <> @@ -450,7 +453,7 @@ export function AlignmentSection({ > Use symmetry - - + )} diff --git a/src/ui/CommandSearch.tsx b/src/ui/CommandSearch.tsx new file mode 100644 index 00000000..bf7c6150 --- /dev/null +++ b/src/ui/CommandSearch.tsx @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { useEffect, useRef, useState } from 'react' +import { useShell } from '../state/shellStore' +import { Modal } from './Modal' +import { workspaceTabs } from './TopBar' +import { MeasureSearch } from './MeasurePanels' + +interface Destination { label: string; detail: string; element: HTMLElement } + +/** Search navigates to the real control. It never clones handlers, guesses + * command parameters or turns a search selection into a destructive action. */ +export function CommandSearch() { + const [query, setQuery] = useState(useShell.getState().searchQuery) + const workspace = useShell(s => s.workspace) + const measureActions = useShell(s => s.measureActions) + const [destinations] = useState(() => { + const seen = new Set() + return Array.from(document.querySelectorAll('.mid .panel button, .mid .panel select, .mid .panel input:not([type=file])')).flatMap((element) => { + const label = (element.getAttribute('aria-label') || (element.tagName === 'BUTTON' ? element.textContent : element.closest('label')?.querySelector('span')?.textContent) || element.title || '').replace(/\s+/g, ' ').trim() + if (label.length < 2 || /^(Hide Project|Hide .*width|×|Undo point)$/.test(label) || seen.has(label)) return [] + seen.add(label) + return [{ label, detail: element.title || (element.closest('.measure-project') ? 'Project' : 'Tool settings'), element }] + }) + }) + const results = useRef(null) + const opener = useRef(document.activeElement as HTMLElement | null) + useEffect(() => () => { queueMicrotask(() => opener.current?.isConnected && opener.current.focus()) }, []) + const close = () => useShell.getState().setSearchOpen(false) + const go = ({ element }: Destination) => { + const side = element.closest('.measure-project') ? 'project' : 'context' + if (useShell.getState().workspace === 'elements') { + if (side === 'project') { useShell.getState().setProjectOpen(true); useShell.getState().setContextCollapsed(true) } + else useShell.getState().setContextCollapsed(false) + } else document.dispatchEvent(new CustomEvent('workspace-reveal', { detail: side })) + close() + requestAnimationFrame(() => { + let parent = element.parentElement + while (parent) { if (parent instanceof HTMLDetailsElement) parent.open = true; parent = parent.parentElement } + element.scrollIntoView({ block: 'center' }) + if (element.matches(':disabled')) { const group = element.closest('.group'); if (group) { group.tabIndex = -1; group.focus() } } + else element.focus() + }) + } + const words = query.toLowerCase().trim().split(/\s+/) + const matches = destinations.filter(d => words.every(w => `${d.label} ${d.detail}`.toLowerCase().includes(w))) + const tabs = workspaceTabs().filter(w => words.every(word => `${w.label} ${w.title}`.toLowerCase().includes(word))) + return +
{ + if (!['ArrowDown','ArrowUp'].includes(e.key)) return + const rows = Array.from(results.current?.querySelectorAll('button') ?? []) + const i = rows.indexOf(document.activeElement as HTMLButtonElement) + rows[Math.max(0, Math.min(rows.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]?.focus() + e.preventDefault() + }}> +

Find a tool

+ {workspace === 'elements' ? ({ ...a, run: () => { close(); a.run() } }))} /> : <> + +

Choose a result to open its controls. Your current draft stays in place.

+
+ {tabs.map(w => )} + {matches.map(d => )} + {!tabs.length && !matches.length &&

No matching control. Try a workspace name or a shorter tool name.

} +
+ } +
+
+} diff --git a/src/ui/DeviationPanel.tsx b/src/ui/DeviationPanel.tsx index eebc5a0e..a3adb5b2 100644 --- a/src/ui/DeviationPanel.tsx +++ b/src/ui/DeviationPanel.tsx @@ -5,6 +5,8 @@ // other workspaces — while a fitted element needs only to be chosen. Everything // below the two is the same, because the map they produce is the same map. +import { ToolActions } from './ToolActions' +import { WorkspacePanels } from './WorkspacePanels' import { useShallow } from 'zustand/react/shallow' import { MIN_LOCAL_POINTS } from '../core/deviation/align' import { MAX_DEFLECTION_SCALE, MIN_DEFLECTION_SCALE } from '../core/deviation/deflection' @@ -184,7 +186,8 @@ export function DeviationPanel({ const aside = d.marking ? 'group muted' : 'group' return ( - + ) } diff --git a/src/ui/DimensionSection.tsx b/src/ui/DimensionSection.tsx index 0d0c5cef..2472de7f 100644 --- a/src/ui/DimensionSection.tsx +++ b/src/ui/DimensionSection.tsx @@ -3,6 +3,7 @@ // rows the finished dimensions are read from. import { useMemo } from 'react' +import { ToolActions } from './ToolActions' import { DIMENSION_TYPES, dimensionTypeInfo, @@ -51,11 +52,13 @@ export function DimDraftHead({ } export function DimensionSection({ + part = 'all', editorOpen, onPickPoint, }: { /** True while any editor is open — element, dimension or alignment. */ editorOpen: boolean + part?: 'all' | 'editor' | 'results' /** Fill dimension slot n by picking a new point on the scan. */ onPickPoint: (slot: number) => void }) { @@ -129,6 +132,7 @@ export function DimensionSection({ return (
+ {part !== 'results' && <>
Create dimensions @@ -262,6 +266,7 @@ export function DimensionSection({
)} +
+ ) )} + } + {part !== 'editor' && <> {own.length > 0 && (
- Dimensions + {part !== 'results' && Dimensions} d.visible !== false)} what="dimensions" @@ -304,6 +312,7 @@ export function DimensionSection({ onDelete={() => removeDimension(dim.id)} /> ))} + }
) } diff --git a/src/ui/DraftEditor.tsx b/src/ui/DraftEditor.tsx index c8ce4b45..02b63bc7 100644 --- a/src/ui/DraftEditor.tsx +++ b/src/ui/DraftEditor.tsx @@ -24,6 +24,7 @@ import { OrientFields } from './OrientFields' import { NameField, providersFor, RefSelect } from './RefSelect' import { tintStyle } from './tint' import { useLength } from '../app/useUnits' +import { ToolActions } from './ToolActions' export function DraftEditor({ onSelectMode, @@ -375,6 +376,7 @@ export function DraftEditor({ {(draft.status === 'ready' || (draft.status === 'failed' && draft.fit !== undefined)) && isExtendable(shownFit) && } + +
{method.mode !== 'construct' && ( )} - +
) diff --git a/src/ui/FlatPanel.tsx b/src/ui/FlatPanel.tsx index c2cc02f4..9b481d47 100644 --- a/src/ui/FlatPanel.tsx +++ b/src/ui/FlatPanel.tsx @@ -6,6 +6,10 @@ // workspace is at home in the other. One group per concern, top to bottom in // the order the work happens. +import { ToolActions } from './ToolActions' +import { ToolPalette } from './ToolPalette' +import { WorkspacePanels } from './WorkspacePanels' +import { ProjectTree } from './MeasurePanels' import { useState } from 'react' import { toDpi } from '../core/flat/calibration' import { flatDefaultMethod } from '../core/flat/construct' @@ -70,6 +74,7 @@ export function FlatPanel({ const edgeSensitivity = useFlat((s) => s.edgeSensitivity) const showEdges = useFlat((s) => s.showEdges) const elements = useFlat((s) => s.elements) + const selection = useFlat(s => s.selectedElementIds) const draft = useFlat((s) => s.draft) const unit = useFlat((s) => (s.pxPerMm ? 'mm' : 'px')) // units-ok: the sheet's document unit, an id // A sheet in millimetres is read in the units shown. @@ -144,7 +149,8 @@ export function FlatPanel({ } return ( - + ) } diff --git a/src/ui/Imprint.tsx b/src/ui/Imprint.tsx index 11c57760..bba8d807 100644 --- a/src/ui/Imprint.tsx +++ b/src/ui/Imprint.tsx @@ -7,24 +7,20 @@ import { plugins } from '../plugins/registry' import { useStore } from '../state/store' import { APP_VERSION } from '../version' -import { useDismiss } from './useDismiss' +import { Modal } from './Modal' export function ImprintModal() { const open = useStore((s) => s.imprintOpen) const close = useStore((s) => s.openImprint) - // Escape closes it. Captured, so the workspace's own Escape handling — which - // would discard the draft behind the dialog — never sees the key. - useDismiss(open, () => close(false)) - if (!open) return null // The plugins' notices on the third-party software they ship. const notices = plugins().flatMap((p) => (p.notices ? [{ id: p.id, Notices: p.notices }] : [])) return ( -
close(false)}> + close(false)}>
e.stopPropagation()}>
-

Imprint & Privacy Policy

+

Imprint & Privacy Policy

@@ -118,6 +114,6 @@ export function ImprintModal() {
-
+
) } diff --git a/src/ui/InfoDot.tsx b/src/ui/InfoDot.tsx index 736b8a12..60cee52c 100644 --- a/src/ui/InfoDot.tsx +++ b/src/ui/InfoDot.tsx @@ -75,7 +75,7 @@ export function InfoDot({ const onKey = (e: KeyboardEvent) => { // Swallowed, or Esc would close the card and cancel the draft behind it // in the same keystroke. - if (e.key === 'Escape') { + if (e.key === 'Escape' && !document.querySelector('dialog:modal')) { e.stopPropagation() setOpen(false) dotRef.current?.focus() @@ -96,9 +96,8 @@ export function InfoDot({ type="button" className={'infodot' + (open ? ' on' : '')} data-test={testId} - // Out of the Tab order: Tab from one number goes to the next number, - // not to the explanation between them. The mouse still opens it. - tabIndex={-1} + // Explanations must be reachable without a pointer. + tabIndex={0} aria-expanded={open} aria-label={`More about ${title}`} onClick={(e) => { diff --git a/src/ui/MeasurePanels.tsx b/src/ui/MeasurePanels.tsx new file mode 100644 index 00000000..3ca70fd8 --- /dev/null +++ b/src/ui/MeasurePanels.tsx @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { useShell } from '../state/shellStore' +import { ToolActionsTarget } from './ToolActions' + +export function DockPanel({ side, title, open, onClose, children, hideActions = false }: { + side: 'project' | 'context'; title: string; open: boolean; onClose: () => void; children: ReactNode; hideActions?: boolean +}) { + const [width, setWidth] = useState(side === 'project' ? 248 : 304) + const [footer, setFooter] = useState(null) + const drag = useRef<{ x: number; width: number } | null>(null) + useEffect(() => { + const cancel = () => { if (drag.current) setWidth(drag.current.width); drag.current = null } + window.addEventListener('blur', cancel) + return () => window.removeEventListener('blur', cancel) + }, []) + const min = side === 'project' ? 200 : 280, max = side === 'project' ? 360 : 400 + const bounded = (value: number) => setWidth(Math.max(min, Math.min(max, value))) + const sign = side === 'project' ? 1 : -1 + return +} + +/** Keyboard navigation changes focus; Enter/Space chooses the focused item. + * No second selection model lives here. */ +export function ProjectTree({ children, label = "Measured elements" }: { children: ReactNode; label?: string }) { + const root = useRef(null) + const active = useRef(null) + const rove = (row: HTMLElement | null) => { + const rows = Array.from(root.current?.querySelectorAll('[role=treeitem]') ?? []) + const next = row && rows.includes(row) ? row : rows[0] + rows.forEach((item) => { item.tabIndex = item === next ? 0 : -1 }) + active.current = next ?? null + } + useEffect(() => { rove(active.current) }) + const typed = useRef({ text: '', at: 0 }) + return
rove((e.target as HTMLElement).closest('[role=treeitem]'))} role="tree" aria-label={label} aria-multiselectable="true" className="measure-tree" onKeyDown={(e) => { + if ((e.target as HTMLElement).closest('input,button')) return + const rows = Array.from(e.currentTarget.querySelectorAll('[role=treeitem]')) + const index = rows.indexOf(e.target as HTMLElement) + let next = index + if (e.key === 'ArrowDown') next = Math.min(rows.length - 1, index + 1) + else if (e.key === 'ArrowUp') next = Math.max(0, index - 1) + else if (e.key === 'Home') next = 0 + else if (e.key === 'End') next = rows.length - 1 + else if (e.key.length === 1 && e.key !== ' ' && !e.ctrlKey && !e.metaKey) { + const now = Date.now() + typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : '') + e.key.toLowerCase(), at: now } + const order = [...rows.slice(index + 1), ...rows.slice(0, index + 1)] + const match = order.find((row) => row.querySelector('.name')?.textContent?.toLowerCase().startsWith(typed.current.text)) + if (match) next = rows.indexOf(match) + } else return + e.preventDefault(); e.stopPropagation(); rows[next]?.focus() + }}>{children}
+} + +export interface MeasureAction { name: string; keywords: string; run: () => void; reason?: string } +export function MeasureSearch({ actions }: { actions: MeasureAction[] }) { + const opener = useRef(document.activeElement as HTMLElement | null) + useEffect(() => () => { + const target = opener.current + queueMicrotask(() => { if (target?.isConnected && target.getBoundingClientRect().width) target.focus() }) + }, []) + const [query, setQuery] = useState('') + const [index, setIndex] = useState(0) + const matches = actions.filter((a) => `${a.name} ${a.keywords}`.toLowerCase().includes(query.toLowerCase())) + const results = useRef(null) + return
{ + const buttons = Array.from(results.current?.querySelectorAll('button') ?? []) + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); e.stopPropagation() + const current = buttons.indexOf(e.target as HTMLButtonElement) + const next = current < 0 ? (e.key === 'ArrowDown' ? 0 : buttons.length - 1) : Math.max(0, Math.min(buttons.length - 1, current + (e.key === 'ArrowDown' ? 1 : -1))) + buttons[next]?.focus() + } else if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') { + e.preventDefault(); e.stopPropagation(); if (!matches[0]?.reason) matches[0]?.run() + } + }}> + +

{matches.length} matching tools. Use the arrow keys to browse.

+
+ {matches.map((a, i) => )} + {matches.length === 0 &&

No matching tool. Try “plane”, “distance”, or “export”.

} +
+
+} + +export function useMeasureShell(active: boolean) { + useEffect(() => { + document.documentElement.dataset.design = active ? 'measure' : '' + return () => { delete document.documentElement.dataset.design } + }, [active]) + useEffect(() => { + if (!active) return + const key = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k' && !document.querySelector('dialog:modal')) { + e.preventDefault(); e.stopImmediatePropagation(); useShell.getState().setSearchOpen(true) + } + } + document.addEventListener('keydown', key, true) + return () => document.removeEventListener('keydown', key, true) + }, [active]) + // Layout changes and switching workspace do not change the selected model. + +} diff --git a/src/ui/MeasurementGuide.tsx b/src/ui/MeasurementGuide.tsx new file mode 100644 index 00000000..d7d869e3 --- /dev/null +++ b/src/ui/MeasurementGuide.tsx @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { useEffect, useRef, useState } from 'react' +import { draftHolds, useStore } from '../state/store' +import { useShell } from '../state/shellStore' +import { useHistory } from '../state/historyStore' +import type { ElementKind } from '../core/types' +import { useLength } from '../app/useUnits' +import { onScan } from '../app/scanEvents' + +const TASKS = [ + { label: 'Angle between flat faces', kind: 'plane', count: 2, dimension: 'angle-plane-plane', surface: 'each of the two flat faces' }, + { label: 'Distance between parallel faces', kind: 'plane', count: 2, dimension: 'dist-plane-plane', surface: 'each of the two parallel flat faces' }, + { label: 'Hole or shaft diameter', kind: 'cylinder', count: 1, dimension: 'size-diameter', surface: 'the curved wall of the hole or shaft' }, +] as const +const TITLES = ['Open a scan', 'Check its scale', 'Choose a measurement', 'Fit the surfaces', 'Create the dimension', 'Try Undo and Redo', 'Save editable work'] + +/** Progress follows actual geometry and history. Back reviews instructions; + * it never undoes, cancels or creates an operation behind the user's back. */ +export function MeasurementGuide({ onStart }: { onStart: (kind: ElementKind) => void }) { + const [open, setOpen] = useState(false) + const [scaleChecked, setScaleChecked] = useState(false) + const [taskIndex, setTaskIndex] = useState(null) + const [review, setReview] = useState(null) + const [sawUndo, setSawUndo] = useState(false) + const [recovered, setRecovered] = useState(false) + const file = useStore(s => s.fileName) + const size = useStore(s => s.modelSize) + const elements = useStore(s => s.elements) + const dimensions = useStore(s => s.dimensions) + const draft = useStore(s => s.draft) + const busy = useStore(s => s.busy) + const dimDraft = useStore(s => s.dimDraft) + const otherDraft = useStore(s => Boolean(s.alignDraft || s.sectionDraft)) + const canUndo = useHistory(s => s.past.length > 0) + const futureCount = useHistory(s => s.future.length) + const undoAvailable = futureCount > 0 + const saves = useShell(s => s.saveDownloads) + const [initialSaves, setInitialSaves] = useState(saves) + const len = useLength() + const task = taskIndex === null ? null : TASKS[taskIndex] + const fitted = task ? elements.filter(e => e.kind === task.kind && e.fit).length : 0 + const measured = Boolean(task && dimensions.some(d => d.type === task.dimension)) + useEffect(() => onScan({ loaded: () => { + // Import identity is not a filename: scanner exports often reuse a name. + setScaleChecked(false); setTaskIndex(null); setSawUndo(false); setRecovered(false); setReview(null) + setInitialSaves(useShell.getState().saveDownloads) + } }), []) + const previousHistory = useRef({ futureCount, measured }) + useEffect(() => { + if (open && task && previousHistory.current.measured && futureCount > previousHistory.current.futureCount) setSawUndo(true) + previousHistory.current = { futureCount, measured } + }, [open, task, futureCount, measured]) + useEffect(() => { if (sawUndo && !undoAvailable && measured) setRecovered(true) }, [sawUndo, undoAvailable, measured]) + const step = !file ? 0 : !scaleChecked ? 1 : !task ? 2 : fitted < task.count ? 3 : !measured && !sawUndo ? 4 : !recovered ? 5 : 6 + const shown = Math.min(review ?? step, step) + const target = shown === 0 ? '[data-test=start-scan] button' : shown === 3 ? '[data-test=create-element]' : shown === 4 ? '[data-test=add-dimension]' : shown === 5 ? '[data-test=undo]' : shown === 6 ? '[data-test=save-project]' : null + useEffect(() => { + if (!open || !target) return + const control = document.querySelector(target) + control?.classList.add('guide-target') + return () => control?.classList.remove('guide-target') + }, [open, target, draft?.fit, dimDraft]) + if (!open) return + return
+

Step {shown + 1} of {TITLES.length}

+

{TITLES[shown]}

+ {shown === 0 &&

Open your scanner’s mesh, or choose the measurement sample in the canvas. No CAD modeling is needed. If asked, choose the units used when exporting the scan.

} + {shown === 1 && <> +

The scan’s corner-to-corner size is approximately {len.fmt(size * 2)}. Does that make sense for the physical part?

+

Check the export units if the scale is wrong. Before trusting a result, compare a known size with a physical measurement. Correct units alone do not prove scanner accuracy.

+ + } + {shown === 2 &&
{TASKS.map((item, i) => )}
} + {shown === 3 && task && <> +

Fit {task.surface}. {fitted} of {task.count} surfaces ready.

+

A fit finds an ideal shape from many scan points. Click the surface, inspect the highlighted patch, then use Create {task.kind}. Cancel lets you retry a wrong pick.

+ {draft?.fit &&

Check the preview’s scatter and accepted points. A small scatter means a close fit to these points, not a guarantee that the scanner measured the true part accurately. Exclude neighboring faces or fixtures if they were picked.

} + + } + {shown === 4 && task && <> +

Select the fitted {task.kind === 'plane' ? 'planes' : 'cylinder'} in the dimension’s reference fields. Check the value and units in the preview, then choose Add dimension.

+ + } + {shown === 5 &&

{dimDraft ? 'The dimension is saved. Press Escape to put away the empty dimension tool, then use Undo and Redo in the top bar.' : sawUndo ? 'Now use Redo in the top bar to restore your measurement.' : !canUndo ? 'This reopened project has no undo history yet. Make a small edit with Edit in the Project panel, then use Undo and Redo to try recovery.' : 'Use Undo in the top bar to remove the latest committed operation, then Redo to restore it. The guide follows the real history; Back below only reviews instructions.'}

} + {shown === 6 && <> +

Save Project in the top bar downloads the scan and editable measurements. Copy report shares readings; STEP exports fitted geometry for CAD.

+ {saves > initialSaves ?

Project download started. Check your browser’s downloads for the file. You can reopen it with Load.

:

Choose Save Project to keep your work. A browser recovery checkpoint is separate from your downloaded project.

} + } +
+ + {review !== null && } + +
+
+} diff --git a/src/ui/Modal.tsx b/src/ui/Modal.tsx new file mode 100644 index 00000000..0c12480d --- /dev/null +++ b/src/ui/Modal.tsx @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// The browser owns the modal stack, background inertness and return focus. +// Workspace shortcuts must not also act on keys pressed inside a dialog. +import { useLayoutEffect, useRef, type ReactNode } from 'react' + +export function Modal({ children, labelledBy, onClose, className = '', initialFocus, onEnter }: { + children: ReactNode + labelledBy: string + onClose: () => void + className?: string + initialFocus?: string + onEnter?: () => void +}) { + const ref = useRef(null) + useLayoutEffect(() => { + const dialog = ref.current! + dialog.showModal() + if (initialFocus) dialog.querySelector(initialFocus)?.focus() + return () => dialog.close() + }, [initialFocus]) + + return ( + { e.preventDefault(); onClose() }} + onClick={(e) => { if (e.target === e.currentTarget) onClose() }} + // A middle press must not confirm the editor behind the window. Let + // releases through, so a gesture already in progress can still end. + onPointerDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + // Native dialogs exclude background controls, but forward Tab at the + // last control can visit browser chrome. Keep the dialog's cycle + // explicit in both directions, including dynamically disabled fields. + if (e.key === 'Tab') { + const controls = [...e.currentTarget.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')] + .filter((el) => el.tabIndex >= 0 && !el.matches(':disabled') && el.getClientRects().length > 0 && getComputedStyle(el).visibility !== 'hidden') + const first = controls[0], last = controls.at(-1) + if (!first || document.activeElement === (e.shiftKey ? first : last)) { + e.preventDefault() + const next = e.shiftKey ? last : first + next?.focus() + } + } + if (e.key === 'Enter' && onEnter && !(e.target as Element).closest('button, input, select, textarea, a, [contenteditable]')) { + e.preventDefault() + onEnter() + } + }} + onKeyUp={(e) => e.stopPropagation()} + > + {children} + + ) +} diff --git a/src/ui/Panel.tsx b/src/ui/Panel.tsx index 258a2950..923a4586 100644 --- a/src/ui/Panel.tsx +++ b/src/ui/Panel.tsx @@ -2,6 +2,11 @@ // The left faceplate: everything the operator sets, and every number the tool // reports. Controls at the top, readouts below, in the order the work happens. +import { MeasurementGuide } from './MeasurementGuide' +import { useEffect } from 'react' +import { useShell } from '../state/shellStore' +import { DockPanel, ProjectTree, MeasureSearch, type MeasureAction } from './MeasurePanels' +import { dimensionTypeInfo } from '../core/dimensions' import { draftHolds, useStore, type SelectMode } from '../state/store' import { sectionElementsOf, useFlat } from '../state/flatStore' import { usePulse } from '../app/useHints' @@ -174,8 +179,48 @@ export function Panel({ ].filter((r): r is number => typeof r === 'number'), ) - return ( - - ) + } + +
{activeTool ? title : `${selection.length} elements selected`}{draft ? `. Preview ${draft.status}.` : ''}
+ + } // Kept out of the main component so the file-slot block reads as one unit. diff --git a/src/ui/RowTools.tsx b/src/ui/RowTools.tsx index a476d724..f28109e6 100644 --- a/src/ui/RowTools.tsx +++ b/src/ui/RowTools.tsx @@ -37,6 +37,7 @@ export function RowTools({ data-test={editTestId} disabled={editDisabled} title={editTitle} + aria-label={`Edit ${name}`} onClick={onEdit} > @@ -45,12 +46,14 @@ export function RowTools({ )} - diff --git a/src/ui/SectionEditor.tsx b/src/ui/SectionEditor.tsx index 5c8dd3af..5aa1ea6b 100644 --- a/src/ui/SectionEditor.tsx +++ b/src/ui/SectionEditor.tsx @@ -5,6 +5,7 @@ // tilt the gizmo's rings have given it, a shadow's play and tolerance, and // what the cut produces. Renders nothing without a section draft. +import { ToolActions } from './ToolActions' import { describeShadow } from '../core/section/shadow' import { useLength } from '../app/useUnits' import { useFieldUnits } from './NumberField' @@ -395,7 +396,7 @@ export function SectionEditor({ )}
- - + ) } diff --git a/src/ui/SettingsModal.tsx b/src/ui/SettingsModal.tsx index 661ea1b1..38f0ce59 100644 --- a/src/ui/SettingsModal.tsx +++ b/src/ui/SettingsModal.tsx @@ -28,7 +28,7 @@ import { type SpaceMouseAxisId, } from '../viewer/spaceMouse' import { VIEW_THEMES, themeById } from '../viewer/viewThemes' -import { useDismiss } from './useDismiss' +import { Modal } from './Modal' /** The puck's six motions by 3Dconnexion's names, in the order its trainer * walks through them. */ @@ -121,7 +121,6 @@ export function SettingsModal() { const setHintsOn = useHintPrefs((s) => s.setOn) const agentLink = usePrefs((s) => s.agentLink) const setAgentLink = usePrefs((s) => s.setAgentLink) - const agentDataAsking = usePrefs((s) => s.agentDataAsking) const agentPort = usePrefs((s) => s.agentPort) const setAgentPort = usePrefs((s) => s.setAgentPort) const agentToken = usePrefs((s) => s.agentToken) @@ -134,22 +133,15 @@ export function SettingsModal() { const puckReverse = usePrefs((s) => s.spaceMouseReverse) const setPuckReversed = usePrefs((s) => s.setSpaceMouseReversed) - // Escape closes it. Captured, like the imprint's, so the workspace's own - // Escape handling — which would discard the draft behind the dialog — never - // sees the key. With the agent warning up over it, the key is the warning's. - useDismiss(open && !agentDataAsking, () => close(false)) - if (!open) return null const scheme = schemeById(navScheme) const theme = themeById(viewTheme) const map = colormapById(colormap) return ( -
close(false)}> + close(false)}>
e.stopPropagation()} >
@@ -567,6 +559,6 @@ export function SettingsModal() {
-
+
) } diff --git a/src/ui/StartPane.tsx b/src/ui/StartPane.tsx index c5026a97..a625188a 100644 --- a/src/ui/StartPane.tsx +++ b/src/ui/StartPane.tsx @@ -84,6 +84,7 @@ export function StartPane({ slots, skip, onOpenSample, + sample, }: { title: string blurb: string @@ -93,6 +94,7 @@ export function StartPane({ skip?: { label: string; title: string; onClick: () => void } /** For someone with no scan to hand: the one that ships with the build. */ onOpenSample?: () => Promise + sample?: { onClick: () => void; caption: string } }) { // A workspace that wants two models can have the second one dropped first — // a STEP file can only ever be the reference — so the prompt says what is @@ -131,6 +133,7 @@ export function StartPane({ No scan to hand? {SAMPLE_DETAIL}.
)} + {sample &&
{sample.caption}
} {skip && (
- + ) } diff --git a/src/ui/ThicknessPanel.tsx b/src/ui/ThicknessPanel.tsx index 21d646b8..0c66ca41 100644 --- a/src/ui/ThicknessPanel.tsx +++ b/src/ui/ThicknessPanel.tsx @@ -5,6 +5,8 @@ // as it is turned. Laid out like the deviation panel next door, because it is // the same instrument pointed at a different question. +import { ToolActions } from './ToolActions' +import { WorkspacePanels } from './WorkspacePanels' import { CONE_CHOICES, useThickness } from '../state/thicknessStore' import { useStore } from '../state/store' import { usePulse } from '../app/useHints' @@ -38,7 +40,8 @@ export function ThicknessPanel({ const ready = Boolean(scanName) return ( - + ) } diff --git a/src/ui/ToleranceSection.tsx b/src/ui/ToleranceSection.tsx index 9c85352c..ab3b1d15 100644 --- a/src/ui/ToleranceSection.tsx +++ b/src/ui/ToleranceSection.tsx @@ -6,6 +6,7 @@ // ever open across the two groups. import { useMemo } from 'react' +import { ToolActions } from './ToolActions' import { dimensionTypeInfo, evaluateDimension, @@ -33,10 +34,12 @@ const GROUPS: { id: DimensionGroup; label: string }[] = [ ] export function ToleranceSection({ + part = 'all', editorOpen, }: { /** True while any editor is open — element, dimension or alignment. */ editorOpen: boolean + part?: 'all' | 'editor' | 'results' }) { const elements = useStore((s) => s.elements) const dimensions = useStore((s) => s.dimensions) @@ -95,6 +98,7 @@ export function ToleranceSection({ return (
+ {part !== 'results' && <>
GD&T @@ -216,6 +220,7 @@ export function ToleranceSection({
)} +
+ ) )} + } + {part !== 'editor' && <> {own.length > 0 && (
Tolerances @@ -259,6 +267,7 @@ export function ToleranceSection({ onDelete={() => removeDimension(dim.id)} /> ))} + }
) } diff --git a/src/ui/ToolActions.tsx b/src/ui/ToolActions.tsx new file mode 100644 index 00000000..2a900b9e --- /dev/null +++ b/src/ui/ToolActions.tsx @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { createContext, useContext, type ReactNode } from 'react' +import { createPortal } from 'react-dom' + +/** Editors keep ownership of their actions; the workspace supplies a footer + * outside the scrolling parameters so Commit and Cancel remain reachable. */ +export const ToolActionsTarget = createContext(null) +export function ToolActions({ children, dock = true }: { children: ReactNode; dock?: boolean }) { + const target = useContext(ToolActionsTarget) + return target && dock ? createPortal(children, target) : <>{children} +} diff --git a/src/ui/ToolPalette.tsx b/src/ui/ToolPalette.tsx new file mode 100644 index 00000000..63f522ce --- /dev/null +++ b/src/ui/ToolPalette.tsx @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { Children, isValidElement, type ReactNode, type HTMLAttributes } from 'react' + +/** Keep the same real controls and handlers in a short, stable shortlist. + * Additional operations remain mounted for keyboard navigation and search. */ +export function ToolPalette({ children, ...props }: HTMLAttributes & { children: ReactNode }) { + const items = Children.toArray(children) + const available = items.map((child, index) => isValidElement<{ disabled?: boolean }>(child) && !child.props.disabled ? index : -1).filter(i => i >= 0) + const recommended = new Set((available.length ? available : items.map((_, i) => i)).slice(0, 4)) + return
+
{items.filter((_, i) => recommended.has(i))}
+ {items.length > recommended.size &&
More tools +

Additional operations. Unavailable tools need compatible geometry or a completed current tool.

+
{items.filter((_, i) => !recommended.has(i))}
+
} +
+} diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 646d76f9..ab1bbd04 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -38,7 +38,7 @@ const CORE_TABS: { id: Workspace; icon: IconName; label: string; title: string } ] /** The app's tabs, then those of the plugins in use, in their order. */ -const workspaceTabs = () => [ +export const workspaceTabs = () => [ ...CORE_TABS, ...plugins() .flatMap((p) => (p.workspace ? [p.workspace] : [])) @@ -176,6 +176,17 @@ export function TopBar({ const agent = useAgentLink() const openRef = useRef(null) const barRef = useRef(null) + const fileMenu = useRef(null) + const closeFileMenu = (restoreFocus = false) => { + if (!fileMenu.current?.open) return + fileMenu.current.open = false + if (restoreFocus) fileMenu.current.querySelector('summary')?.focus() + } + useEffect(() => { + const outside = (e: PointerEvent) => { if (!fileMenu.current?.contains(e.target as Node)) closeFileMenu() } + document.addEventListener('pointerdown', outside) + return () => document.removeEventListener('pointerdown', outside) + }, []) useFit(barRef) const onLoad = (file: File | undefined) => { @@ -214,7 +225,7 @@ export function TopBar({ {/* Both workspaces share the loaded scan, the scene and the camera, so switching is free and neither side loses what it had. */} -
+
{workspaceTabs().map((w) => ( +
{ + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeFileMenu(true) } + }} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) closeFileMenu() }} onClick={(e) => { + if ((e.target as HTMLElement).closest('button')) closeFileMenu(true) + }}>File
+

{recovery.dirty ? 'Unsaved changes' : 'No unsaved changes'}{recovery.status}

+ + + + +
+ } {/* While the agent connection is on, where it stands — and the way to its settings. */} {agentLink && ( @@ -276,6 +303,7 @@ export function TopBar({
- + ) } diff --git a/src/ui/Viewer.tsx b/src/ui/Viewer.tsx index 7cac2b24..0fde3d92 100644 --- a/src/ui/Viewer.tsx +++ b/src/ui/Viewer.tsx @@ -24,7 +24,7 @@ export function Viewer({ onHover?: (hit: PickHit | null) => void /** A click that landed on an existing element while element picking is * on, with where the click was in client pixels. */ - onElementPick?: (id: number, clientX: number, clientY: number) => void + onElementPick?: (id: number, clientX: number, clientY: number, additive?: boolean) => void /** A click on one of the coordinate planes offered to a section. */ onWorldPlanePick?: (axis: WorldAxis) => void /** A brush stroke ended, with this many vertices marked in total. */ @@ -67,7 +67,7 @@ export function Viewer({ } scene.onPick = (hit) => pickRef.current(hit) scene.onHover = (hit) => hoverRef.current?.(hit) - scene.onElementPick = (id, x, y) => elementPickRef.current?.(id, x, y) + scene.onElementPick = (id, x, y, additive) => elementPickRef.current?.(id, x, y, additive) scene.onWorldPlanePick = (axis) => worldPlaneRef.current?.(axis) scene.onPaintChange = (count) => paintRef.current?.(count) scene.onExtendDrag = (side, delta, phase) => extendRef.current?.(side, delta, phase) diff --git a/src/ui/WorkspacePanels.tsx b/src/ui/WorkspacePanels.tsx new file mode 100644 index 00000000..c153c0d9 --- /dev/null +++ b/src/ui/WorkspacePanels.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { useEffect, useState, type ReactNode } from 'react' +import { DockPanel } from './MeasurePanels' + +/** Workspace-owned controls stay mounted when a dock is hidden. Hiding a + * panel is navigation, never a request to cancel a draft or reset its values. */ +export function WorkspacePanels({ title, project, children, activeKey }: { + title: string; project: ReactNode; children: ReactNode; activeKey?: string | null +}) { + const [projectOpen, setProjectOpen] = useState(() => matchMedia('(min-width: 768px)').matches) + const [contextOpen, setContextOpen] = useState(() => matchMedia('(min-width: 1200px)').matches) + useEffect(() => { if (activeKey) setContextOpen(true) }, [activeKey]) + useEffect(() => { + const reveal = (event: Event) => { + const side = (event as CustomEvent<'project' | 'context'>).detail + if (side === 'project') { + setProjectOpen(true) + if (matchMedia('(max-width: 1199px)').matches) setContextOpen(false) + } else { + setContextOpen(true) + if (matchMedia('(max-width: 767px)').matches) setProjectOpen(false) + } + } + document.addEventListener('workspace-reveal', reveal) + return () => document.removeEventListener('workspace-reveal', reveal) + }, []) + return <> + + setProjectOpen(false)}>{project} + setContextOpen(false)}>{children} + +} diff --git a/src/ui/useDismiss.ts b/src/ui/useDismiss.ts index fcebcfbd..c551d109 100644 --- a/src/ui/useDismiss.ts +++ b/src/ui/useDismiss.ts @@ -24,7 +24,9 @@ export function useDismiss(open: boolean, onClose: () => void, options: DismissO const close = () => closeRef.current() const take = escape === 'take' const onKey = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return + // A native modal owns Escape even when this background menu installed + // its capture listener first. + if (e.key !== 'Escape' || document.querySelector('dialog:modal')) return if (take) e.stopPropagation() close() } diff --git a/src/viewer/SceneManager.ts b/src/viewer/SceneManager.ts index 8b7e0fb7..eb33e18c 100644 --- a/src/viewer/SceneManager.ts +++ b/src/viewer/SceneManager.ts @@ -422,7 +422,7 @@ export class SceneManager { onHover: ((hit: PickHit | null) => void) | null = null /** A click on an element while element picking is on — with where the * click was, for a chip bar to stand by. */ - onElementPick: ((id: number, clientX: number, clientY: number) => void) | null = null + onElementPick: ((id: number, clientX: number, clientY: number, additive?: boolean) => void) | null = null /** A click on one of the coordinate planes offered to a section. */ onWorldPlanePick: ((axis: WorldAxis) => void) | null = null /** How many vertices the brush has marked, reported when a stroke ends. */ @@ -456,7 +456,7 @@ export class SceneManager { // what it took and ends there, rather than being dragged across the part // by a hand that has started navigating. onMultiTouch: () => this.marking.endGesture(), - onClick: (x, y, e) => this.handleClick(x, y, modHeld(e)), + onClick: (x, y, e) => this.handleClick(x, y, modHeld(e) || Boolean(e?.shiftKey)), // The stroke and hover queues are what decide whether this frame has // anything new to show at all. onTick: () => { @@ -698,11 +698,11 @@ export class SceneManager { const steps: { order: number; click: (x: number, y: number, additive: boolean) => boolean }[] = [ { order: LAYER_ORDER.elements, - click: (x, y) => { + click: (x, y, additive) => { if (!this.elementPickEnabled) return false const id = this.elementAt(x, y) if (id === null) return false - this.onElementPick?.(id, x, y) + this.onElementPick?.(id, x, y, additive) return true }, }, diff --git a/src/viewer/orthoViewport.ts b/src/viewer/orthoViewport.ts index dd459b42..ae049439 100644 --- a/src/viewer/orthoViewport.ts +++ b/src/viewer/orthoViewport.ts @@ -554,6 +554,7 @@ export class OrthoViewport { * the part turns about a point at its own depth. */ animateLook(dir: THREE.Vector3, up: THREE.Vector3, target: THREE.Vector3, ms = 500, zoom?: number): void { + if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) ms = 0 const goal = this.poseOf(dir, up, target, zoom) const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(this.camera.quaternion) const t0 = this.controls.target.clone() diff --git a/src/viewer/viewThemes.ts b/src/viewer/viewThemes.ts index 31404373..56e2c3f6 100644 --- a/src/viewer/viewThemes.ts +++ b/src/viewer/viewThemes.ts @@ -97,8 +97,8 @@ export interface ViewTheme { /** Chassis grey behind the parts, one for each interface theme — matching * `--stage` in the stylesheet, light and dark. */ -export const STAGE_LIGHT = 0xdedcd6 -export const STAGE_DARK = 0x2e3135 +export const STAGE_LIGHT = 0xe9edf2 +export const STAGE_DARK = 0x080c12 const STAGE = STAGE_LIGHT export const VIEW_THEMES: readonly ViewTheme[] = [ diff --git a/tests/history.test.ts b/tests/history.test.ts index 222a35c0..efa8dd7c 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -38,6 +38,62 @@ function flatPoint() { } describe('project history', () => { + it('2D selection stays out of history; rename and delete restore the selected geometry', async () => { + useShell.getState().setWorkspace('flat') + const id = flatPoint() + f().cancelDraft() + clearHistory() + f().stageClick([100, 200], { alt: false, unitsPerScreenPx: 1 }, null) + expect(f().selectedElementIds).toEqual([id]) + expect(h().past).toHaveLength(0) + const name = f().elements[0].name + f().renameElement(id, ' Datum point ') + expect(f().elements[0].name).toBe('Datum point') + f().renameElement(id, ' ') + expect(h().past).toHaveLength(1) + await undo() + expect(f().elements[0].name).toBe(name) + expect(f().selectedElementIds).toEqual([id]) + await redo() + f().deleteElement(id) + expect(f().selectedElementIds).toEqual([]) + await undo() + expect(f().selectedElementIds).toEqual([id]) + expect(f().elements[0].name).toBe('Datum point') + }) + + it('keeps selection out of history but restores selected deleted elements with undo', async () => { + const first = plane(), second = plane() + clearHistory() + m().selectElement(first) + m().selectElement(second, true) + m().selectElement(9999, true) + expect(m().selectedElementIds).toEqual([first, second]) + expect(h().past).toHaveLength(0) + m().removeElement(first) + expect(m().selectedElementIds).toEqual([second]) + await undo() + expect(m().selectedElementIds).toEqual([first, second]) + await redo() + expect(m().selectedElementIds).toEqual([second]) + }) + + it('renames without changing measured geometry and ignores cancelled or unchanged names', async () => { + const id = plane(), original = m().elements[0] + clearHistory() + m().renameElement(id, ' ') + m().renameElement(id, original.name) + m().renameElement(9999, 'Missing element') + expect(h().past).toHaveLength(0) + m().renameElement(id, ' Inspection datum ') + expect(m().elements[0].name).toBe('Inspection datum') + expect(m().elements[0].fit).toBe(original.fit) + await undo() + expect(m().elements[0]).toEqual(original) + await redo() + expect(m().elements[0].name).toBe('Inspection datum') + }) + it('undoes and redoes chronologically across all four workspaces with stable IDs', async () => { plane() useShell.getState().setWorkspace('flat') diff --git a/tests/reducedMotion.test.ts b/tests/reducedMotion.test.ts new file mode 100644 index 00000000..8a8c81ca --- /dev/null +++ b/tests/reducedMotion.test.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as THREE from 'three' +import { OrthoViewport } from '../src/viewer/orthoViewport' + +// Exercise the actual camera interpolation without constructing a GPU renderer. +function cameraViewport() { + const viewport = Object.create(OrthoViewport.prototype) as OrthoViewport + Object.assign(viewport, { + camera: new THREE.OrthographicCamera(-1, 1, 1, -1, .01, 100), + controls: { target: new THREE.Vector3() }, + clipSphere: { center: new THREE.Vector3(), radius: 1 }, + invalidate: vi.fn(), + poseOf: () => ({q: new THREE.Quaternion(),target: new THREE.Vector3(1,2,3),zoom:2,dist:10}), + }) + return viewport +} +afterEach(() => vi.unstubAllGlobals()) +describe('camera motion preference', () => { + it('reaches the exact final pose immediately when reduced motion is requested', () => { + vi.stubGlobal('matchMedia', () => ({matches:true})) + const viewport = cameraViewport() + viewport.animateLook(new THREE.Vector3(0,0,1),new THREE.Vector3(0,1,0),new THREE.Vector3(),500) + expect(viewport.turning()).toBe(false) + expect(viewport.pose().target.toArray()).toEqual([1,2,3]) + expect(viewport.pose().zoom).toBe(2) + }) + it('retains animated navigation for the normal preference', () => { + vi.stubGlobal('matchMedia', () => ({matches:false})) + const viewport = cameraViewport() + viewport.animateLook(new THREE.Vector3(0,0,1),new THREE.Vector3(0,1,0),new THREE.Vector3(),500) + expect(viewport.turning()).toBe(true) + }) +})