@reekon-tools/boldr-cad 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +12 -1
  2. package/dist/data/provider.d.ts +2 -1
  3. package/dist/data/sync.js +4 -1
  4. package/dist/data/sync.js.map +1 -1
  5. package/dist/editor/actions.js +10 -1
  6. package/dist/editor/actions.js.map +1 -1
  7. package/dist/editor/advance.d.ts +2 -0
  8. package/dist/editor/advance.js +23 -0
  9. package/dist/editor/advance.js.map +1 -0
  10. package/dist/editor/dimSize.d.ts +13 -0
  11. package/dist/editor/dimSize.js +50 -0
  12. package/dist/editor/dimSize.js.map +1 -0
  13. package/dist/editor/gestures.js +30 -7
  14. package/dist/editor/gestures.js.map +1 -1
  15. package/dist/editor/keypad.d.ts +8 -0
  16. package/dist/editor/keypad.js +15 -1
  17. package/dist/editor/keypad.js.map +1 -1
  18. package/dist/editor/laser.d.ts +9 -0
  19. package/dist/editor/laser.js +38 -12
  20. package/dist/editor/laser.js.map +1 -1
  21. package/dist/editor/ports.d.ts +9 -0
  22. package/dist/editor/ports.js +2 -0
  23. package/dist/editor/ports.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/model/clockwise.d.ts +11 -0
  28. package/dist/model/clockwise.js +123 -0
  29. package/dist/model/clockwise.js.map +1 -0
  30. package/dist/model/settings.d.ts +2 -0
  31. package/dist/model/settings.js +1 -1
  32. package/dist/model/settings.js.map +1 -1
  33. package/dist/react/BottomBar.js +43 -3
  34. package/dist/react/BottomBar.js.map +1 -1
  35. package/dist/react/PlanCanvas.native.js +15 -9
  36. package/dist/react/PlanCanvas.native.js.map +1 -1
  37. package/dist/react/host.d.ts +6 -0
  38. package/dist/react/host.js +11 -1
  39. package/dist/react/host.js.map +1 -1
  40. package/dist/react/ports.js +2 -1
  41. package/dist/react/ports.js.map +1 -1
  42. package/dist/react/store.d.ts +1 -1
  43. package/dist/react/store.js.map +1 -1
  44. package/dist/render/draw.d.ts +7 -0
  45. package/dist/render/draw.js +17 -8
  46. package/dist/render/draw.js.map +1 -1
  47. package/dist/render/navigate.d.ts +11 -6
  48. package/dist/render/navigate.js +34 -20
  49. package/dist/render/navigate.js.map +1 -1
  50. package/dist/tools/dim.d.ts +6 -0
  51. package/dist/tools/dim.js +28 -4
  52. package/dist/tools/dim.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/data/provider.ts +2 -2
  55. package/src/data/sync.ts +4 -1
  56. package/src/editor/actions.ts +6 -1
  57. package/src/editor/advance.ts +23 -0
  58. package/src/editor/dimSize.ts +53 -0
  59. package/src/editor/gestures.ts +31 -9
  60. package/src/editor/keypad.ts +17 -1
  61. package/src/editor/laser.ts +38 -10
  62. package/src/editor/ports.ts +10 -0
  63. package/src/index.ts +1 -2
  64. package/src/model/clockwise.ts +121 -0
  65. package/src/model/settings.ts +3 -1
  66. package/src/react/BottomBar.tsx +46 -2
  67. package/src/react/PlanCanvas.native.tsx +25 -12
  68. package/src/react/host.ts +11 -1
  69. package/src/react/ports.ts +3 -2
  70. package/src/react/store.ts +1 -1
  71. package/src/render/draw.ts +20 -9
  72. package/src/render/navigate.ts +31 -20
  73. package/src/tools/dim.ts +28 -4
  74. package/src/ui/domPorts.ts +2 -0
@@ -0,0 +1,53 @@
1
+ import { ed } from '../app/state';
2
+ import { clamp } from '../model/geom';
3
+ import { settings } from '../model/settings';
4
+ import { saveSoon } from '../model/store';
5
+ import { requestDraw } from '../render/view';
6
+ import { elevOpen } from './elevation';
7
+ import { selInfo } from './selection';
8
+ import { is3d } from './view3dState';
9
+
10
+ /*
11
+ The size of the drawing's measurement tiles — its dimensions' chips: lengths, angles, diameters, a door's or window's
12
+ — one size for the whole file, every page, everyone's (the user's call, 2026-11-05: "rescaling is global so the scale
13
+ will apply to all measurement tiles in the cad file"). The bar's Size with a dimension picked opens its slider, as a
14
+ tile's Size does for the tiles (editor/linkActions.ts). It's kept with the file's settings (CadFileMeta.settings
15
+ .chipPct) and saved with the drawing; it isn't an undo step, as the wall height and the layers aren't.
16
+
17
+ Unlike a tile, a chip is drawn a set size on the screen, whatever the zoom, so its range is a chip's: from half to
18
+ three times its size, on the tiles' log scale, catching at 100%.
19
+ */
20
+
21
+ /** The slider's range, percent. */
22
+ export const DIM_PCT_MIN = 50, DIM_PCT_MAX = 300;
23
+
24
+ /** Whether the bar offers Size: the plan in Draft, for someone who can edit, with a dimension picked. */
25
+ export function dimSizeVisible(): boolean {
26
+ if (ed.readOnly || ed.mode !== 'draft' || elevOpen() || is3d() || !ed.sel.length) return false;
27
+ const s = selInfo();
28
+ return s.dims.length + s.odims.length > 0;
29
+ }
30
+
31
+ /** Size every measurement tile to `pct` percent (DIM_PCT_MIN to DIM_PCT_MAX), drawn at once — for the slider as it moves. */
32
+ export function setDimPct(pct: number): void {
33
+ settings.chipPct = Math.round(clamp(pct, DIM_PCT_MIN, DIM_PCT_MAX));
34
+ requestDraw();
35
+ }
36
+
37
+ /** The slider let go: a change to save (the file's settings: Save, or the next save where saving is automatic). */
38
+ export function dimSizeDone(): void {
39
+ ed.editSeq++;
40
+ saveSoon();
41
+ requestDraw();
42
+ }
43
+
44
+ /** The slider's steps (as the tiles' slider has: SIZE_STEPS of them, 100% in the middle of a log scale). */
45
+ export const DIM_STEPS = 1000;
46
+ const LOG_MIN = Math.log(DIM_PCT_MIN), LOG_SPAN = Math.log(DIM_PCT_MAX) - LOG_MIN, LOG_100 = (Math.log(100) - LOG_MIN) / LOG_SPAN;
47
+ export const dimPctToStep = (pct: number): number => Math.round(((Math.log(clamp(pct, DIM_PCT_MIN, DIM_PCT_MAX)) - LOG_MIN) / LOG_SPAN) * DIM_STEPS);
48
+ /** A step as a percent: whole percents, catching at 100% so the size the drawing had is easy to find again. */
49
+ export function stepToDimPct(step: number): number {
50
+ const t = clamp(step, 0, DIM_STEPS) / DIM_STEPS;
51
+ if (Math.abs(t - LOG_100) < 0.02) return 100;
52
+ return Math.round(Math.exp(LOG_MIN + t * LOG_SPAN));
53
+ }
@@ -16,9 +16,12 @@ import type { SelItem } from '../model/types';
16
16
 
