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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ saves carry the same number as `appVersion`. How a release is cut is in

## Unreleased

- **A shared workspace layout and guided measurement**. All workspaces use the
same header, workspace rail, resizable panels and persistent tool actions,
with sheets on narrow screens and light/dark themes. Tool search, keyboard
tree selection and a seven-step guide make the existing measurement workflow
easier to find and follow. A generated block supports measurement practice;
the bundled sample scan remains available.

- **Keep keyboard focus in dialogs** (2026-11-01). Settings, units, agent
consent, support and imprint windows keep Tab inside and restore focus on
close. Escape closes the top window without changing background editors.

- **Contributors are credited** (2026-11-01). Each publish to this repository now
names the people whose work it brings as co-authors, starting with Jozkah and
Starzrng. CONTRIBUTING.md says how it works.
Expand Down
17 changes: 17 additions & 0 deletions docs/images/workspace-design/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Workspace design review screenshots

Captured from the implementation at `2e4552a`, based on upstream `5c1f2f9`,
with isolated headless Chrome profiles and the generated measurement block.
These are screenshots of the running app, not design mockups.

- `measure-edit.png`: desktop element editing with Project and tool panels.
- `guided-measurement.png`: the optional guide at the surface-fitting step.
- `dark-deviation.png`: Surface deviation using the shared dark shell.
- `phone-tools.png`: 390 × 844 layout with the active tool's action footer.

The measure/phone captures come from `scripts/e2e-measure-design.mjs`;
the guide/deviation captures come from `scripts/e2e-workspace-design.mjs`
against the production build. The scripts write fresh captures under `e2e-out/`.

The generated geometry is for practice and layout verification. These images
do not establish physical scanner accuracy or hardware compatibility.
Binary file added docs/images/workspace-design/dark-deviation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/workspace-design/measure-edit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/workspace-design/phone-tools.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 38 additions & 0 deletions docs/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ workspaces have references of their own, in
[plugins/reverse/README.md](../plugins/reverse/README.md) and
[plugins/mesh-tools/README.md](../plugins/mesh-tools/README.md).

