@reekon-tools/boldr-cad 1.6.0 → 1.8.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 (87) 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 +26 -2
  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/linkActions.d.ts +5 -1
  22. package/dist/editor/linkActions.js +27 -5
  23. package/dist/editor/linkActions.js.map +1 -1
  24. package/dist/editor/ports.d.ts +9 -0
  25. package/dist/editor/ports.js +2 -0
  26. package/dist/editor/ports.js.map +1 -1
  27. package/dist/editor/refDim.d.ts +17 -0
  28. package/dist/editor/refDim.js +62 -0
  29. package/dist/editor/refDim.js.map +1 -0
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/model/clockwise.d.ts +11 -0
  34. package/dist/model/clockwise.js +123 -0
  35. package/dist/model/clockwise.js.map +1 -0
  36. package/dist/model/settings.d.ts +2 -0
  37. package/dist/model/settings.js +1 -1
  38. package/dist/model/settings.js.map +1 -1
  39. package/dist/model/types.d.ts +5 -0
  40. package/dist/react/BottomBar.js +43 -3
  41. package/dist/react/BottomBar.js.map +1 -1
  42. package/dist/react/PlanCanvas.native.js +15 -9
  43. package/dist/react/PlanCanvas.native.js.map +1 -1
  44. package/dist/react/host.d.ts +6 -0
  45. package/dist/react/host.js +11 -1
  46. package/dist/react/host.js.map +1 -1
  47. package/dist/react/ports.js +2 -1
  48. package/dist/react/ports.js.map +1 -1
  49. package/dist/react/store.d.ts +1 -1
  50. package/dist/react/store.js.map +1 -1
  51. package/dist/render/draw.d.ts +8 -0
  52. package/dist/render/draw.js +164 -92
  53. package/dist/render/draw.js.map +1 -1
  54. package/dist/render/navigate.d.ts +11 -6
  55. package/dist/render/navigate.js +34 -20
  56. package/dist/render/navigate.js.map +1 -1
  57. package/dist/tools/dim.d.ts +15 -0
  58. package/dist/tools/dim.js +171 -45
  59. package/dist/tools/dim.js.map +1 -1
  60. package/dist/tools/line.js +5 -0
  61. package/dist/tools/line.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/data/provider.ts +2 -2
  64. package/src/data/sync.ts +4 -1
  65. package/src/editor/actions.ts +20 -2
  66. package/src/editor/advance.ts +23 -0
  67. package/src/editor/dimSize.ts +53 -0
  68. package/src/editor/gestures.ts +31 -9
  69. package/src/editor/keypad.ts +17 -1
  70. package/src/editor/laser.ts +38 -10
  71. package/src/editor/linkActions.ts +25 -6
  72. package/src/editor/ports.ts +10 -0
  73. package/src/editor/refDim.ts +61 -0
  74. package/src/index.ts +1 -2
  75. package/src/model/clockwise.ts +121 -0
  76. package/src/model/settings.ts +3 -1
  77. package/src/model/types.ts +5 -0
  78. package/src/react/BottomBar.tsx +46 -2
  79. package/src/react/PlanCanvas.native.tsx +25 -12
  80. package/src/react/host.ts +11 -1
  81. package/src/react/ports.ts +3 -2
  82. package/src/react/store.ts +1 -1
  83. package/src/render/draw.ts +131 -71
  84. package/src/render/navigate.ts +31 -20
  85. package/src/tools/dim.ts +168 -35
  86. package/src/tools/line.ts +4 -0
  87. package/src/ui/domPorts.ts +2 -0
@@ -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.
@@ -5,12 +5,12 @@ import { pushHistory } from '../model/history';
5
5
  import { uid } from '../model/ids';
6
6
  import { isLink, LINK_COLOR, LINK_HARD_MAX_IN, LINK_HARD_MIN_IN, LINK_MAX_IN, LINK_MIN_IN, LINK_SIDE_IN, LINK_TILE_PX, linkInfoOf, linkName, linkSide, linkSnapshot } from '../model/links';
7
7
  import { dimMeasurement } from '../model/measurement';
8
- import { imageLinkAt, linkAt } from '../render/hit';
8
+ import { hitAny, imageLinkAt, linkAt } from '../render/hit';
9
9
  import { askConfirm } from './confirm';
10
10
  import { imageById } from '../model/images';
11
11
  import { settings } from '../model/settings';
12
12
  import { page, SK, store } from '../model/store';
13
- import type { LinkTarget, Note, Sketch, Underlay } from '../model/types';
13
+ import type { LinkTarget, Note, SelItem, Sketch, Underlay } from '../model/types';
14
14
  import { tileTypeOf } from '../render/linkTile';