17
17
  /*
18
18
  Pointer routing for the plan canvas, whatever delivers the pointers (DOM pointer events, React Native touches).
19
- Pen draws, fingers navigate: once a pen has been seen, touch only pans and zooms. Without a pen, one finger drives
20
- the tool and a second finger turns the action into a pan/pinch — a finger's press waits a moment for one first
21
- (pressTiming). Two-finger tap undoes, three-finger tap redoes.
19
+ A finger or the Pencil drives the tool, one at a time, and a second finger turns a finger's action into a pan/pinch —
20
+ a finger's press waits a moment for one first (pressTiming). Two-finger tap undoes, three-finger tap redoes.
21
+ Pencil and fingers at once (the user's call, 2026-11-05; fingers used to only pan and zoom once a Pencil had been
22
+ seen): fingers landing while the Pencil is down pan and pinch around it, and the Pencil draws while fingers move the
23
+ view. A finger already down when the Pencil lands and not moving the view — a hand resting on the screen — never
24
+ pressed anything: it's set aside until it lifts (see parked).
22
25
  Mouse: middle or right button, or Space held, drags to pan; the wheel zooms.
23
26
  With a sheet up over the drawing (the keypad, a conflict, a question), the drawing only pans and zooms, by any
24
27
  pointer, and a tap on it closes the sheet. View only, a tap on a link's tile asks to open what it's for.
@@ -67,7 +70,9 @@ export const pressTiming = {
67
70
  };
68
71
 
69
72
  const ptrs = new Map<number, PointerIn>();
70
- let penSeen = false, gesture: Gesture | null = null, primary: PointerIn | null = null;
73
+ let gesture: Gesture | null = null, primary: PointerIn | null = null;
74
+ /** Fingers the Pencil landed on (setAsideFingers): they do nothing, and count for nothing, until they lift. */
75
+ const parked = new Set<number>();
71
76
  /** close: a press under a sheet (see sheetUp) — a tap closes it; open: a view-only press — a tap opens a link's tile */
72
77
  let panDrag: { x: number; y: number; id: number; moved: number; close?: boolean; open?: boolean } | null = null;
73
78
  /** where the primary pointer went down, and whether a gesture on the drawing is in motion */