- [Workspace layout and guided measurement](#workspace-layout-and-guided-measurement)
- [Undo and redo](#undo-and-redo)
- [3D Measure: fitting and measuring features](#3d-measure-fitting-and-measuring-features)
- [How to use it](#how-to-use-it)
Expand Down Expand Up @@ -51,6 +52,38 @@ workspaces have references of their own, in
- [Alignment, dimensions, and what leaves the tool](#alignment-dimensions-and-what-leaves-the-tool)
- [Measuring a section](#measuring-a-section)

## Workspace layout and guided measurement

All six workspaces share a top bar, workspace rail, Project panel and active-tool
panel. Drag a panel divider to resize it, or focus the divider and use the arrow
keys; Home and End choose its smallest and largest widths. On a narrow screen,
Project and tool controls open as sheets. The active tool's commit and cancel
buttons stay at the bottom of its panel.

**Find a tool** or **Ctrl+K** (Mac: **Cmd+K**) searches the current workspace.
In 3D Measure it starts the selected tool; in the other workspaces it reveals
and focuses the existing control. **More** holds additional tools. **File**
contains New, Open and Export; Save Project downloads the editable project.
A browser recovery checkpoint is separate from that downloaded copy.

In 3D Measure, **Guided measurement** walks through opening a scan, checking
its scale, choosing a measurement, fitting surfaces, adding a dimension, trying
Undo and Redo, and saving. It follows actual work in the project. Back reviews
instructions without undoing anything, and Skip closes the guide. The supported
paths are an angle between faces, distance between parallel faces, and a hole or
shaft diameter. The scale check is an acknowledgement, not a calibration: compare
a known size with the physical part before trusting a measurement.

**Try a measurement sample** opens a generated 30 × 20 × 12 mm block through
the normal importer. It is ideal geometry for practice, not a scanner accuracy
test. **Open a sample scan** still opens the bundled fridge-bracket scan.

Click a fitted element in the viewport or Project tree to select it. Shift adds
to the selection; Ctrl or Command also works in the viewport. Tree arrow keys
move focus, Space selects, and F2 renames. Visibility controls keep the geometry
while hiding it. Settings offers light, dark and system themes; reduced-motion
preferences disable interface transitions.

## Undo and redo

The top bar's **Undo** and **Redo** buttons work across 3D Measure, 2D Measure,
Expand Down Expand Up @@ -384,6 +417,11 @@ last over. On a flat 2D sheet it pans and zooms and does not turn.

### Settings

Settings and other modal windows keep keyboard focus inside: **Tab** and
**Shift+Tab** cycle through their controls, and **Escape** closes only the
top window. Focus returns to the control that opened it; the work behind
the window stays in place. **Enter** activates the focused button.

**⚙ Settings** in the top bar holds what is set once and left, all of it
remembered per browser and none of it saved with a project:

Expand Down
22 changes: 7 additions & 15 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
<!doctype html>
<!-- SPDX-License-Identifier: AGPL-3.0-only -->

<html lang="en">
<html lang="en" data-design="measure">
<head>
<meta charset="UTF-8" />
<!-- The page never zooms: on a phone a pinch belongs to the 3D viewport,
and a page zoomed by one left the instrument stuck in a corner of
the window. maximum-scale also keeps iOS from zooming in on a
focused field and staying there. -->
<!-- Page zoom remains available; the canvas owns its own gestures. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
content="width=device-width, initial-scale=1.0"
/>
<title>ScanRuler by CNC Kitchen</title>
<meta
Expand All @@ -21,20 +18,15 @@
for browsers that take no SVG. The full logo set is in docs/brand. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="16x16 24x24 32x32 48x48" />
<link rel="preload" href="/fonts/barlow-400.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/barlow-600.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/b612-mono-400.woff2" as="font" type="font/woff2" crossorigin />
<style>
/* pre-CSS paint: chassis gray instead of a white flash — and the dark
chassis where that is what was chosen (the same key the settings
store keeps it under; the figures match --chassis in styles.css). */
html { background: #e7e6e1; }
html[data-theme='dark'] { background: #1b1d20; }
/* Match the shared canvas before its stylesheet arrives. */
html { background: #e9edf2; }
html[data-theme='dark'] { background: #080c12; }
</style>
<script>
try {
var t = localStorage.getItem('scanruler.uitheme')
if (t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches))
if (t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches))
document.documentElement.dataset.theme = 'dark'
} catch (e) {}
</script>
Expand Down
26 changes: 14 additions & 12 deletions plugins/mesh-tools/scripts/e2e-mesh-edits.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { clickTool } from '../../../scripts/e2e-lib.mjs'
import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs'
// End-to-end test for the Mesh Tools edits beyond Remove islands: drives the
// real app in headless Chrome on a generated scan — a noisy box standing on
// the XY plane with two holes in its top — and fills one hole by clicking its
Expand Down Expand Up @@ -156,7 +158,7 @@ const step = async (key, word) => {
}

await setViewTheme(page, 'studio')
await page.click('[data-test=support-card] .sc-x').catch(() => {})
await dismissSupportCard(page)
await page.click('[data-test=workspace-mesh]')
await page.waitForSelector('[data-test=start-pane]')
await loadScan(page, SCAN, { inputSelector: '[data-test=start-scan] input[type=file]', timeout: 120_000, settle: 600 })
Expand All @@ -168,7 +170,7 @@ for (const id of ['reduce', 'islands', 'delete', 'cut', 'holes', 'smooth']) {

// ---- Fill holes: the holes in the top are found and ringed; one is closed by
// a click on its ring, the other with the button ---------------------------------
await page.click('[data-test=mesh-tool-holes]')
await clickTool(page, '[data-test=mesh-tool-holes]')
await page.waitForSelector('[data-test=holes-count]', { timeout: 60_000 })
console.log(`holes: ${await text('[data-test=holes-rims]')} · ${await text('[data-test=holes-count]')}`)
check(count(await text('[data-test=holes-rims] b')) === 2, 'the scan has two open rims')
Expand Down Expand Up @@ -212,7 +214,7 @@ check(/Filled 1 hole under/.test(await text('[data-test=pending-edit]')), 'the f
await page.screenshot({ path: shotPath('mesh-edits-filled.png') })

// ---- Smooth: the scan's scatter taken out, within the limit -------------------
await page.click('[data-test=mesh-tool-smooth]')
await clickTool(page, '[data-test=mesh-tool-smooth]')
await page.waitForSelector('[data-test=smooth-tool]')
const limit = Number(await page.$eval('[data-test=smooth-limit]', (el) => el.value))
check(limit > 0 && limit < 0.2, `a limit is suggested for the part (${limit} mm)`)
Expand All @@ -230,10 +232,10 @@ check(rms > 0.005, `and moves it by about the scatter it was given (RMS ${rms} m
check((await copyCount()) === filled, 'and keeps every triangle')

// ---- Cut: shown before it is made, then made ----------------------------------
await page.click('[data-test=mesh-tool-reduce]')
await clickTool(page, '[data-test=mesh-tool-reduce]')
await sleep(400)
const whole = await stage()
await page.click('[data-test=mesh-tool-cut]')
await clickTool(page, '[data-test=mesh-tool-cut]')
await page.waitForSelector('[data-test=cut-tool]')
await sleep(400)
const middle = Number(await page.$eval('[data-test=cut-offset]', (el) => el.value))
Expand Down Expand Up @@ -272,13 +274,13 @@ check(/Cut at XY plane, Z 5 mm/.test(cutStatus), 'the status line says where it
check(/closed flat/.test(cutStatus), 'and that the part was closed flat')
const cut = await copyCount()
check(cut < filled, 'the cut takes triangles off')
await page.click('[data-test=mesh-tool-holes]')
await clickTool(page, '[data-test=mesh-tool-holes]')
await page.waitForSelector('[data-test=holes-closed]', { timeout: 60_000 })
check(true, 'the cut part is still closed')
await page.screenshot({ path: shotPath('mesh-edits-cut.png') })

// ---- Delete marked: a window over the part, asked, deleted --------------------
await page.click('[data-test=mesh-tool-delete]')
await clickTool(page, '[data-test=mesh-tool-delete]')
await page.waitForSelector('[data-test=delete-tool]')
check(await page.$eval('[data-test=delete-marked]', (el) => el.disabled), 'nothing to delete before anything is marked')
await page.click('[data-test=mark-window]')
Expand Down Expand Up @@ -361,7 +363,7 @@ check((await copyCount()) === beforeDelete, 'the next puts the deleted surface b
check((await edits()) === 4, 'and takes it off the list')
await step('KeyZ', 'Undone')
check((await copyCount()) === filled, 'the next puts back what the cut took off')
await page.click('[data-test=mesh-tool-cut]')
await clickTool(page, '[data-test=mesh-tool-cut]')
await page.waitForSelector('[data-test=cut-offset]')
check(Number(await page.$eval('[data-test=cut-offset]', (el) => el.value)) === 5, 'the offset typed for the cut is still set')
await step('KeyZ', 'Undone')
Expand All @@ -372,7 +374,7 @@ await step('KeyZ', 'Undone')
await step('KeyZ', 'Undone')
check(!(await page.$('[data-test=edited-copy]')), 'and three more leave no copy')
check((await scanCount()) === opened && (await inUse()) === 0, 'the scan as it was opened, with nothing in use')
await page.click('[data-test=mesh-tool-holes]')
await clickTool(page, '[data-test=mesh-tool-holes]')
await page.waitForFunction(() => /^2$/.test(document.querySelector('[data-test=holes-count] b')?.textContent ?? ''), { timeout: 60_000 })
check(true, 'both holes are open again')
await step('KeyY', 'Redone')
Expand All @@ -382,7 +384,7 @@ check((await edits()) === 1, 'and lists it')
// ---- the cut preview across a hole the plane runs into ---------------------------
// A plane across X through the hole still open is bridged under it; across
// the one filled, it closes as it is.
await page.click('[data-test=mesh-tool-cut]')
await clickTool(page, '[data-test=mesh-tool-cut]')
await page.waitForSelector('[data-test=cut-ref]')
await page.select('[data-test=cut-ref]', 'x')
await sleep(300)
Expand All @@ -402,7 +404,7 @@ await across(at[0].includes('bridging') ? 7 : -7)
await page.screenshot({ path: shotPath('mesh-edits-cut-bridged.png') })

// ---- Smooth a marked region alone ------------------------------------------------
await page.click('[data-test=mesh-tool-smooth]')
await clickTool(page, '[data-test=mesh-tool-smooth]')
await page.waitForSelector('[data-test=smooth-scope-marked]')
await page.click('[data-test=smooth-scope-marked]')
await page.waitForSelector('[data-test=mark-window]')
Expand Down Expand Up @@ -442,7 +444,7 @@ await page.click('[data-test=mark-window]')
await sleep(300)
check(count(await text('[data-test=mark-count]')) > 0, 'a new region can be marked')
await page.keyboard.press('Escape')
await page.click('[data-test=mesh-tool-delete]')
await clickTool(page, '[data-test=mesh-tool-delete]')
await page.waitForSelector('[data-test=delete-tool]')
await sleep(300)
check(count(await text('[data-test=mark-count]')) === 0, 'Delete marked starts with nothing marked')
Expand Down
3 changes: 2 additions & 1 deletion plugins/mesh-tools/scripts/e2e-mesh-flip.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// Prereqs: dev server running (npm run dev), Chrome installed.
// node plugins/mesh-tools/scripts/e2e-mesh-flip.mjs
// Env: CHROME (chrome.exe path), APP_URL, SHOT_DIR.
import { clickTool } from '../../../scripts/e2e-lib.mjs'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { canvasRect, check, drag, finish, launchApp, loadScan, OUT_DIR, shotPath, sleep } from '../../../scripts/e2e-lib.mjs'
Expand Down Expand Up @@ -63,7 +64,7 @@ const ctrl = async (key) => {
await loadScan(page, SCAN)
await page.click('[data-test=workspace-mesh]')
await page.waitForSelector('[data-test=mesh-tool-flip]')
await page.click('[data-test=mesh-tool-flip]')
await clickTool(page, '[data-test=mesh-tool-flip]')
await page.waitForSelector('[data-test=flip-tool]')
check(!(await page.$('[data-test=flip-backfaces-hint]')), 'Backfaces is on to start, so no hint asks for it')
check(await page.$eval('[data-test=flip-marked]', (el) => el.disabled), 'Flip marked waits on a marking')
Expand Down
20 changes: 11 additions & 9 deletions plugins/mesh-tools/scripts/e2e-mesh-shell.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { clickTool } from '../../../scripts/e2e-lib.mjs'
import { dismissSupportCard } from '../../../scripts/e2e-lib.mjs'
// End-to-end test for Shrink / expand and Shell: drives the real app in
// headless Chrome on a generated scan — a closed noisy box standing on the XY
// plane — shrinks it and reads off the Deviation view that the surface moved
Expand Down Expand Up @@ -66,14 +68,14 @@ const markMiddle = async (half) => {
}
/** Whether the mesh is closed, as Fill holes says. */
const closedNow = async () => {
await page.click('[data-test=mesh-tool-holes]')
await clickTool(page, '[data-test=mesh-tool-holes]')
await page.waitForSelector('[data-test=holes-closed], [data-test=holes-rims]', { timeout: 60_000 })
await sleep(300)
return Boolean(await page.$('[data-test=holes-closed]'))
}

await setViewTheme(page, 'studio')
await page.click('[data-test=support-card] .sc-x').catch(() => {})
await dismissSupportCard(page)
await page.click('[data-test=workspace-mesh]')
await page.waitForSelector('[data-test=start-pane]')
await loadScan(page, SCAN, { inputSelector: '[data-test=start-scan] input[type=file]', timeout: 120_000, settle: 600 })
Expand All @@ -82,7 +84,7 @@ check(opened === fixture.triangles, `the scan opens with its ${fixture.triangles
for (const id of ['offset', 'shell']) check(Boolean(await page.$(`[data-test=mesh-tool-${id}]`)), `the ${id} tool has a key`)

// ---- Shrink the whole part: the surface 0.2 mm in, every triangle kept ------------
await page.click('[data-test=mesh-tool-offset]')
await clickTool(page, '[data-test=mesh-tool-offset]')
await page.waitForSelector('[data-test=offset-tool]')
await page.click('[data-test=offset-shrink]')
await setField('[data-test=offset-distance]', 0.2)
Expand Down Expand Up @@ -129,7 +131,7 @@ await page.screenshot({ path: shotPath('mesh-shell-expanded.png') })
check(await closedNow(), 'the part is still closed')

// ---- Hollow: a sealed cavity, seen in the Cut tool's preview ------------------------
await page.click('[data-test=mesh-tool-shell]')
await clickTool(page, '[data-test=mesh-tool-shell]')
await page.waitForSelector('[data-test=shell-closed]', { timeout: 60_000 })
check(true, 'Shell sees the part is closed, and offers to hollow it')
await setField('[data-test=shell-thickness]', 2)
Expand All @@ -147,23 +149,23 @@ const cavity = 25.6 * 25.6 * 15.6
check(Math.abs(out / cavity - 1) < 0.03, `the cavity is the box 2 mm in all round (${out} of ${cavity.toFixed(0)} mm³)`)
check((await copyCount()) > beforeHollow, 'the inner wall brings triangles of its own')
check(await closedNow(), 'the hollow part is closed')
await page.click('[data-test=mesh-tool-cut]')
await clickTool(page, '[data-test=mesh-tool-cut]')
await page.waitForSelector('[data-test=cut-closes]', { timeout: 60_000 })
check(true, 'cut across the middle it closes flat — a ring round the cavity')
await page.screenshot({ path: shotPath('mesh-shell-hollow-cut.png') })
await step('KeyZ', 'Undone')
check((await copyCount()) === beforeHollow && (await edits()) === 2, 'a step back fills the part again')

// ---- A small hole: Shell asks for it to be filled first ---------------------------------
await page.click('[data-test=mesh-tool-delete]')
await clickTool(page, '[data-test=mesh-tool-delete]')
await page.waitForSelector('[data-test=delete-tool]')
check((await markMiddle(10)) > 0, 'a small patch is marked to delete')
await page.keyboard.press('Escape')
await page.click('[data-test=delete-marked]')
await page.waitForSelector('[data-test=delete-confirm]')
await page.click('[data-test=delete-marked-confirm]')
await editsReach(3)
await page.click('[data-test=mesh-tool-shell]')
await clickTool(page, '[data-test=mesh-tool-shell]')
await page.waitForSelector('[data-test=shell-holes]', { timeout: 60_000 })
console.log('shell:', await text('[data-test=shell-holes]'))
check(/closed but for 1 hole/.test(await text('[data-test=shell-holes]')), 'Shell says the part is closed but for a hole')
Expand All @@ -174,13 +176,13 @@ await page.waitForSelector('[data-test=holes-tool]')
check(true, 'Fill holes first takes the tool there')

// ---- Cut open and thickened into a solid --------------------------------------------------
await page.click('[data-test=mesh-tool-cut]')
await clickTool(page, '[data-test=mesh-tool-cut]')
await page.waitForSelector('[data-test=cut-tool]')
await page.click('[data-test=cut-cap]')
await page.waitForSelector('[data-test=cut-scan]:not([disabled])')
await page.click('[data-test=cut-scan]')
await editsReach(4)
await page.click('[data-test=mesh-tool-shell]')
await clickTool(page, '[data-test=mesh-tool-shell]')
await page.waitForSelector('[data-test=shell-open]', { timeout: 60_000 })
console.log('shell:', await text('[data-test=shell-open]'))
check(true, 'Shell sees an open surface, and offers to thicken it')
Expand Down
2 changes: 1 addition & 1 deletion plugins/mesh-tools/scripts/e2e-mesh-trim.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,6 @@ check(d.scan && d.piece && d.bodies === 0, 'and the box goes again, the scan bac
check(Boolean(await page.$('[data-test=parts-export-trimmed]:not([disabled])')), 'the piece can be exported from the list')
await click(page, '[data-test=mesh-tool-trim]')
await sleep(300)
check(!(await page.$('[data-test=mesh-parts]')), 'a tool in hand puts the list away')
check(Boolean(await page.$('.measure-project [data-test=mesh-parts]')) && Boolean(await page.$('.measure-context [data-test=trim-tool]')), 'opening Trim keeps the parts tree in Project and the active tool in context')

await finish(browser, consoleErrors)
Loading
Loading