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)} />
-
+
{draft.featureId !== undefined ? 'Save changes' : 'Create extrude'}
-
+
- { re.getState().setHint(null); re.getState().cancelExtrude() }}>
+ { re.getState().setHint(null); re.getState().cancelExtrude() }}>
Cancel
-
+
)
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 && }
- re.getState().commitFeature()}>
+ re.getState().commitFeature()}>
{d.featureId !== undefined
? 'Save changes'
: planned
@@ -1204,11 +1205,11 @@ export function FeatureEditor() {
: d.kind === 'deleteFace'
? `Delete the ${d.faces.length === 1 ? 'face' : 'faces'}${bodyNamed ? ` of ${bodyNamed}` : ''}`
: `Create ${title[d.kind].toLowerCase()}${bodyNamed ? ` on ${bodyNamed}` : ''}`}
-
+
- re.getState().cancelFeature()}>
+ re.getState().cancelFeature()}>
Cancel
-
+
)
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 (
-
+
+
Model
@@ -244,149 +247,6 @@ export function ReversePanel({ onOpenScan, onExportStep, onCompare, chainsOf }:
- {!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.
-
-
-
-
- Sketch on
-
-
-
- re.getState().startExtrude()}>
-
- Extrude
-
- re.getState().startRevolve()}>
-
- Revolve
-
- re.getState().startFeature('extract')}>
-
- Extract
-
- re.getState().startFeature('import', { file, name: file.name.replace(/\.(step|stp)$/i, '') })}>
-
- Import STEP
-
- startShell()}>
-
- Shell
-
- re.getState().startFeature('fillet')}>
-
- Fillet
-
- re.getState().startFeature('chamfer')}>
-
- Chamfer
-
- re.getState().startFeature('draft')}>
-
- Draft
-
- re.getState().startFeature('moveFace')}>
-
- Move face
-
- re.getState().startFeature('deleteFace')}>
-
- Delete face
-
- re.getState().startFeature('hole')}>
-
- Hole
-
- re.getState().startFeature('thread')}>
-
- Thread
-
- re.getState().startFeature('cutPlane')}>
-
- Cut by plane
-
- re.getState().startFeature('mirror')}>
-
- Mirror
-
- re.getState().startFeature('boolean')}>
-
- Combine
-
- re.getState().startFeature('sheet')}>
-
- Sheet
-
- re.getState().startFeature('split')}>
-
- Split
-
- re.getState().startFeature('loft')}>
-
- Loft
-
- re.getState().startFeature('sweep')}>
-
- Sweep
-
- re.getState().startFeature('pattern')}>
-
- Pattern
-
- re.getState().startFeature('moveBody', { bodies: movingBodies() })}>
-
- Move body
-
- re.getState().startFeature('offsetBody')}>
-
- Offset body
-
- re.getState().startFeature('surface')}>
-
- Fit surface
-
- re.getState().startFeature('network')}>
-
- Patch network
-
- b.surface) && !hasSolidElement)} title="Close a set of fitted surfaces, sheets and measured planes, cylinders, cones and spheres into a solid: the faces they cut space into are kept where they lie on the scan and sewn" onClick={() => re.getState().startFeature('trim')}>
-
- Trim
-
- b.surface)} title="Make a sheet or a fitted surface a solid of a thickness — a freeform thin shell's wall from its one skin, where a shell or an offset of a lofted body will not go" onClick={() => re.getState().startFeature('thicken')}>
-
- Thicken
-
-
- {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.
}
-
- )}
-
-
-
-
-
-
-
-
-
{features.length + rolledBack.length > 0 && (
@@ -581,7 +441,152 @@ export function ReversePanel({ onOpenScan, onExportStep, onCompare, chainsOf }:
>
)}
-
+
+ >}>
+ {!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.
+
+
+
+
+ Sketch on
+
+
+
+ re.getState().startExtrude()}>
+
+ Extrude
+
+ re.getState().startRevolve()}>
+
+ Revolve
+
+ re.getState().startFeature('extract')}>
+
+ Extract
+
+ re.getState().startFeature('import', { file, name: file.name.replace(/\.(step|stp)$/i, '') })}>
+
+ Import STEP
+
+ startShell()}>
+
+ Shell
+
+ re.getState().startFeature('fillet')}>
+
+ Fillet
+
+ re.getState().startFeature('chamfer')}>
+
+ Chamfer
+
+ re.getState().startFeature('draft')}>
+
+ Draft
+
+ re.getState().startFeature('moveFace')}>
+
+ Move face
+
+ re.getState().startFeature('deleteFace')}>
+
+ Delete face
+
+ re.getState().startFeature('hole')}>
+
+ Hole
+
+ re.getState().startFeature('thread')}>
+
+ Thread
+
+ re.getState().startFeature('cutPlane')}>
+
+ Cut by plane
+
+ re.getState().startFeature('mirror')}>
+
+ Mirror
+
+ re.getState().startFeature('boolean')}>
+
+ Combine
+
+ re.getState().startFeature('sheet')}>
+
+ Sheet
+
+ re.getState().startFeature('split')}>
+
+ Split
+
+ re.getState().startFeature('loft')}>
+
+ Loft
+
+ re.getState().startFeature('sweep')}>
+
+ Sweep
+
+ re.getState().startFeature('pattern')}>
+
+ Pattern
+
+ re.getState().startFeature('moveBody', { bodies: movingBodies() })}>
+
+ Move body
+
+ re.getState().startFeature('offsetBody')}>
+
+ Offset body
+
+ re.getState().startFeature('surface')}>
+
+ Fit surface
+
+ re.getState().startFeature('network')}>
+
+ Patch network
+
+ b.surface) && !hasSolidElement)} title="Close a set of fitted surfaces, sheets and measured planes, cylinders, cones and spheres into a solid: the faces they cut space into are kept where they lie on the scan and sewn" onClick={() => re.getState().startFeature('trim')}>
+
+ Trim
+
+ b.surface)} title="Make a sheet or a fitted surface a solid of a thickness — a freeform thin shell's wall from its one skin, where a shell or an offset of a lofted body will not go" onClick={() => re.getState().startFeature('thicken')}>
+
+ Thicken
+
+
+ {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 })} />
- { re.getState().setHint(null); re.getState().commitRevolve() }}>
+ { re.getState().setHint(null); re.getState().commitRevolve() }}>
{draft.featureId !== undefined ? 'Save changes' : coil ? 'Create coil' : 'Create revolve'}
-
+
- { re.getState().setHint(null); re.getState().cancelRevolve() }}>
+ { re.getState().setHint(null); re.getState().cancelRevolve() }}>
Cancel
-
+
)
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({
/>
))}
- setManual(null)}>
Cancel
-
+
) : alignDraft === null ? (
<>
@@ -450,7 +453,7 @@ export function AlignmentSection({
>
Use symmetry
-