15
15
  import { cvs, linkBox, linkThumb, mainT, requestDraw, s2w, underlayMid } from '../render/view';
16
16
  import { setTool } from '../tools/registry';
@@ -18,6 +18,7 @@ import { elevOpen } from './elevation';
18
18
  import { is3d } from './view3dState';
19
19
  import { besideOpen, canLink, canOpenBeside, linksHost, openLink, openLinkBeside, pickLinks } from './links';
20
20
  import { toast } from './ports';
21
+ import { refTarget } from './refDim';
21
22
 
22
23
  /*
23
24
  Linking. In Annotate the + puts the job's files the person picks on the plan as tiles (selected, to move them where
@@ -270,11 +271,16 @@ export function toggleExpand(): void {
270
271
  else if (u) collapseLink(u.id);
271
272
  }
272
273
 
273
- /** A view-only tap on the plan: on a link's tile or an expanded photo's badge, the person is asked, then what it's for opens. */
274
+ /**
275
+ * A view-only tap on the plan: on a link's tile or an expanded photo's badge, the person is asked, then what it's for
276
+ * opens. Anywhere else, what's under it is picked to read its size — a line, a wall or its edge, a corner, a curve:
277
+ * its grey number shows (editor/refDim.ts; the user's call, 2026-11-05) — and a tap on nothing of the kind lets go.
278
+ */
274
279
  export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
275
- if (!ed.readOnly || !canLink()) return;
276
- const t = linkAt(sx, sy, touch)?.link ?? imageLinkAt(sx, sy, touch)?.link;
277
- if (!t || !canOpenLink(t)) return;
280
+ if (!ed.readOnly) return;
281
+ const t = canLink() ? linkAt(sx, sy, touch)?.link ?? imageLinkAt(sx, sy, touch)?.link : undefined;
282
+ if (!t) { pickToRead(sx, sy, touch); return; }
283
+ if (!canOpenLink(t)) return;
278
284
  const where = linkInfoOf(t)?.groupName ?? t.groupName;
279
285
  askConfirm({
280
286
  title: 'Open ' + linkName(t) + '?',
@@ -287,6 +293,19 @@ export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
287
293
  });
288
294
  }
289
295
 
296
+ /** What a view-only tap picks to read the size of: one thing the + measures alone (the selection is otherwise empty). */
297
+ const READABLE = new Set(['line', 'wface', 'point', 'circle', 'arc']);
298
+
299
+ function pickToRead(sx: number, sy: number, touch: boolean): void {
300
+ const h = hitAny(sx, sy, { touch, faces: true });
301
+ // (a wall picked whole: the side it was tapped on, which side of it its length is read on between two spaces)
302
+ if (h?.side && (h.type === 'line' || h.type === 'circle' || h.type === 'arc')) ed.wallTap = { id: h.id, side: h.side };
303
+ ed.sel = h && !h.weak && READABLE.has(h.type) ? [{ type: h.type as SelItem['type'], id: h.id }] : [];
304
+ // (a point that isn't a corner has no size to read)
305
+ if (ed.sel.length && !refTarget()) ed.sel = [];
306
+ requestDraw();
307
+ }
308
+
290
309
  /** Let go of a dimension's link: its value stays, the drawing's alone now. */
