@reekon-tools/boldr-cad 1.5.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 (185) hide show
  1. package/README.md +12 -1
  2. package/dist/app/state.d.ts +3 -3
  3. package/dist/data/provider.d.ts +2 -1
  4. package/dist/data/sync.js +4 -1
  5. package/dist/data/sync.js.map +1 -1
  6. package/dist/editor/actions.js +122 -19
  7. package/dist/editor/actions.js.map +1 -1
  8. package/dist/editor/advance.d.ts +2 -0
  9. package/dist/editor/advance.js +23 -0
  10. package/dist/editor/advance.js.map +1 -0
  11. package/dist/editor/dimSize.d.ts +13 -0
  12. package/dist/editor/dimSize.js +50 -0
  13. package/dist/editor/dimSize.js.map +1 -0
  14. package/dist/editor/gestures.js +30 -7
  15. package/dist/editor/gestures.js.map +1 -1
  16. package/dist/editor/keypad.d.ts +8 -0
  17. package/dist/editor/keypad.js +15 -1
  18. package/dist/editor/keypad.js.map +1 -1
  19. package/dist/editor/keys.js +2 -1
  20. package/dist/editor/keys.js.map +1 -1
  21. package/dist/editor/laser.d.ts +13 -2
  22. package/dist/editor/laser.js +42 -14
  23. package/dist/editor/laser.js.map +1 -1
  24. package/dist/editor/linkActions.d.ts +29 -11
  25. package/dist/editor/linkActions.js +76 -29
  26. package/dist/editor/linkActions.js.map +1 -1
  27. package/dist/editor/links.d.ts +13 -0
  28. package/dist/editor/links.js +7 -1
  29. package/dist/editor/links.js.map +1 -1
  30. package/dist/editor/mode.d.ts +2 -1
  31. package/dist/editor/mode.js +7 -5
  32. package/dist/editor/mode.js.map +1 -1
  33. package/dist/editor/ports.d.ts +9 -0
  34. package/dist/editor/ports.js +2 -0
  35. package/dist/editor/ports.js.map +1 -1
  36. package/dist/editor/selection.js +1 -1
  37. package/dist/editor/selection.js.map +1 -1
  38. package/dist/editor/tooltips.js +1 -0
  39. package/dist/editor/tooltips.js.map +1 -1
  40. package/dist/index.d.ts +2 -1
  41. package/dist/index.js +2 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/model/clockwise.d.ts +11 -0
  44. package/dist/model/clockwise.js +123 -0
  45. package/dist/model/clockwise.js.map +1 -0
  46. package/dist/model/constraints.js +2 -0
  47. package/dist/model/constraints.js.map +1 -1
  48. package/dist/model/geom.d.ts +2 -0
  49. package/dist/model/geom.js +11 -0
  50. package/dist/model/geom.js.map +1 -1
  51. package/dist/model/links.d.ts +6 -1
  52. package/dist/model/links.js +6 -1
  53. package/dist/model/links.js.map +1 -1
  54. package/dist/model/scale.js +3 -0
  55. package/dist/model/scale.js.map +1 -1
  56. package/dist/model/settings.d.ts +2 -0
  57. package/dist/model/settings.js +1 -1
  58. package/dist/model/settings.js.map +1 -1
  59. package/dist/model/sketch.d.ts +5 -1
  60. package/dist/model/sketch.js +19 -18
  61. package/dist/model/sketch.js.map +1 -1
  62. package/dist/model/trim.d.ts +54 -0
  63. package/dist/model/trim.js +219 -0
  64. package/dist/model/trim.js.map +1 -0
  65. package/dist/model/types.d.ts +24 -2
  66. package/dist/react/BottomBar.js +94 -25
  67. package/dist/react/BottomBar.js.map +1 -1
  68. package/dist/react/CadEditor.js +18 -9
  69. package/dist/react/CadEditor.js.map +1 -1
  70. package/dist/react/Drawer.js +3 -2
  71. package/dist/react/Drawer.js.map +1 -1
  72. package/dist/react/PlanCanvas.native.js +15 -9
  73. package/dist/react/PlanCanvas.native.js.map +1 -1
  74. package/dist/react/PlanCanvas.web.js +17 -5
  75. package/dist/react/PlanCanvas.web.js.map +1 -1
  76. package/dist/react/ShapeMenu.js +3 -2
  77. package/dist/react/ShapeMenu.js.map +1 -1
  78. package/dist/react/ToolTip.js +3 -2
  79. package/dist/react/ToolTip.js.map +1 -1
  80. package/dist/react/TopBar.js +4 -3
  81. package/dist/react/TopBar.js.map +1 -1
  82. package/dist/react/editorBox.d.ts +8 -0
  83. package/dist/react/editorBox.js +10 -0
  84. package/dist/react/editorBox.js.map +1 -0
  85. package/dist/react/host.d.ts +7 -1
  86. package/dist/react/host.js +12 -2
  87. package/dist/react/host.js.map +1 -1
  88. package/dist/react/pageLock.native.d.ts +2 -0
  89. package/dist/react/pageLock.native.js +2 -0
  90. package/dist/react/pageLock.native.js.map +1 -1
  91. package/dist/react/pageLock.web.d.ts +6 -0
  92. package/dist/react/pageLock.web.js +12 -3
  93. package/dist/react/pageLock.web.js.map +1 -1
  94. package/dist/react/ports.js +10 -4
  95. package/dist/react/ports.js.map +1 -1
  96. package/dist/react/saving.d.ts +7 -1
  97. package/dist/react/saving.js +10 -2
  98. package/dist/react/saving.js.map +1 -1
  99. package/dist/react/store.d.ts +5 -2
  100. package/dist/react/store.js +4 -1
  101. package/dist/react/store.js.map +1 -1
  102. package/dist/render/draw.d.ts +7 -0
  103. package/dist/render/draw.js +41 -15
  104. package/dist/render/draw.js.map +1 -1
  105. package/dist/render/linkTile.js +2 -2
  106. package/dist/render/linkTile.js.map +1 -1
  107. package/dist/render/navigate.d.ts +11 -6
  108. package/dist/render/navigate.js +34 -20
  109. package/dist/render/navigate.js.map +1 -1
  110. package/dist/render/snap.d.ts +5 -0
  111. package/dist/render/snap.js +84 -14
  112. package/dist/render/snap.js.map +1 -1
  113. package/dist/render/view.js +7 -2
  114. package/dist/render/view.js.map +1 -1
  115. package/dist/solver/equations.js +30 -16
  116. package/dist/solver/equations.js.map +1 -1
  117. package/dist/tools/dim.d.ts +6 -0
  118. package/dist/tools/dim.js +28 -4
  119. package/dist/tools/dim.js.map +1 -1
  120. package/dist/tools/modify.d.ts +32 -4
  121. package/dist/tools/modify.js +247 -97
  122. package/dist/tools/modify.js.map +1 -1
  123. package/dist/tools/registry.d.ts +2 -0
  124. package/dist/tools/registry.js +8 -0
  125. package/dist/tools/registry.js.map +1 -1
  126. package/dist/tools/select.d.ts +11 -8
  127. package/dist/tools/select.js +51 -19
  128. package/dist/tools/select.js.map +1 -1
  129. package/package.json +1 -1
  130. package/src/app/state.ts +3 -3
  131. package/src/data/provider.ts +2 -2
  132. package/src/data/sync.ts +4 -1
  133. package/src/editor/actions.ts +98 -16
  134. package/src/editor/advance.ts +23 -0
  135. package/src/editor/dimSize.ts +53 -0
  136. package/src/editor/gestures.ts +31 -9
  137. package/src/editor/keypad.ts +17 -1
  138. package/src/editor/keys.ts +2 -1
  139. package/src/editor/laser.ts +45 -14
  140. package/src/editor/linkActions.ts +87 -26
  141. package/src/editor/links.ts +17 -1
  142. package/src/editor/mode.ts +7 -5
  143. package/src/editor/ports.ts +10 -0
  144. package/src/editor/selection.ts +1 -1
  145. package/src/editor/tooltips.ts +1 -0
  146. package/src/index.ts +2 -2
  147. package/src/model/clockwise.ts +121 -0
  148. package/src/model/constraints.ts +1 -0
  149. package/src/model/geom.ts +10 -0
  150. package/src/model/links.ts +6 -1
  151. package/src/model/scale.ts +2 -0
  152. package/src/model/settings.ts +3 -1
  153. package/src/model/sketch.ts +14 -7
  154. package/src/model/trim.ts +188 -0
  155. package/src/model/types.ts +19 -2
  156. package/src/react/BottomBar.tsx +109 -25
  157. package/src/react/CadEditor.tsx +19 -9
  158. package/src/react/Drawer.tsx +3 -2
  159. package/src/react/PlanCanvas.native.tsx +25 -12
  160. package/src/react/PlanCanvas.web.tsx +17 -5
  161. package/src/react/ShapeMenu.tsx +3 -2
  162. package/src/react/ToolTip.tsx +3 -2
  163. package/src/react/TopBar.tsx +4 -3
  164. package/src/react/editorBox.ts +20 -0
  165. package/src/react/host.ts +12 -2
  166. package/src/react/pageLock.native.ts +2 -0
  167. package/src/react/pageLock.web.ts +12 -3
  168. package/src/react/ports.ts +10 -4
  169. package/src/react/saving.ts +10 -2
  170. package/src/react/store.ts +5 -2
  171. package/src/render/draw.ts +38 -16
  172. package/src/render/linkTile.ts +2 -2
  173. package/src/render/navigate.ts +31 -20
  174. package/src/render/snap.ts +80 -15
  175. package/src/render/view.ts +6 -2
  176. package/src/solver/equations.ts +30 -16
  177. package/src/tools/dim.ts +28 -4
  178. package/src/tools/modify.ts +205 -69
  179. package/src/tools/registry.ts +8 -0
  180. package/src/tools/select.ts +47 -20
  181. package/src/ui/domPorts.ts +2 -0
  182. package/dist/react/LaserTray.d.ts +0 -4
  183. package/dist/react/LaserTray.js +0 -26
  184. package/dist/react/LaserTray.js.map +0 -1
  185. package/src/react/LaserTray.tsx +0 -42