@@ -85,7 +90,7 @@ let held: { p: PointerIn; at: PointerIn; moves: PointerIn[]; timer: ReturnType<t
85
90
  let wheelT: ReturnType<typeof setTimeout> | undefined;
86
91
  export const keys = { spaceDown: false };
87
92
  onSessionReset(() => {
88
- ptrs.clear(); penSeen = false; gesture = null; primary = null; panDrag = null; keys.spaceDown = false; primaryAt = null; beforePress = null; primaryMoved = false;
93
+ ptrs.clear(); parked.clear(); gesture = null; primary = null; panDrag = null; keys.spaceDown = false; primaryAt = null; beforePress = null; primaryMoved = false;
89
94
  dropHeld();
90
95
  setMotion(false);
91
96
  clearTimeout(wheelT);
@@ -122,7 +127,8 @@ function evOf(p: PointerIn): PEv {
122
127
  }
123
128
 
124
129
  const curTool = () => tools[ed.tool];
125
- const touchList = () => [...ptrs.values()].filter(q => q.kind === 'touch');
130
+ const touchList = () => [...ptrs.values()].filter(q => q.kind === 'touch' && !parked.has(q.id));
131
+ const penDown = () => [...ptrs.values()].some(q => q.kind === 'pen');
126
132
 
127
133
  function gState(ts: PointerIn[]): GState | null {
128
134
  if (ts.length >= 2) { const a = ts[0], b = ts[1]; return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, d: hyp(a.x - b.x, a.y - b.y), n: ts.length }; }
@@ -160,7 +166,8 @@ function endGesture(t: number): void {
160
166
  if (g.moved < MOTION_PX && sheetUp()) closeSheet();
161
167
  // (view only: a finger's tap on a link's tile opens it, once the person says so)
162
168
  else if (g.moved < MOTION_PX && ed.readOnly && g.max === 1 && g.last && t - g.t0 < 500) readOnlyTap(g.last.x, g.last.y, true);
163
- else if (g.moved < MOTION_PX && t - g.t0 < 380 && !ed.readOnly) { if (g.max === 2) undo(); else if (g.max >= 3) redo(); }
169
+ // (not with the Pencil mid-stroke: it would undo under it)
170
+ else if (g.moved < MOTION_PX && t - g.t0 < 380 && !ed.readOnly && !primary) { if (g.max === 2) undo(); else if (g.max >= 3) redo(); }
164
171
  saveViewSoon();
165
172
  }
166
173
 
@@ -216,9 +223,22 @@ function releaseHeld(): void {
216
223
  for (const m of h.moves) movePrimary(m);
217
224
  }
218
225
 
226
+ /**
227
+ * The Pencil landed: a finger already down that isn't moving the view — a hand resting on the screen, or a finger
228
+ * beaten to it — never pressed anything (a press it made is undone, as for a second finger) and is set aside until it
229
+ * lifts. Fingers already panning or pinching carry on.
230
+ */
231
+ function setAsideFingers(): void {
232
+ if (gesture) return;
233
+ dropHeld();
234
+ if (primary && primary.kind === 'touch') cancelPrimary();
235
+ for (const q of touchList()) parked.add(q.id);
236
+ }
237
+
219
238
  function endPtr(e: PointerIn, cancel: boolean): void {
220
239
  const p = ptrs.get(e.id);
221
240
  ptrs.delete(e.id);
241
+ if (parked.delete(e.id)) return;
222
242
  if (panDrag && panDrag.id === e.id) {
223
243
  const still = panDrag.moved < MOTION_PX && !cancel, close = !!panDrag.close && still, open = !!panDrag.open && still;
224
244
  panDrag = null; setCursor(''); saveViewSoon();
@@ -239,7 +259,6 @@ function endPtr(e: PointerIn, cancel: boolean): void {
239
259
 
240
260
  export function pointerDown(e: PointerIn): void {
241
261
  dismissPopovers();
242
- if (e.kind === 'pen') penSeen = true;
243
262
  ed.lastPointerTouch = e.kind !== 'mouse';
244
263
  const p = { ...e };
245
264
  ptrs.set(e.id, p);
@@ -250,9 +269,11 @@ export function pointerDown(e: PointerIn): void {
250
269
  setCursor('grabbing');
251
270
  return;
252
271
  }
272
+ if (e.kind === 'pen') setAsideFingers();
253
273
  if (e.kind === 'touch') {
254
274
  const touches = touchList();
255
- if (penSeen || touches.length >= 2 || navOnly) {
275
+ // (with the Pencil down, a finger moves the view around it)
276
+ if (touches.length >= 2 || navOnly || penDown()) {
256
277
  // (a second finger while the first one's press waited: that never pressed anything)
257
278
  dropHeld();
258
279
  if (primary && primary.kind === 'touch') cancelPrimary();
@@ -280,6 +301,7 @@ export function pointerMove(e: PointerIn): void {
280
301
  return;
281
302
  }
282
303
  if (p) { p.x = e.x; p.y = e.y; }
304
+ if (parked.has(e.id)) return;
283
305
  if (gesture && p && p.kind === 'touch') { updateGesture(); return; }
284
306
  // (a finger whose press is waiting: its moves wait with it, and one past a tap sends them all now — a drag)
285
307
  if (held && e.id === held.at.id) {
@@ -118,6 +118,22 @@ export function keypadSetReading(v: number): void {
118
118
  kps.cur.error = undefined;
119
119
  }
120
120
 
121
+ /** Whether an open keypad would take a laser reading now (a length one, with laser on): keypadTakeLaser's test alone. */
122
+ export function keypadTakesLaser(): boolean {
123
+ const k = kps.cur;
124
+ return !!k && k.spec.laser !== false && !k.spec.numeric;
125
+ }
126
+
127
+ /**
128
+ * Whether a reading of `v` taken into the open keypad now would replace a measured value: only the host's own keypad
129
+ * applies a reading at once (this one puts it in the display, still to be applied), and only one opened on such a
130
+ * value says so (KeypadSpec.overwrites: a dimension's).
131
+ */
132
+ export function keypadReadingOverwrites(v: number): boolean {
133
+ const k = kps.cur;
134
+ return !!k && !!k.host && keypadTakesLaser() && !!k.spec.overwrites?.(v);
135
+ }
136
+
121
137
  /**
122
138
  * A laser reading into an open length keypad. False if this keypad can't take one (degrees, or laser turned off).
123
139
  * The host's own keypad can't be typed into from here, so a reading there is the value: it's applied at once.
@@ -125,7 +141,7 @@ export function keypadSetReading(v: number): void {
125
141
  */
126
142
  export function keypadTakeLaser(v: number, capture?: unknown): boolean {
127
143
  const k = kps.cur;
128
- if (!k || k.spec.laser === false || k.spec.numeric) return false;
144
+ if (!k || !keypadTakesLaser()) return false;
129
145
  if (k.host) { keypadSubmit(v, capture); return true; }
130
146
  keypadSetReading(v);
131
147
  k.capture = capture !== undefined ? { of: capture, text: k.text } : undefined;
@@ -9,9 +9,10 @@ import { lengthDimOf, lineLen } from '../model/sketch';
9
9
  import { SK } from '../model/store';
10
10
  import { faceLength } from '../model/wallfaces';
11
11
  import { V } from '../render/view';
12
- import { LENGTH_SET_ALREADY, setDimValue, type DimValueOpts } from '../tools/dim';
12
+ import { LENGTH_SET_ALREADY, replacesMeasured, setDimValue, type DimValueOpts } from '../tools/dim';
13
13
  import { fmtLen } from '../units';
14
- import { keypadTakeLaser } from './keypad';
14
+ import { advanceAfterReading } from './advance';
15
+ import { keypadReadingOverwrites, keypadTakeLaser, keypadTakesLaser } from './keypad';
15
16
  import { refreshKeypad, refreshTray, toast } from './ports';
16
17
  import { selInfo } from './selection';
17
18
 
@@ -82,9 +83,9 @@ function readingTarget(id: string): [string, (1 | -1)?] {
82
83
  return sk.lines[id] || sk.circles[id] || sk.arcs[id] ? [id] : edgeOf(id);
83
84
  }
84
85
 
85
- /** A reading put on a line's (or straight wall's) length, or a circle's or arc's diameter. */
86
- function setReading(id: string, v: number, side?: 1 | -1, capture?: unknown): void {
87
- if (SK().lines[id]) setLineLength(id, v, side, { capture }); else setCurveDiameter(id, v, side, { capture });
86
+ /** A reading put on a line's (or straight wall's) length, or a circle's or arc's diameter. Whether it took. */
87
+ function setReading(id: string, v: number, side?: 1 | -1, capture?: unknown): boolean {
88
+ return SK().lines[id] ? setLineLength(id, v, side, { capture }) : setCurveDiameter(id, v, side, { capture });
88
89
  }
89
90
 
90
91
  /**
@@ -109,12 +110,13 @@ function appliedTo(id: string): string {
109
110
  */
110
111
  export function applyReading(v: number, capture?: unknown): void {
111
112
  if (keypadTakeLaser(v, capture)) { refreshKeypad(); return; }
112
- const s = selInfo();
113
- const one = s.n === 1 ? s.lines[0] ?? s.wfaces[0] ?? s.curves[0] : undefined;
114
- if (ed.mode === 'draft' && one) {
115
- const [id, side] = readingTarget(one);
116
- setReading(id, v, side, capture);
113
+ const target = selectedReadingTarget();
114
+ if (target) {
115
+ const [id, side] = target;
116
+ const took = setReading(id, v, side, capture);
117
117
  toast('Laser ' + fmtLen(v) + ' applied to ' + appliedTo(id));
118
+ // on round to the next line clockwise, for the next reading (not past one the drawing refused, or asked about)
119
+ if (took) advanceAfterReading(id, side);
118
120
  return;
119
121
  }
120
122
  laser.readings.unshift({ id: uid('m'), v, ...(capture !== undefined ? { capture } : {}) });
@@ -123,6 +125,32 @@ export function applyReading(v: number, capture?: unknown): void {
123
125
  toast('Select a line, then take the reading.', { warn: true });
124
126
  }
125
127
 
128
+ /** Where a reading arriving now goes when no keypad takes it: the one selected line, wall edge or curve, in Draft. */
129
+ function selectedReadingTarget(): [string, (1 | -1)?] | null {
130
+ const s = selInfo();
131
+ const one = s.n === 1 ? s.lines[0] ?? s.wfaces[0] ?? s.curves[0] : undefined;
132
+ return ed.mode === 'draft' && one ? readingTarget(one) : null;
133
+ }
134
+
135
+ /**
136
+ * Whether a reading of `v` arriving now would replace a measured value, for a host that warns before one does (BOLDR's
137
+ * mobile app, with the warning a filled annotation tile or form cell gets; the user's call, 2026-11-05): a value
138
+ * already entered (a driving dimension, not a default nobody measured), changed by it (replacesMeasured). That's a
139
+ * reading going onto the selected line, wall edge or curve; or into the host's own keypad opened on such a dimension (a
140
+ * phone's: it applies a reading at once). Not one into the editor's own keypad: it waits in the display to be applied.
141
+ * Not one linked to a measurement of the job: the editor asks about that itself (askLinkedChange).
142
+ */
143
+ export function readingOverwrites(v: number): boolean {
144
+ if (keypadTakesLaser()) return keypadReadingOverwrites(v);
145
+ const target = selectedReadingTarget();
146
+ if (!target) return false;
147
+ const sk = SK(), [id] = target;
148
+ const ex = sk.lines[id] ? lengthDimOf(sk, id) : diamDimOf(id);
149
+ const c = ex ? sk.cons[ex] : undefined;
150
+ // (as setCurveDiameter gives it: a fillet's radius dimension holds the diameter)
151
+ return replacesMeasured(c, c?.type === 'diam' && c.rad ? v * 2 : v);
152
+ }
153
+
126
154
  /**
127
155
  * Use a reading from the tray: `dropOn` is the line it was dragged onto (null: dropped off any line); without it
128
156
  * (a tap), it goes to the one selected line.
@@ -38,6 +38,12 @@ export interface KeypadSpec {
38
38
  extras?: () => KeypadExtra[];
39
39
  /** the value; `capture`, a laser reading's record (laserReading's), when it's that reading applied as it came in */
40
40
  onDone?: (v: number, capture?: unknown) => void;
41
+ /**
42
+ * Whether `v` (as onDone takes it) would replace a value measured there: set by a keypad opened on a dimension, so a
43
+ * host that warns before a reading overwrites can ask, where the host's keypad applies a reading at once
44
+ * (laserReadingOverwrites).
45
+ */
46
+ overwrites?: (v: number) => boolean;
41
47
  /**
42
48
  * A line's length, or a dimension's angle: the job's measurements are offered with the keys (a page swiped to on a
43
49
  * touch screen, Select From List on a desktop), those of `kind` pickable — the one picked handed to `pick` (the
@@ -91,6 +97,8 @@ export interface EditorPorts {
91
97
  toggleImageRotation(anchor?: unknown): void;
92
98
  /** the link tiles' size slider, above the bar (its button again closes it) */
93
99
  toggleTileSize(anchor?: unknown): void;
100
+ /** the measurement tiles' size slider, above the bar, as the tiles' (editor/dimSize.ts) */
101
+ toggleDimSize(anchor?: unknown): void;
94
102
  /** let the user type a new name for a space */
95
103
  editRoomName(roomId: string): void;
96
104
  /** the mouse cursor over the plan (or the wall elevation), as a CSS cursor name ('' = the default); ignored without a mouse */
@@ -125,6 +133,7 @@ export const basePorts = (): EditorPorts => ({
125
133
  toggleImageOpacity() {},
126
134
  toggleImageRotation() {},
127
135
  toggleTileSize() {},
136
+ toggleDimSize() {},
128
137
  editRoomName() {},
129
138
  setCursor() {},
130
139
  dismissPopovers: () => false,
@@ -154,6 +163,7 @@ export const openPalette = (anchor?: unknown): void => ports.openPalette(anchor)
154
163
  export const toggleImageOpacity = (anchor?: unknown): void => ports.toggleImageOpacity(anchor);
155
164
  export const toggleImageRotation = (anchor?: unknown): void => ports.toggleImageRotation(anchor);
156
165
  export const toggleTileSize = (anchor?: unknown): void => ports.toggleTileSize(anchor);
166
+ export const toggleDimSize = (anchor?: unknown): void => ports.toggleDimSize(anchor);
157
167
  export const editRoomName = (roomId: string): void => ports.editRoomName(roomId);
158
168
  export const setCursor = (cursor: string, where: 'plan' | 'elevation' = 'plan'): void => ports.setCursor(cursor, where);
159
169
  export const dismissPopovers = (): boolean => ports.dismissPopovers();
package/src/index.ts CHANGED
@@ -11,8 +11,7 @@ export type { Unit } from './units';
11
11
  export { memoryProvider } from './data/memory';
12
12
  export { setFileSaver } from './react/ports';
13
13
  export { BESIDE as CAD_BESIDE } from './react/pageLock';
14
- export { laserReading, setLaserConnected, surveyClose, surveyCursor, surveyPoint } from './react/host';
15
- export type { HostKeypad, HostKeypadExtra, HostKeypadRequest, MeasurementList, MeasurementListPage } from './react/hostKeypad';
14
+ export { laserReading, laserReadingOverwrites, setLaserConnected, surveyClose, surveyCursor, surveyPoint } from './react/host';export type { HostKeypad, HostKeypadExtra, HostKeypadRequest, MeasurementList, MeasurementListPage } from './react/hostKeypad';
16
15
  export type { CadLinkPick, CadLinks, CadSaveMeasurement, MeasurementKind } from './editor/links';
17
16
  export type { LinkInfo, LinkTarget } from './model/types';
18
17
  export { linkKey } from './model/links';
@@ -0,0 +1,121 @@
1
+ import type { Sketch } from './types';
2
+
3
+ /*
4
+ The next line clockwise round the shape a line is part of: where a laser reading moves on to once it has set a
5
+ line's length (editor/advance.ts; the user's call, 2026-11-05). Clockwise as seen: the plan's y runs up, so as it is
6
+ in the model too, and turning the view doesn't change it.
7
+
8
+ The shape is the face the line bounds, walked with it on the right: at each corner the walk takes the edge that turns
9
+ most to the right, so it keeps tracing the same face past a T (an inner wall) and comes round to where it started. A
10
+ line can bound two faces (a wall between two rooms) or only the outside: the walk that closes clockwise is the room
11
+ (its signed area negative), and between two rooms, the smaller one, or for a wall edge, the room that edge faces. A
12
+ run that never closes goes clockwise round its own middle, and stops at its end. Arcs are walked through (a fillet in
13
+ a room's corner) but never moved onto: the next straight line is. Guides aren't part of any shape.
14
+ */
15
+
16
+ /** An edge walked one way: `id` (a line or an arc) from point `from` to point `to`. */
17
+ interface Step { id: string; from: string; to: string }
18
+
19
+ /** Every line and arc, by the points they end at (guides left out). */
20
+ function ends(sk: Sketch): Map<string, { id: string; other: string }[]> {
21
+ const m = new Map<string, { id: string; other: string }[]>();
22
+ const add = (id: string, a: string, b: string) => {
23
+ if (a === b || !sk.points[a] || !sk.points[b]) return;
24
+ (m.get(a) ?? m.set(a, []).get(a)!).push({ id, other: b });
25
+ (m.get(b) ?? m.set(b, []).get(b)!).push({ id, other: a });
26
+ };
27
+ for (const [id, l] of Object.entries(sk.lines)) if (!l.constr) add(id, l.a, l.b);
28
+ for (const [id, c] of Object.entries(sk.arcs)) if (!c.constr) add(id, c.a, c.b);
29
+ return m;
30
+ }
31
+
32
+ const TURN = 2 * Math.PI;
33
+
34
+ /** The step after `s`, keeping the face on the right: the edge at its end turning most to the right. Null at a dead end. */
35
+ function after(sk: Sketch, at: Map<string, { id: string; other: string }[]>, s: Step): Step | null {
36
+ const v = sk.points[s.to], u = sk.points[s.from];
37
+ const back = Math.atan2(u.y - v.y, u.x - v.x);
38
+ let best: Step | null = null, least = Infinity;
39
+ for (const e of at.get(s.to) ?? []) {
40
+ if (e.id === s.id) continue;
41
+ const w = sk.points[e.other];
42
+ // (counter-clockwise from the way back: the least is the sharpest turn right)
43
+ let d = Math.atan2(w.y - v.y, w.x - v.x) - back;
44
+ while (d <= 1e-9) d += TURN;
45
+ while (d > TURN) d -= TURN;
46
+ if (d < least) { least = d; best = { id: e.id, from: s.to, to: e.other }; }
47
+ }
48
+ return best;
49
+ }
50
+
51
+ /** The walk from `s` on round: every step after it, and whether it came back to `s` (a closed shape). */
52
+ function walk(sk: Sketch, at: Map<string, { id: string; other: string }[]>, s: Step): { steps: Step[]; closed: boolean } {
53
+ const steps: Step[] = [];
54
+ let cur: Step | null = s;
55
+ const most = 2 * (Object.keys(sk.lines).length + Object.keys(sk.arcs).length) + 2;
56
+ while (steps.length < most) {
57
+ cur = after(sk, at, cur!);
58
+ if (!cur) return { steps, closed: false };
59
+ if (cur.id === s.id && cur.from === s.from) return { steps, closed: true };
60
+ steps.push(cur);
61
+ }
62
+ return { steps, closed: false };
63
+ }
64
+
65
+ /** Twice the signed area round `s` and the steps after it (negative: clockwise, y up). */
66
+ function area(sk: Sketch, s: Step, steps: Step[]): number {
67
+ let a = 0;
68
+ for (const t of [s, ...steps]) {
69
+ const p = sk.points[t.from], q = sk.points[t.to];
70
+ a += p.x * q.y - q.x * p.y;
71
+ }
72
+ return a;
73
+ }
74
+
75
+ /** The middle of everything joined to `pid` (the points it can reach along lines and arcs). */
76
+ function middle(sk: Sketch, at: Map<string, { id: string; other: string }[]>, pid: string): { x: number; y: number } {
77
+ const seen = new Set([pid]), todo = [pid];
78
+ while (todo.length) for (const e of at.get(todo.pop()!) ?? []) if (!seen.has(e.other)) { seen.add(e.other); todo.push(e.other); }
79
+ let x = 0, y = 0;
80
+ for (const id of seen) { x += sk.points[id].x; y += sk.points[id].y; }
81
+ return { x: x / seen.size, y: y / seen.size };
82
+ }
83
+
84
+ /** The side of a straight line on the right of walking it as `s` does (+1 the left of its A → B, −1 the right). */
85
+ const rightOf = (sk: Sketch, s: Step): 1 | -1 => (s.from === sk.lines[s.id]?.a ? -1 : 1);
86
+
87
+ /**
88
+ * The next line clockwise from line or arc `id`, round the shape it's part of; null when there's none (a lone line, a
89
+ * run's end, a circle). `side`: a wall's edge it was measured on (+1 the left of its A → B): the edge of the next wall on
90
+ * the same side of the shape (the inside stays the inside) comes back with it. A line between two rooms goes round the
91
+ * one that edge faces, or the smaller one.
92
+ */
93
+ export function nextClockwise(sk: Sketch, id: string, side?: 1 | -1): { id: string; side?: 1 | -1 } | null {
94
+ const e = sk.lines[id] ?? sk.arcs[id];
95
+ if (!e || e.constr || e.a === e.b) return null;
96
+ const at = ends(sk);
97
+ const fwd: Step = { id, from: e.a, to: e.b }, rev: Step = { id, from: e.b, to: e.a };
98
+ const wf = walk(sk, at, fwd), wr = walk(sk, at, rev);
99
+ const room = (s: Step, w: { steps: Step[]; closed: boolean }) => w.closed && area(sk, s, w.steps) < 0;
100
+ const inF = room(fwd, wf), inR = room(rev, wr);
101
+ let start: Step, w: { steps: Step[]; closed: boolean };
102
+ if (inF && inR) {
103
+ // between two rooms: the one the edge faces (on the walk's right), or the smaller
104
+ const pickF = side && sk.lines[id] ? rightOf(sk, fwd) === side : Math.abs(area(sk, fwd, wf.steps)) <= Math.abs(area(sk, rev, wr.steps));
105
+ [start, w] = pickF ? [fwd, wf] : [rev, wr];
106
+ } else if (inF || inR) {
107
+ [start, w] = inF ? [fwd, wf] : [rev, wr];
108
+ } else {
109
+ // a run that doesn't close: clockwise round its middle (the middle on the right of the way it's walked)
110
+ const m = middle(sk, at, e.a), a = sk.points[e.a], b = sk.points[e.b];
111
+ const leftOfAB = (b.x - a.x) * (m.y - a.y) - (b.y - a.y) * (m.x - a.x) >= 0;
112
+ [start, w] = leftOfAB ? [rev, wr] : [fwd, wf];
113
+ }
114
+ const next = w.steps.find(s => s.id !== id && sk.lines[s.id]);
115
+ if (!next) return null;
116
+ if (!side || !sk.lines[id]) return { id: next.id };
117
+ // the measured edge's side of the shape (on the walk's right, or its left), kept on the next line
118
+ const sameRight = rightOf(sk, start) === side;
119
+ const r = rightOf(sk, next);
120
+ return { id: next.id, side: sameRight ? r : (-r as 1 | -1) };
121
+ }
@@ -24,13 +24,15 @@ export interface Settings extends UnitPrefs {
24
24
  layers: Layers;
25
25
  /** default wall height (floor to ceiling), inches */
26
26
  wallH: number;
27
+ /** the size of the drawing's measurement tiles (its dimensions' chips), percent: the file's, everyone's (editor/dimSize.ts) */
28
+ chipPct: number;
27
29
  }
28
30
 
29
31
  export const defaultLayers = (): Layers => ({ underlay: true, construction: true, dims: true, rooms: true, annot: true, cabinets: true, objSnap: true });
30
32
 
31
33
  export const settings = sessionState((): Settings => ({
32
34
  unit: 'ftin', denom: 16, snapGrid: false, autoCons: true, toolTips: true, showCons: true, theme: 'system', welcomed: false,
33
- layers: defaultLayers(), wallH: 96,
35
+ layers: defaultLayers(), wallH: 96, chipPct: 100,
34
36
  }));
35
37
 
36
38
  useUnitPrefs(settings);
@@ -4,6 +4,8 @@ import { ed } from '../app/state';
4
4
  import { applyCtxAction, barItems, deleteSelection, iconAlone, plusAction, plusLabel, plusVisible } from '../editor/actions';
5
5
  import { addLinks, LINK_PLUS_LABEL, linkPlusVisible, linkSizeVisible, openLinkTarget, opensWithChoice, pageLinkSize, pageTilePct, pctToStep, selectedOpenTarget, setTilePct, SIZE_STEPS, stepToPct } from '../editor/linkActions';
6
6
  import { afterEdit } from '../app/edit';
7
+ import { DIM_STEPS, dimPctToStep, dimSizeDone, dimSizeVisible, setDimPct, stepToDimPct } from '../editor/dimSize';
8
+ import { settings } from '../model/settings';
7
9
  import { bar3d, run3d } from '../editor/view3d';
8
10
  import { is3d } from '../editor/view3dState';
9
11
  import { OPACITY_MIN, OPACITY_STEP, ROTATE_MAX, ROTATE_STEP, setUnderlayOpacity, setUnderlayRotation } from '../editor/project';
@@ -33,7 +35,7 @@ export function BottomBar({ phone }: { phone: boolean }) {
33
35
  // what's in the bar and what it's for, and the selected image's opacity and turn (its sliders)
34
36
  useEditorKey(() => {
35
37
  const u = ed.sel.length === 1 && ed.sel[0].type === 'underlay' ? imageById(ed.sel[0].id) : undefined;
36
- return [buttonsKey(), barFor(), plusVisible(), ed.sel.length, u ? u.opacity ?? 0.6 : '', u ? (u.rot ?? 0) + (u.locked ? 'L' : '') : '', linkPlusVisible(), ui.linkFab, linkSizeVisible(), pageLinkSize() ?? ''].join('|');
38
+ return [buttonsKey(), barFor(), plusVisible(), ed.sel.length, u ? u.opacity ?? 0.6 : '', u ? (u.rot ?? 0) + (u.locked ? 'L' : '') : '', linkPlusVisible(), ui.linkFab, linkSizeVisible(), pageLinkSize() ?? '', dimSizeVisible(), settings.chipPct].join('|');
37
39
  });
38
40
  // (on a phone only buttons show: a bar that would hold just a label isn't shown at all. The 3D tab measures and
39
41
  // deletes nothing: what's tapped there shows its sizes, and what can be done with it from there)
@@ -54,6 +56,7 @@ export function BottomBar({ phone }: { phone: boolean }) {
54
56
  <View style={[s.bottom, phone ? s.phoneRow : s.centered]} onLayout={e => setRow(e.nativeEvent.layout.width)}>
55
57
  <ImagePopover />
56
58
  <LinkSizePopover />
59
+ <DimSizePopover />
57
60
  <OpenPopover />
58
61
  {/* a phone's bar sits between two springs that start as wide as the trash takes and share what's left
59
62
  equally, so it's centred; only the left one gives way (first, before the bar), so a long bar slides left
@@ -202,7 +205,7 @@ const BarButtons = memo(function BarButtons({ phone, onWidth }: { k: string; pho
202
205
  if (t && opensWithChoice(t)) { ui.imgPop = ui.imgPop === 'open' ? null : 'open'; notify(); return; }
203
206
  applyCtxAction(it.act, e.currentTarget);
204
207
  }}
205
- style={st => [s.cb, iconAlone(it) && s.lead, it.on || (it.act === 'imgOpacity' && ui.imgPop === 'opacity') || (it.act === 'imgRotate' && ui.imgPop === 'rotate') || (it.act === 'linkSize' && ui.imgPop === 'tilesize') || (isOpen(it.act) && ui.imgPop === 'open') ? { backgroundColor: 'rgba(255,255,255,0.14)' } : null,
208
+ style={st => [s.cb, iconAlone(it) && s.lead, it.on || (it.act === 'imgOpacity' && ui.imgPop === 'opacity') || (it.act === 'imgRotate' && ui.imgPop === 'rotate') || (it.act === 'linkSize' && ui.imgPop === 'tilesize') || (it.act === 'dimSize' && ui.imgPop === 'dimsize') || (isOpen(it.act) && ui.imgPop === 'open') ? { backgroundColor: 'rgba(255,255,255,0.14)' } : null,
206
209
  it.on ? { backgroundColor: '#fff' } : hovered(st) && { backgroundColor: 'rgba(255,255,255,0.08)' }]}>
207
210
  <Icon name={it.ic} color={fg} />
208
211
  {/* (the bar's lead — Lock, or Open — and Lock wherever it is: its icon alone, its name said, not shown) */}
@@ -355,6 +358,47 @@ function LinkSizePopover() {
355
358
  );
356
359
  }
357
360
 
361
+ /**
362
+ * The measurement tiles' size slider (editor/dimSize.ts): the bar's Size with a dimension picked, as LinkSizePopover is
363
+ * for the tiles — one size for every measurement tile in the file, in percent of the size they're drawn at (50% to
364
+ * 300%), catching at 100%. Kept quick the same way: the thumb and the number follow the finger, the drawing is redrawn
365
+ * at most once a frame, and the change is marked (to save) as it's let go. Not an undo step, as the file's other
366
+ * settings aren't.
367
+ */
368
+ function DimSizePopover() {
369
+ const [live, setLive] = useState<number | null>(null);
370
+ const pending = useRef<{ v: number; frame: number } | null>(null);
371
+ const open = ui.imgPop === 'dimsize' && dimSizeVisible();
372
+ const pop = usePopup(open, { y: 8 }, 'bottom center');
373
+ if (!pop.present) return null;
374
+ const value = live ?? dimPctToStep(settings.chipPct);
375
+ const change = (v: number) => {
376
+ setLive(v);
377
+ const p = pending.current;
378
+ if (p) { p.v = v; return; }
379
+ pending.current = { v, frame: requestAnimationFrame(() => { const q = pending.current; pending.current = null; if (q) setDimPct(stepToDimPct(q.v)); }) };
380
+ };
381
+ const end = () => {
382
+ const p = pending.current;
383
+ if (p) { cancelAnimationFrame(p.frame); pending.current = null; setDimPct(stepToDimPct(p.v)); }
384
+ setLive(null);
385
+ dimSizeDone();
386
+ notify();
387
+ };
388
+ return (
389
+ <View style={s.popWrap} pointerEvents="box-none">
390
+ <Animated.View style={[s.pop, pop.style]} pointerEvents={open ? 'auto' : 'none'} testID={open ? 'dimSizePop' : undefined}>
391
+ <Text style={s.popLabel}>Measurement size</Text>
392
+ <View style={{ flex: 1 }}>
393
+ <Slider label="Measurement size" testID="dimSizeSlider" value={value} min={0} max={DIM_STEPS} step={1} format={v => stepToDimPct(v) + '%'}
394
+ onChange={change} onEnd={end} />
395
+ </View>
396
+ <Text style={s.popValue}>{(live != null ? stepToDimPct(live) : settings.chipPct) + '%'}</Text>
397
+ </Animated.View>
398
+ </View>
399
+ );
400
+ }
401
+
358
402
  /** The hint for screen readers: what to do next with the tool or what's picked, read out as it changes. */
359
403
  export function SpokenHint() {
360
404
  useEditorKey(currentHint);
@@ -31,7 +31,8 @@ import { TABS_TOP, topLayout } from './topRow';
31
31
 
32
32
  As a tool follows the pointer, only the moving parts (preview, snap marker, selection box) are drawn again; the
33
33
  drawing is kept until it changes. Touches still reach editor/gestures.ts for the tools, Apple Pencil, and two- and
34
- three-finger taps (undo, redo); it leaves the view to this canvas (setHostNavigation).
34
+ three-finger taps (undo, redo); it leaves the view to this canvas (setHostNavigation). A Pencil and fingers can be
35
+ down at once (fingers pan and pinch while it draws), so each touch is handed over with what it is (OwnType).
35
36
 
36
37
  While a finger or Pencil places or drags something, the lens (render/lens.ts) shows the drawing around it twice as
37
38
  close in a top corner: the same pictures again, through its zoom, clipped to it — nothing more is drawn for it.
@@ -52,8 +53,15 @@ interface Frame {
52
53
  /** the latest of the UI thread's views it was drawn at (see UiView) */
53
54
  seq: number;
54
55
  }
55
- /** A touch as the UI thread hands it over: id, position on the canvas, position in the window. */
56
- type Packed = [id: number, x: number, y: number, ax: number, ay: number];
56
+ /** A touch as the UI thread hands it over: id, position on the canvas, position in the window, what it is. */
57
+ type Packed = [id: number, x: number, y: number, ax: number, ay: number, kind: string];
58
+ /**
59
+ * A touch that says what it is. react-native-gesture-handler gives an event one pointer type, whichever touch iOS lists
60
+ * first, so with an Apple Pencil and a finger both down it's a coin toss which the other is taken for; BOLDR patches it
61
+ * to say for each touch too (BOLDR_App patches/react-native-gesture-handler+*.patch). Without that, the event's is used.
62
+ */
63
+ type OwnType = TouchData & { pointerType?: PointerType };
64
+ interface Typed { id: number; x: number; y: number; ax: number; ay: number; kind: string }
57
65
 
58
66
  /** The latest of the UI thread's views this thread has taken; each frame says which it includes. */
59
67
  let hostSeq = 0;
@@ -65,11 +73,11 @@ let placeLens: (() => void) | null = null;
65
73
  * Touches from the UI thread: the view first, if the fingers moved it, then each touch to the editor's gestures (as a
66
74
  * pointer lands, `down` is every one still down: any other's lift was lost).
67
75
  */
68
- function touchesOnJS(type: number, kind: string, ts: Packed[], view: UiView | null, down: number[] | null): void {
76
+ function touchesOnJS(type: number, ts: Packed[], view: UiView | null, down: number[] | null): void {
69
77
  if (view) { hostSeq = view.seq; setViewFromHost(view.s, view.cx, view.cy); }
70
78
  if (down) pointersStill(down);
71
79
  const fn = type === NAV_DOWN ? pointerDown : type === NAV_MOVE ? pointerMove : type === NAV_UP ? pointerUp : pointerCancel, t = Date.now();
72
- for (const [id, x, y, ax, ay] of ts) fn({ id, kind, x, y, t, cx: ax, cy: ay });
80
+ for (const [id, x, y, ax, ay, kind] of ts) fn({ id, kind, x, y, t, cx: ax, cy: ay });
73
81
  // what a tool shows following the finger (a shape's outline, its marks, the snap), drawn now: left for the next frame
74
82
  // it reached the screen a frame behind the finger. Only when nothing but those moving parts changed — the drawing
75
83
  // itself, and the view, are drawn on the frame as before.
@@ -81,17 +89,22 @@ const kindOf = (t: PointerType): string => {
81
89
  'worklet';
82
90
  return t === PointerType.STYLUS ? 'pen' : t === PointerType.MOUSE ? 'mouse' : 'touch';
83
91
  };
84
- const pack = (ts: TouchData[]): Packed[] => {
92
+ /** Each touch, and what it is: its own pointer type where it says, else the event's (see OwnType). */
93
+ const typed = (ts: TouchData[], type: PointerType): Typed[] => {
94
+ 'worklet';
95
+ return ts.map(t => ({ id: t.id, x: t.x, y: t.y, ax: t.absoluteX, ay: t.absoluteY, kind: kindOf((t as OwnType).pointerType ?? type) }));
96
+ };
97
+ const pack = (ts: Typed[]): Packed[] => {
85
98
  'worklet';
86
- return ts.map(t => [t.id, t.x, t.y, t.absoluteX, t.absoluteY] as Packed);
99
+ return ts.map(t => [t.id, t.x, t.y, t.ax, t.ay, t.kind] as Packed);
87
100
  };
88
101
 
89
102
  export const PlanCanvas = memo(function PlanCanvas() {
90
103
  const first = useRef(true);
91
104
  const frame = useSharedValue<Frame | null>(null);
92
105
  const view = useSharedValue<UiView>({ s: 1, cx: 0, cy: 0, seq: 0 });
93
- // the UI thread's own: the fingers down, whether they're navigating and where they were last, a pen seen
94
- const nav = useSharedValue<NavState>({ fingers: {}, navigating: false, last: null, pen: false });
106
+ // the UI thread's own: the fingers down, whether they're navigating and where they were last, any Pencil down
107
+ const nav = useSharedValue<NavState>({ fingers: {}, navigating: false, last: null, pens: [] });
95
108
  const readOnly = useSharedValue(false);
96
109
  // the lens, and whether it's up (mounted only then: nothing drawn twice for it otherwise)
97
110
  const lens = useSharedValue<Lens | null>(null);
@@ -187,11 +200,11 @@ export const PlanCanvas = memo(function PlanCanvas() {
187
200
  /** A touch event on the UI thread: follow the fingers (render/navigate.ts), then hand the touches to this thread. */
188
201
  const touch = (type: number, e: GestureTouchEvent) => {
189
202
  'worklet';
190
- const kind = kindOf(e.pointerType), down = type === NAV_DOWN ? e.allTouches.map(t => t.id) : null;
191
- const r = navTouch(nav.value, type, kind, e.changedTouches, down, readOnly.value, view.value, frame.value);
203
+ const ts = typed(e.changedTouches, e.pointerType), down = type === NAV_DOWN ? e.allTouches.map(t => t.id) : null;
204
+ const r = navTouch(nav.value, type, kindOf(e.pointerType), ts, down, readOnly.value, view.value, frame.value);
192
205
  nav.value = r.st;
193
206
  if (r.view !== view.value) view.value = r.view;
194
- runOnJS(touchesOnJS)(type, kind, pack(e.changedTouches), r.moved ? r.view : null, down);
207
+ runOnJS(touchesOnJS)(type, pack(ts), r.moved ? r.view : null, down);
195
208
  };
196
209
  const touches = Gesture.Manual()
197
210
  .onTouchesDown((e, manager) => {