291
310
  export function unlinkDim(id: string): void {
292
311
  const c = SK().cons[id];
@@ -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();
@@ -0,0 +1,61 @@
1
+ import { afterEdit } from '../app/edit';
2
+ import { ed } from '../app/state';
3
+ import { measure } from '../model/constraints';
4
+ import { pushHistory } from '../model/history';
5
+ import { settings } from '../model/settings';
6
+ import { addCons } from '../model/sketch';
7
+ import { SK } from '../model/store';
8
+ import type { Constraint } from '../model/types';
9
+ import { selectionDim } from '../tools/dim';
10
+ import { elevOpen } from './elevation';
11
+ import { keypadOpen } from './keypad';
12
+ import { noScaleYet } from './scaleGate';
13
+
14
+ /*
15
+ Reference measurements by hand (the user's call, 2026-11-05: "upon selection of a line, two points, angle etc there
16
+ should be a grey measurement ... bottom bar should have a make reference option, when pressed the shown grey
17
+ measurement now stays on screen"). With something picked that the yellow + measures, the dimension it would make is
18
+ drawn as it measures now: a faded grey number with a dashed outline (the "ghost", render/draw.ts). It's a readout,
19
+ not a tap target: it sits on what it measures (a line's middle, a wall's cabinets), and a press there is for that —
20
+ the bar's + sets it. The bar's Make reference keeps it, as a reference dimension (never drives the drawing) that
21
+ stays on screen (`Constraint.kept`), solid grey in brackets. Only new ones: a dimension that drives stays driving. A reference the
22
+ drawing already set (one that would over-define it) shows only while selected, so it's drawn as a ghost too, and
23
+ Make reference keeps it. Everywhere (BOLDR's app and web), and in a drawing opened view-only, where a tap picks one
24
+ thing to read its size (readOnlyTap) and nothing can be kept.
25
+ */
26
+
27
+ /**
28
+ * What the grey number is for: the dimension the + would make for the selection (`id` unset), or the reference already
29
+ * there for it that isn't kept. Null with nothing to show: a dimension already measured or kept there, nothing the +
30
+ * measures this way, the wall view, Annotate, another tool, the keypad up (it draws what it's setting), dimensions
31
+ * hidden, or a page whose sizes are still placeholders (rule 15).
32
+ */
33
+ export function refTarget(): { c: Constraint; id?: string } | null {
34
+ if (ed.mode === 'annotate' || ed.tool !== 'select' || elevOpen() || keypadOpen() || !settings.layers.dims || noScaleYet()) return null;
35
+ const d = selectionDim();
36
+ if (!d) return null;
37
+ const sk = SK();
38
+ if (!d.existing) return { c: { ...d.c, value: measure(sk, d.c) } };
39
+ const e = sk.cons[d.existing];
40
+ return e.driving === false && !e.kept ? { c: e, id: d.existing } : null;
41
+ }
42
+
43
+ /** The dimension the grey number shows, when it isn't one of the drawing's yet. */
44
+ export function ghostDim(): Constraint | null {
45
+ const t = refTarget();
46
+ return t && !t.id ? t.c : null;
47
+ }
48
+
49
+ /** Whether the bar offers Make reference: something to keep, in a drawing that can be edited. */
50
+ export const makeRefVisible = (): boolean => !ed.readOnly && !!refTarget();
51
+
52
+ /** Make reference: the grey number kept, as a reference dimension that stays on screen. One undo step. */
53
+ export function makeReference(): void {
54
+ const t = refTarget();
55
+ if (!t || ed.readOnly) return;
56
+ pushHistory();
57
+ const sk = SK();
58
+ if (t.id) sk.cons[t.id].kept = true;
59
+ else addCons(sk, { ...t.c, driving: false, kept: true });
60
+ afterEdit();
61
+ }
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);
@@ -58,6 +58,11 @@ export interface Constraint {
58
58
  value?: number;
59
59
  /** false = reference dimension (shown in parentheses, never drives geometry) */
60
60
  driving?: boolean;
61
+ /**
62
+ * a reference made by hand (the bar's Make reference: editor/refDim.ts): it stays on screen, as an entered dimension
63
+ * does. Any other reference — one the drawing already set — shows only while it or what it measures is selected.
64
+ */
65
+ kept?: boolean;
61
66
  /**
62
67
  * label offset from the measured span, across it (a distance between parallel lines or walls: along them); a
63
68
  * diameter's, how far past the circle the label sits; an angle's, its arc's radius once pulled off its corner
@@ -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) => {
package/src/react/host.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { applyReading, laser } from '../editor/laser';
1
+ import { applyReading, laser, readingOverwrites } from '../editor/laser';
2
2
  import { store } from '../model/store';
3
3
  import { closeSurveyRun, setSurveyCursor, takeSurveyPoint } from '../tools/survey';
4
4
  import { notify } from './store';
@@ -33,6 +33,16 @@ export function laserReading(inches: number, capture?: unknown): boolean {
33
33
  return true;
34
34
  }
35
35
 
36
+ /**
37
+ * Whether a reading of `inches`, passed to laserReading now, would replace a length or diameter already measured on
38
+ * the selected line, wall edge or curve (see readingOverwrites), for a host that warns first: hold the reading, ask,
39
+ * and pass it on only once the person agrees. Nothing changes by asking. False when no drawing is open to take it.
40
+ */
41
+ export function laserReadingOverwrites(inches: number): boolean {
42
+ if (!editorHost.open || !store.project || !Number.isFinite(inches) || inches <= 0) return false;
43
+ return readingOverwrites(inches);
44
+ }
45
+
36
46
  /**
37
47
  * A point measured by a templating device (BOLDR's T1R), in inches, on the plan's axes: x to the right, y UP. Turning
38
48
  * the device's own frame into that is the host's job, and it's where the handedness goes: a device whose angle turns