@@ -3,20 +3,20 @@ import { ed } from '../app/state';
3
3
  import { clamp } from '../model/geom';
4
4
  import { pushHistory } from '../model/history';
5
5
  import { uid } from '../model/ids';
6
- import { isLink, LINK_COLOR, LINK_MAX_IN, LINK_MIN_IN, LINK_TILE_PX, linkInfoOf, linkName, linkSide, linkSnapshot } from '../model/links';
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
8
  import { 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, Underlay } from '../model/types';
13
+ import type { LinkTarget, Note, 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';
17
17
  import { elevOpen } from './elevation';
18
18
  import { is3d } from './view3dState';
19
- import { canLink, linksHost, openLink, pickLinks } from './links';
19
+ import { besideOpen, canLink, canOpenBeside, linksHost, openLink, openLinkBeside, pickLinks } from './links';
20
20
  import { toast } from './ports';
21
21
 
22
22
  /*
@@ -68,6 +68,8 @@ export async function addLinks(): Promise<void> {
68
68
  let k = 0;
69
69
  while (k < 40 && spots.some(([x, y]) => taken(x + k * step, y - k * step))) k++;
70
70
  pushHistory();
71
+ // (the page's first tiles: the size they're placed at is their 100%)
72
+ if (pageLinkSize(sk) == null) sk.tileRef = ts;
71
73
  const ids = picked.map((t, i) => {
72
74
  const n: Note = { id: uid('lk'), type: 'link', x: spots[i][0] + k * step, y: spots[i][1] - k * step, ts, color: LINK_COLOR, link: linkSnapshot(t) };
73
75
  sk.notes.push(n);
@@ -83,7 +85,9 @@ const LINK_GAP_PX = 16;
83
85
 
84
86
  /*
85
87
  Every tile on a page is one size, on the drawing (so it zooms with the plan and prints at that size): the Tile size
86
- slider above the bar, shown while a tile is selected, sizes them all together.
88
+ slider above the bar, shown while a tile is selected, sizes them all together. It says how big in percent of the size
89
+ they were first placed at (the user's call, 2026-11-03): a tile has no real size, and before the page's first length
90
+ its inches are only placeholders, so feet and metres said nothing true.
87
91
  */
88
92
 
89
93
  /** The size of the page's tiles, inches on the drawing (null: it has none). */
@@ -99,21 +103,44 @@ export function linkSizeVisible(): boolean {
99
103
  return ed.sel.some(x => x.type === 'note' && sk.notes.find(n => n.id === x.id)?.type === 'link');
100
104
  }
101
105
 
106
+ /** What the page's tiles call 100%, inches on the drawing: the size they were first placed at (a page from before: theirs now). */
107
+ const tileRef = (sk: Sketch): number => sk.tileRef ?? pageLinkSize(sk) ?? LINK_SIDE_IN;
108
+
109
+ /** How big the page's tiles are, in percent of the size they were first placed at (null: it has none). */
110
+ export function pageTilePct(sk = SK()): number | null {
111
+ const s = pageLinkSize(sk);
112
+ return s == null ? null : Math.round((s / tileRef(sk)) * 100);
113
+ }
114
+
115
+ /** The slider's range, percent. */
116
+ export const TILE_PCT_MIN = 10, TILE_PCT_MAX = 1000;
117
+
102
118
  /**
103
- * Size every tile on the page (inches, to the quarter, from LINK_MIN_IN to LINK_MAX_IN), drawn at once — for the slider
104
- * as it moves. The caller makes the undo step before, and finishes the edit after.
119
+ * Size every tile on the page to `pct` percent (TILE_PCT_MIN to TILE_PCT_MAX; and never past LINK_HARD_MIN_IN to
120
+ * LINK_HARD_MAX_IN on the drawing — placing's 6"–40' bounds would cut the range short), drawn at once — for the slider as
121
+ * it moves. The caller makes the undo step before, and finishes the edit after. (A page from before keeps its 100% from
122
+ * here on.)
105
123
  */
106
- export function setLinkSize(inches: number): void {
107
- const v = Math.round(clamp(inches, LINK_MIN_IN, LINK_MAX_IN) * 4) / 4;
108
- for (const n of SK().notes) if (isLink(n)) n.ts = v;
124
+ export function setTilePct(pct: number): void {
125
+ const sk = SK(), ref = tileRef(sk), v = clamp((ref * clamp(pct, TILE_PCT_MIN, TILE_PCT_MAX)) / 100, LINK_HARD_MIN_IN, LINK_HARD_MAX_IN);
126
+ sk.tileRef = ref;
127
+ for (const n of sk.notes) if (isLink(n)) n.ts = v;
109
128
  requestDraw();
110
129
  }
111
130
 
112
- /** The slider runs along a log scale (a few inches to many feet, each step as noticeable as the last): 0 to SIZE_STEPS. */
131
+ /**
132
+ * The slider runs along a log scale (each step as noticeable as the last), 0 to SIZE_STEPS, 100% in the middle; it
133
+ * catches at 100%, so the size they came in at is easy to find again, and says round numbers: whole percents to 200%,
134
+ * then fives, then tens from 500%.
135
+ */
113
136
  export const SIZE_STEPS = 1000;
114
- const LOG_MIN = Math.log(LINK_MIN_IN), LOG_SPAN = Math.log(LINK_MAX_IN) - LOG_MIN;
115
- export const sizeToStep = (inches: number): number => Math.round(((Math.log(clamp(inches, LINK_MIN_IN, LINK_MAX_IN)) - LOG_MIN) / LOG_SPAN) * SIZE_STEPS);
116
- export const stepToSize = (step: number): number => Math.exp(LOG_MIN + (clamp(step, 0, SIZE_STEPS) / SIZE_STEPS) * LOG_SPAN);
137
+ const LOG_MIN = Math.log(TILE_PCT_MIN), LOG_SPAN = Math.log(TILE_PCT_MAX) - LOG_MIN;
138
+ export const pctToStep = (pct: number): number => Math.round(((Math.log(clamp(pct, TILE_PCT_MIN, TILE_PCT_MAX)) - LOG_MIN) / LOG_SPAN) * SIZE_STEPS);
139
+ export function stepToPct(step: number): number {
140
+ const p = Math.exp(LOG_MIN + (clamp(step, 0, SIZE_STEPS) / SIZE_STEPS) * LOG_SPAN);
141
+ if (Math.abs(p - 100) < 4) return 100;
142
+ return p >= 500 ? Math.round(p / 10) * 10 : p >= 200 ? Math.round(p / 5) * 5 : Math.round(p);
143
+ }
117
144
 
118
145
  /** A tile's leader on or off. On, it points out beside the tile (to the right, or the left near the screen's edge). */
119
146
  export function toggleLeader(id: string): void {
@@ -129,27 +156,57 @@ export function toggleLeader(id: string): void {
129
156
  afterEdit();
130
157
  }
131
158
 
159
+ /*
160
+ Open, for what a link is for: the host's own way (another screen, a sheet over the drawing), or — the user's call,
161
+ 2026-10-30 — in a pane beside the drawing, a split view, where the host has one (CadLinks.openBeside). The bar's Open
162
+ asks which while no pane is open (opensWithChoice); once one is, Open goes to it.
163
+ */
164
+
165
+ /** How Open opens: `here`, the host's own way; `beside`, in a pane beside the drawing; `auto`, beside if one's open. */
166
+ export type OpenHow = 'auto' | 'here' | 'beside';
167
+
132
168
  /** Open what the selected tile, or the selected expanded photo, is for. */
133
- export function openSelectedLink(): void {
169
+ export function openSelectedLink(how: OpenHow = 'auto'): void {
170
+ const t = selectedLink()?.link ?? selectedLinkedImage()?.link;
171
+ if (t) openLinkTarget(t, how);
172
+ }
173
+
174
+ /** What the bar's Open is for: the selected tile's or expanded photo's link, or the selected linked dimension's. */
175
+ export function selectedOpenTarget(): LinkTarget | null {
134
176
  const t = selectedLink()?.link ?? selectedLinkedImage()?.link;
135
- if (t) openTarget(t);
177
+ if (t) return t;
178
+ const s = ed.sel;
179
+ return s.length === 1 && s[0].type === 'dim' ? SK().cons[s[0].id]?.measurementRef ?? null : null;
180
+ }
181
+
182
+ /** Whether Open asks how to open `t`: the host can show it beside the drawing, and no pane's open yet. */
183
+ export function opensWithChoice(t: LinkTarget): boolean {
184
+ return canOpenLink(t) && canOpenBeside(t) && !besideOpen();
136
185
  }
137
186
 
138
- function openTarget(t: LinkTarget): void {
139
- if (!canOpenLink(t)) { toast(linkInfoOf(t)?.exists === false ? 'That’s been removed from the job, so it can’t be opened.' : 'That can’t be opened from here.', { warn: true }); return; }
140
- openLink(t);
187
+ /** Open what `t` is for, `how` it's asked to (see OpenHow); one that's gone, or can't be, says so instead. */
188
+ export function openLinkTarget(t: LinkTarget, how: OpenHow = 'auto'): void {
189
+ if (!canOpenLink(t)) {
190
+ const gone = linkInfoOf(t)?.exists === false;
191
+ toast(!gone ? 'That can’t be opened from here.'
192
+ : t.kind === 'measurement' ? 'That measurement has been removed from the job, so it can’t be opened.'
193
+ : 'That’s been removed from the job, so it can’t be opened.', { warn: true });
194
+ return;
195
+ }
196
+ if ((how === 'beside' || (how === 'auto' && besideOpen())) && canOpenBeside(t)) openLinkBeside(t);
197
+ else openLink(t);
141
198
  }
142
199
 
143
- /** An expanded photo's badge, tapped: what it's for opens. */
200
+ /** An expanded photo's badge, tapped: what it's for opens (beside the drawing, if a pane's open there). */
144
201
  export function openImageLink(id: string): void {
145
202
  const t = imageById(id)?.link;
146
- if (t) openTarget(t);
203
+ if (t) openLinkTarget(t);
147
204
  }
148
205
 
149
206
  /*
150
207
  A photo's tile expands into the photo whole, and back (the user's call, 2026-10-26: "for image objects … you should
151
208
  be able to expand and condense the tile … this is how skia works, it expands the image to full size + shows a link
152
- icon in the corner"). Expanded, it's one of the page's images — moved, sized, stretched, turned, faded and locked as
209
+ icon in the corner"). Expanded, it's one of the page's images — moved, sized, cropped, turned, faded and locked as
153
210
  any is — with the link's badge in its corner; Expand in the bar (on) folds it back into its tile where it is. An image
154
211
  from the computer has no link, so none of this. Each way is one undo step, and what was selected stays selected.
155
212
  */
@@ -198,6 +255,8 @@ export function collapseLink(id: string): void {
198
255
  const n: Note = { ...(was ?? { id: '', type: 'link', color: LINK_COLOR }), type: 'link', x: m.x, y: m.y, ts, link: u.link };
199
256
  if (!n.id || sk.notes.some(x => x.id === n.id)) n.id = uid('lk');
200
257
  pushHistory();
258
+ // (the page's only tile, back: its size is the 100%, unless the page already has one)
259
+ if (pageLinkSize(sk) == null && sk.tileRef == null) sk.tileRef = ts;
201
260
  page().underlays.splice(page().underlays.indexOf(u), 1);
202
261
  sk.notes.push(n);
203
262
  ed.sel = [{ type: 'note', id: n.id }];
@@ -220,7 +279,11 @@ export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
220
279
  askConfirm({
221
280
  title: 'Open ' + linkName(t) + '?',
222
281
  msg: where ? 'It’s in ' + where + '.' : undefined,
223
- choices: [{ label: 'Open', run: () => openLink(t) }, { label: 'Cancel', run: () => {} }],
282
+ choices: [
283
+ { label: 'Open', run: () => openLinkTarget(t, 'here') },
284
+ ...(canOpenBeside(t) ? [{ label: 'Open side by side', run: () => openLinkTarget(t, 'beside') }] : []),
285
+ { label: 'Cancel', run: () => {} },
286
+ ],
224
287
  });
225
288
  }
226
289
 
@@ -234,11 +297,9 @@ export function unlinkDim(id: string): void {
234
297
  }
235
298
 
236
299
  /** Open the measurement a dimension is linked to. */
237
- export function openDimLink(id: string): void {
300
+ export function openDimLink(id: string, how: OpenHow = 'auto'): void {
238
301
  const t = SK().cons[id]?.measurementRef;
239
- if (!t) return;
240
- if (!canOpenLink(t)) { toast(linkInfoOf(t)?.exists === false ? 'That measurement has been removed from the job, so it can’t be opened.' : 'That can’t be opened from here.', { warn: true }); return; }
241
- openLink(t);
302
+ if (t) openLinkTarget(t, how);
242
303
  }
243
304
 
244
305
  /**
@@ -1,4 +1,4 @@
1
- import { linkInfo, linkKey, linkTargets } from '../model/links';
1
+ import { linkInfo, linkInfoOf, linkKey, linkTargets } from '../model/links';
2
2
  import { onSessionReset } from '../model/session';
3
3
  import { store } from '../model/store';
4
4
  import type { LinkInfo, LinkTarget } from '../model/types';
@@ -67,6 +67,13 @@ export interface CadLinks {
67
67
  subscribe(targets: LinkTarget[], cb: (info: Record<string, LinkInfo>) => void): () => void;
68
68
  /** Open what `target` is for (another screen, or a sheet over the drawing). */
69
69
  open(target: LinkTarget): void;
70
+ /**
71
+ * Open what `target` is for in a pane beside the drawing (a split view), the drawing staying as it is. With this, the
72
+ * bar's Open asks how — Open, or Open side by side — until a pane is open (besideOpen), when Open goes to it.
73
+ */
74
+ openBeside?(target: LinkTarget): void;
75
+ /** Whether a pane is open beside the drawing now (with openBeside). */
76
+ besideOpen?(): boolean;
70
77
  /** Change a linked measurement (micrometres, or degrees), the person having said to; `capture` as saveMeasurement's. */
71
78
  update?(target: LinkTarget, value: number, capture?: unknown): void;
72
79
  /** Save a dimension as a new measurement in the drawing's own group; resolves with it (null: not saved). */
@@ -179,3 +186,12 @@ export async function pickJobMeasurement(kind: MeasurementKind): Promise<LinkTar
179
186
 
180
187
  /** Open what a link is for. */
181
188
  export function openLink(t: LinkTarget): void { host?.open(t); }
189
+
190
+ /** Open what a link is for in a pane beside the drawing (CadLinks.openBeside). */
191
+ export function openLinkBeside(t: LinkTarget): void { host?.openBeside?.(t); }
192
+
193
+ /** Whether the host can show `t` in a pane beside the drawing. */
194
+ export function canOpenBeside(t: LinkTarget): boolean { return !!host?.openBeside && linkInfoOf(t)?.canOpenBeside !== false; }
195
+
196
+ /** Whether a pane is open beside the drawing now. */
197
+ export function besideOpen(): boolean { return !!host?.besideOpen?.(); }
@@ -58,11 +58,12 @@ export const railIcon = (id: string): string => WALL_ICON[id] ?? id;
58
58
  /** The tool rail for each mode. 'toggle' items switch something on and off; 'act' items run an action. */
59
59
  export const RAIL: Record<Mode, RailItem[]> = {
60
60
  draft: [
61
- // drawing; then the building (walls, what goes in them, spaces); then guides
62
- ['select', 'Select', 'V'], ['line', 'Line', 'L'], ['freehand', 'Draw to shape', 'S'], ['rect', 'Rectangle', 'R'], ['circle', 'Circle', 'C'], ['arc', 'Arc', 'A'], '|',
61
+ // drawing, and Trim last of it (the user's call, 2026-11-03); then the building (walls, what goes in them, spaces);
62
+ // then guides
63
+ ['select', 'Select', 'V'], ['line', 'Line', 'L'], ['freehand', 'Draw to shape', 'S'], ['rect', 'Rectangle', 'R'], ['circle', 'Circle', 'C'], ['arc', 'Arc', 'A'], ['trim', 'Trim', 'T'], '|',
63
64
  ['door', 'Door', 'B'], ['window', 'Window', 'N'], ['room', 'Space', 'M'], '|',
64
- // Trim (T), Offset (O) and Dimension (D) stay on the keyboard; Offset is also in the bar for a selected line, and
65
- // measuring is the yellow + for what's selected (the user's call, 2026-10-24: no Dimension on the rail).
65
+ // Offset (O) and Dimension (D) stay on the keyboard; Offset is also in the bar for a selected line, and measuring is
66
+ // the yellow + for what's selected (the user's call, 2026-10-24: no Dimension on the rail).
66
67
  // The toggles together: what's drawn is walls, or guides (one or the other, never both)
67
68
  ['wall', 'Walls', 'W', 'toggle'], ['constr', 'Construction lines', 'X', 'toggle'], '|',
68
69
  ['undo', 'Undo', '⌘Z', 'act'], ['redo', 'Redo', '⇧⌘Z', 'act'],
@@ -79,7 +80,8 @@ export const RAIL: Record<Mode, RailItem[]> = {
79
80
  /**
80
81
  * Tools that share one place on a phone's rail, which has no room for each: a tap draws with the one used last (the
81
82
  * place shows it), and a long press opens a menu of them all (react/ShapeMenu.tsx) — Draft's Line, Rectangle, Circle
82
- * and Arc, and Annotate's markup shapes. A tablet's or a browser's rail shows every one in its own place.
83
+ * and Arc, and Annotate's markup shapes. A tablet's or a browser's rail shows every one in its own place. (Trim isn't
84
+ * one of them: on a phone it has its own place, under Draw to shape — the user's call, 2026-11-03.)
83
85
  */
84
86
  export const SHAPE_TOOLS = ['line', 'rect', 'circle', 'arc'];
85
87
  export const SHAPES_GROUP = 'shapes';
@@ -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();
@@ -103,7 +103,7 @@ export function selectionHint(): string {
103
103
  if (s.n === 1 && s.image) {
104
104
  // (a photo of the job's, expanded from its tile: its link opens it, and Expand folds it back)
105
105
  const u = imageById(ed.sel[0].id), what = u?.link ? '<b>Photo</b>' : '<b>Image</b>', link = u?.link ? ' · its link opens it' : '';
106
- return u?.locked ? what + ' · locked — tap Unlock to move or resize it' + link : what + ' · drag to move · corners scale, side handles stretch' + link;
106
+ return u?.locked ? what + ' · locked — tap Unlock to move or resize it' + link : what + ' · drag to move · corners scale, side handles crop' + link;
107
107
  }
108
108
  if (s.n === 1 && s.notes.length && SK().notes.find(n => n.id === s.notes[0])?.type === 'link') return '<b>Link</b> · drag to move · open it, or point it at the drawing with a leader, below · Size sizes every tile on the page';
109
109
  if (s.notes.length) return 'Drag to move. Turn a pen stroke into lines below.';
@@ -15,6 +15,7 @@ const TIPS: Record<string, string> = {
15
15
  rect: 'Tap or drag corner to corner.',
16
16
  circle: 'Tap the center, then the edge.',
17
17
  arc: 'Tap both ends, then drag to bend.',
18
+ trim: 'Tap or drag across the pieces to cut away.',
18
19
  door: 'Tap a wall to add a 3\'-0" door.',
19
20
  window: 'Tap a wall to add a 3\'-0" window.',
20
21
  room: 'Tap inside a closed shape.',
package/src/index.ts CHANGED
@@ -10,8 +10,8 @@ export type { CadDataProvider, CadFileMeta, CadMeasurements, CadMeasurementValue
10
10
  export type { Unit } from './units';
11
11
  export { memoryProvider } from './data/memory';
12
12
  export { setFileSaver } from './react/ports';
13
- export { laserReading, setLaserConnected, surveyClose, surveyCursor, surveyPoint } from './react/host';
14
- export type { HostKeypad, HostKeypadExtra, HostKeypadRequest, MeasurementList, MeasurementListPage } from './react/hostKeypad';
13
+ export { BESIDE as CAD_BESIDE } from './react/pageLock';
14
+ export { laserReading, laserReadingOverwrites, setLaserConnected, surveyClose, surveyCursor, surveyPoint } from './react/host';export type { HostKeypad, HostKeypadExtra, HostKeypadRequest, MeasurementList, MeasurementListPage } from './react/hostKeypad';
15
15
  export type { CadLinkPick, CadLinks, CadSaveMeasurement, MeasurementKind } from './editor/links';
16
16
  export type { LinkInfo, LinkTarget } from './model/types';
17
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
+ }
@@ -16,6 +16,7 @@ export function consLabel(c: Constraint): string {
16
16
  if (c.type === 'dist' || c.type === 'pldist') return (c.type === 'dist' ? (c.line ? 'Length ' : 'Distance ') : 'Offset ') + fmtLen(c.value!);
17
17
  if (c.type === 'diam') return c.rad ? 'Radius ' + fmtLen(c.value! / 2) : 'Diameter ' + fmtLen(c.value!);
18
18
  if (c.type === 'angle') return 'Angle ' + fmtAng(c.value!);
19
+ if (c.type === 'online' && c.face) return 'Point on face';
19
20
  return CONS_LABEL[c.type] || c.type;
20
21
  }
21
22
 
package/src/model/geom.ts CHANGED
@@ -34,6 +34,16 @@ export function lineCircleT(A: XY, B: XY, c: XY, r: number): number[] {
34
34
  return [(-b - s) / (2 * a), (-b + s) / (2 * a)].filter(t => t > 1e-6 && t < 1 - 1e-6);
35
35
  }
36
36
 
37
+ /** Where circle (c1, r1) crosses circle (c2, r2): none, one where they touch, or two. */
38
+ export function circleCircle(c1: XY, r1: number, c2: XY, r2: number): XY[] {
39
+ const dx = c2.x - c1.x, dy = c2.y - c1.y, d = hyp(dx, dy);
40
+ if (d < 1e-12 || d > r1 + r2 + 1e-9 || d < Math.abs(r1 - r2) - 1e-9) return [];
41
+ const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d), h2 = r1 * r1 - a * a, h = h2 > 0 ? Math.sqrt(h2) : 0;
42
+ const mx = c1.x + (dx * a) / d, my = c1.y + (dy * a) / d;
43
+ if (h < 1e-12) return [{ x: mx, y: my }];
44
+ return [{ x: mx - (dy * h) / d, y: my + (dx * h) / d }, { x: mx + (dy * h) / d, y: my - (dx * h) / d }];
45
+ }
46
+
37
47
  /** Circumcenter of three points, or null if they are collinear. */
38
48
  export function circum(a: XY, b: XY, c: XY): XY | null {
39
49
  const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
@@ -22,8 +22,13 @@ export const isLink = (n: Note): n is Note & { link: LinkTarget } => n.type ===
22
22
 
23
23
  /** How big a tile is on the screen as it's placed (points): its side on the drawing is what that is at the zoom then. */
24
24
  export const LINK_TILE_PX = 96;
25
- /** How big a tile can be on the drawing, inches; unset (never, once placed), this. */
25
+ /** How big a tile can be on the drawing as it's placed, inches; unset (never, once placed), this. */
26
26
  export const LINK_MIN_IN = 6, LINK_MAX_IN = 480, LINK_SIDE_IN = 24;
27
+ /**
28
+ * How big the Tile size slider can make one, inches: wide enough that its 10%–1000% of the size tiles came in at is
29
+ * there to reach, and inside what the tile's drawing clips to (render/linkTile.ts: clipToBar).
30
+ */
31
+ export const LINK_HARD_MIN_IN = 1, LINK_HARD_MAX_IN = 2400;
27
32
  /**
28
33
  * On the screen, points: from LINK_FULL_PX across a tile shows its picture or icon and its name; under LINK_PIN_PX it's
29
34
  * a pin of that size, however far out the drawing is.
@@ -47,6 +47,8 @@ export function scaleSketch(sk: Sketch, k: number): void {
47
47
  // (a note's letters and a tile's side are inches on the drawing: they zoom with it, so they scale with it)
48
48
  if (n.ts != null) n.ts *= k;
49
49
  }
50
+ // (and what the tiles' 100% is, so their percent holds)
51
+ if (sk.tileRef != null) sk.tileRef *= k;
50
52
  for (const c of Object.values(sk.cons)) {
51
53
  if (c.off != null) c.off *= k;
52
54
  // (none should be here before the first length; one that is keeps measuring what it measures, met as it was)
@@ -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);
@@ -321,23 +321,30 @@ export function restorePos(sk: Sketch, s: PosSnapshot): void {
321
321
  for (const id in s.r) if (sk.circles[id]) sk.circles[id].r = s.r[id];
322
322
  }
323
323
 
324
- /** Every line and arc joined end to end with the given ones (through shared points), including them. */
324
+ /**
325
+ * Every line and arc joined end to end with the given ones (through shared points), including them. Guides join only
326
+ * guides, and walls and lines only walls and lines: a guide drawn off a shape's corner isn't part of the shape, nor the
327
+ * shape part of the guides (the user's call, 2026-11-02).
328
+ */
325
329
  export function connectedGeometry(sk: Sketch, lineIds: string[], arcIds: string[] = []): { lines: string[]; arcs: string[] } {
330
+ // the points reached by walls and lines [0], and by guides [1]
331
+ const pts = [new Set<string>(), new Set<string>()], lines = new Set(lineIds), arcs = new Set(arcIds);
332
+ type End = { a: string; b: string; constr?: boolean };
326
333
  // on a wall face, the plan's corners and edges aren't part of a shape: nothing joins through them
327
- const pts = new Set<string>(), lines = new Set(lineIds), arcs = new Set(arcIds);
328
- const join = (p: string) => { if (!sk.points[p]?.anchor) pts.add(p); };
329
- lineIds.forEach(id => { const l = sk.lines[id]; if (l) { join(l.a); join(l.b); } });
330
- arcIds.forEach(id => { const a = sk.arcs[id]; if (a) { join(a.a); join(a.b); } });
334
+ const join = (o: End) => { for (const p of [o.a, o.b]) if (!sk.points[p]?.anchor) pts[+!!o.constr].add(p); };
335
+ const meets = (o: End) => pts[+!!o.constr].has(o.a) || pts[+!!o.constr].has(o.b);
336
+ lineIds.forEach(id => { const l = sk.lines[id]; if (l) join(l); });
337
+ arcIds.forEach(id => { const a = sk.arcs[id]; if (a) join(a); });
331
338
  let grow = true;
332
339
  while (grow) {
333
340
  grow = false;
334
341
  for (const id in sk.lines) {
335
342
  const l = sk.lines[id];
336
- if (!lines.has(id) && !l.anchor && !l.cab && (pts.has(l.a) || pts.has(l.b))) { lines.add(id); join(l.a); join(l.b); grow = true; }
343
+ if (!lines.has(id) && !l.anchor && !l.cab && meets(l)) { lines.add(id); join(l); grow = true; }
337
344
  }
338
345
  for (const id in sk.arcs) {
339
346
  const a = sk.arcs[id];
340
- if (!arcs.has(id) && (pts.has(a.a) || pts.has(a.b))) { arcs.add(id); join(a.a); join(a.b); grow = true; }
347
+ if (!arcs.has(id) && meets(a)) { arcs.add(id); join(a); grow = true; }
341
348
  }
342
349
  }
343
350
  return { lines: [...lines], arcs: [...arcs] };