@reekon-tools/boldr-cad 1.4.0 → 1.6.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 (311) hide show
  1. package/README.md +13 -3
  2. package/dist/app/edit.d.ts +3 -3
  3. package/dist/app/edit.js +26 -1
  4. package/dist/app/edit.js.map +1 -1
  5. package/dist/app/state.d.ts +6 -8
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +255 -181
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +4 -3
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/laser.d.ts +4 -2
  23. package/dist/editor/laser.js +4 -2
  24. package/dist/editor/laser.js.map +1 -1
  25. package/dist/editor/linkActions.d.ts +49 -12
  26. package/dist/editor/linkActions.js +164 -35
  27. package/dist/editor/linkActions.js.map +1 -1
  28. package/dist/editor/links.d.ts +13 -0
  29. package/dist/editor/links.js +7 -1
  30. package/dist/editor/links.js.map +1 -1
  31. package/dist/editor/mode.d.ts +2 -1
  32. package/dist/editor/mode.js +8 -5
  33. package/dist/editor/mode.js.map +1 -1
  34. package/dist/editor/project.js +1 -2
  35. package/dist/editor/project.js.map +1 -1
  36. package/dist/editor/scaleGate.d.ts +13 -0
  37. package/dist/editor/scaleGate.js +27 -0
  38. package/dist/editor/scaleGate.js.map +1 -0
  39. package/dist/editor/selection.js +13 -9
  40. package/dist/editor/selection.js.map +1 -1
  41. package/dist/editor/tooltips.js +10 -0
  42. package/dist/editor/tooltips.js.map +1 -1
  43. package/dist/editor/view3d.d.ts +4 -1
  44. package/dist/editor/view3d.js +6 -2
  45. package/dist/editor/view3d.js.map +1 -1
  46. package/dist/export/pdf.d.ts +5 -2
  47. package/dist/export/pdf.js +12 -8
  48. package/dist/export/pdf.js.map +1 -1
  49. package/dist/export/plan.d.ts +1 -1
  50. package/dist/export/plan.js +14 -9
  51. package/dist/export/plan.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/model/constraints.js +2 -0
  56. package/dist/model/constraints.js.map +1 -1
  57. package/dist/model/demo.d.ts +1 -0
  58. package/dist/model/demo.js +2 -1
  59. package/dist/model/demo.js.map +1 -1
  60. package/dist/model/elevation.d.ts +1 -1
  61. package/dist/model/elevation.js +3 -3
  62. package/dist/model/elevation.js.map +1 -1
  63. package/dist/model/geom.d.ts +2 -0
  64. package/dist/model/geom.js +11 -0
  65. package/dist/model/geom.js.map +1 -1
  66. package/dist/model/history.js +4 -1
  67. package/dist/model/history.js.map +1 -1
  68. package/dist/model/links.d.ts +10 -2
  69. package/dist/model/links.js +15 -3
  70. package/dist/model/links.js.map +1 -1
  71. package/dist/model/measurement.d.ts +2 -2
  72. package/dist/model/measurement.js +3 -3
  73. package/dist/model/measurement.js.map +1 -1
  74. package/dist/model/openings.d.ts +10 -0
  75. package/dist/model/openings.js +18 -0
  76. package/dist/model/openings.js.map +1 -1
  77. package/dist/model/planGeom.d.ts +37 -2
  78. package/dist/model/planGeom.js +72 -9
  79. package/dist/model/planGeom.js.map +1 -1
  80. package/dist/model/project.js +5 -2
  81. package/dist/model/project.js.map +1 -1
  82. package/dist/model/rooms.d.ts +18 -5
  83. package/dist/model/rooms.js +178 -73
  84. package/dist/model/rooms.js.map +1 -1
  85. package/dist/model/scale.d.ts +30 -0
  86. package/dist/model/scale.js +123 -0
  87. package/dist/model/scale.js.map +1 -0
  88. package/dist/model/settings.d.ts +0 -1
  89. package/dist/model/settings.js +1 -1
  90. package/dist/model/settings.js.map +1 -1
  91. package/dist/model/sketch.d.ts +26 -2
  92. package/dist/model/sketch.js +77 -22
  93. package/dist/model/sketch.js.map +1 -1
  94. package/dist/model/stroke.js +2 -2
  95. package/dist/model/trim.d.ts +54 -0
  96. package/dist/model/trim.js +219 -0
  97. package/dist/model/trim.js.map +1 -0
  98. package/dist/model/types.d.ts +62 -9
  99. package/dist/react/BottomBar.d.ts +2 -1
  100. package/dist/react/BottomBar.js +144 -46
  101. package/dist/react/BottomBar.js.map +1 -1
  102. package/dist/react/CadEditor.js +18 -9
  103. package/dist/react/CadEditor.js.map +1 -1
  104. package/dist/react/Drawer.js +6 -4
  105. package/dist/react/Drawer.js.map +1 -1
  106. package/dist/react/Duck.js.map +1 -1
  107. package/dist/react/KeypadSheet.js.map +1 -1
  108. package/dist/react/MarkupPanel.js +1 -1
  109. package/dist/react/PlanCanvas.native.js +54 -7
  110. package/dist/react/PlanCanvas.native.js.map +1 -1
  111. package/dist/react/PlanCanvas.web.js +17 -5
  112. package/dist/react/PlanCanvas.web.js.map +1 -1
  113. package/dist/react/Popovers.js +14 -3
  114. package/dist/react/Popovers.js.map +1 -1
  115. package/dist/react/Rail.js +1 -1
  116. package/dist/react/ShapeMenu.js +3 -2
  117. package/dist/react/ShapeMenu.js.map +1 -1
  118. package/dist/react/Sheet.d.ts +2 -2
  119. package/dist/react/Sheet.js.map +1 -1
  120. package/dist/react/ToolTip.js +3 -2
  121. package/dist/react/ToolTip.js.map +1 -1
  122. package/dist/react/TopBar.js +4 -3
  123. package/dist/react/TopBar.js.map +1 -1
  124. package/dist/react/barScroll.d.ts +23 -0
  125. package/dist/react/barScroll.js +47 -0
  126. package/dist/react/barScroll.js.map +1 -0
  127. package/dist/react/corners.d.ts +6 -2
  128. package/dist/react/corners.js.map +1 -1
  129. package/dist/react/editorBox.d.ts +8 -0
  130. package/dist/react/editorBox.js +10 -0
  131. package/dist/react/editorBox.js.map +1 -0
  132. package/dist/react/host.d.ts +1 -1
  133. package/dist/react/host.js +1 -1
  134. package/dist/react/host.js.map +1 -1
  135. package/dist/react/motion.d.ts +1 -1
  136. package/dist/react/motion.js +1 -1
  137. package/dist/react/motion.js.map +1 -1
  138. package/dist/react/pageLock.native.d.ts +2 -0
  139. package/dist/react/pageLock.native.js +2 -0
  140. package/dist/react/pageLock.native.js.map +1 -1
  141. package/dist/react/pageLock.web.d.ts +6 -0
  142. package/dist/react/pageLock.web.js +12 -3
  143. package/dist/react/pageLock.web.js.map +1 -1
  144. package/dist/react/ports.js +8 -3
  145. package/dist/react/ports.js.map +1 -1
  146. package/dist/react/saving.d.ts +7 -1
  147. package/dist/react/saving.js +10 -2
  148. package/dist/react/saving.js.map +1 -1
  149. package/dist/react/store.d.ts +5 -2
  150. package/dist/react/store.js +4 -1
  151. package/dist/react/store.js.map +1 -1
  152. package/dist/render/draw.d.ts +21 -4
  153. package/dist/render/draw.js +203 -151
  154. package/dist/render/draw.js.map +1 -1
  155. package/dist/render/elevation.js +3 -3
  156. package/dist/render/elevation.js.map +1 -1
  157. package/dist/render/figmaIcons.d.ts +6 -0
  158. package/dist/render/figmaIcons.js +16 -1
  159. package/dist/render/figmaIcons.js.map +1 -1
  160. package/dist/render/hit.d.ts +8 -1
  161. package/dist/render/hit.js +23 -4
  162. package/dist/render/hit.js.map +1 -1
  163. package/dist/render/icons.js +27 -20
  164. package/dist/render/icons.js.map +1 -1
  165. package/dist/render/lens.d.ts +27 -0
  166. package/dist/render/lens.js +41 -0
  167. package/dist/render/lens.js.map +1 -0
  168. package/dist/render/linkTile.d.ts +20 -2
  169. package/dist/render/linkTile.js +476 -246
  170. package/dist/render/linkTile.js.map +1 -1
  171. package/dist/render/snap.d.ts +10 -1
  172. package/dist/render/snap.js +87 -15
  173. package/dist/render/snap.js.map +1 -1
  174. package/dist/render/svgPath.d.ts +29 -0
  175. package/dist/render/svgPath.js +114 -0
  176. package/dist/render/svgPath.js.map +1 -0
  177. package/dist/render/tileIcons.d.ts +18 -0
  178. package/dist/render/tileIcons.js +67 -0
  179. package/dist/render/tileIcons.js.map +1 -0
  180. package/dist/render/view.d.ts +18 -1
  181. package/dist/render/view.js +38 -6
  182. package/dist/render/view.js.map +1 -1
  183. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  184. package/dist/skia/SkiaDrawCtx.js +27 -5
  185. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  186. package/dist/solver/equations.d.ts +6 -0
  187. package/dist/solver/equations.js +53 -22
  188. package/dist/solver/equations.js.map +1 -1
  189. package/dist/solver/gauss-newton.js +6 -1
  190. package/dist/solver/gauss-newton.js.map +1 -1
  191. package/dist/tools/annotate.d.ts +0 -2
  192. package/dist/tools/annotate.js +1 -22
  193. package/dist/tools/annotate.js.map +1 -1
  194. package/dist/tools/chamfer.js +4 -0
  195. package/dist/tools/chamfer.js.map +1 -1
  196. package/dist/tools/dim.d.ts +3 -3
  197. package/dist/tools/dim.js +26 -65
  198. package/dist/tools/dim.js.map +1 -1
  199. package/dist/tools/fillet.js +4 -0
  200. package/dist/tools/fillet.js.map +1 -1
  201. package/dist/tools/freehand.js +1 -4
  202. package/dist/tools/freehand.js.map +1 -1
  203. package/dist/tools/line.js +15 -11
  204. package/dist/tools/line.js.map +1 -1
  205. package/dist/tools/modify.d.ts +32 -4
  206. package/dist/tools/modify.js +253 -98
  207. package/dist/tools/modify.js.map +1 -1
  208. package/dist/tools/registry.d.ts +7 -0
  209. package/dist/tools/registry.js +14 -1
  210. package/dist/tools/registry.js.map +1 -1
  211. package/dist/tools/select.d.ts +30 -8
  212. package/dist/tools/select.js +165 -34
  213. package/dist/tools/select.js.map +1 -1
  214. package/dist/tools/shapes.d.ts +1 -1
  215. package/dist/tools/shapes.js +12 -5
  216. package/dist/tools/shapes.js.map +1 -1
  217. package/dist/view3d/build.js +9 -8
  218. package/dist/view3d/build.js.map +1 -1
  219. package/package.json +9 -9
  220. package/src/app/edit.ts +25 -1
  221. package/src/app/state.ts +6 -8
  222. package/src/data/sync.ts +7 -2
  223. package/src/editor/actions.ts +213 -122
  224. package/src/editor/elevation.ts +13 -16
  225. package/src/editor/gestures.ts +83 -13
  226. package/src/editor/keys.ts +3 -2
  227. package/src/editor/laser.ts +7 -4
  228. package/src/editor/linkActions.ts +169 -31
  229. package/src/editor/links.ts +17 -1
  230. package/src/editor/mode.ts +8 -5
  231. package/src/editor/project.ts +1 -2
  232. package/src/editor/scaleGate.ts +32 -0
  233. package/src/editor/selection.ts +13 -7
  234. package/src/editor/tooltips.ts +9 -0
  235. package/src/editor/view3d.ts +6 -2
  236. package/src/export/pdf.ts +7 -4
  237. package/src/export/plan.ts +14 -10
  238. package/src/index.ts +1 -0
  239. package/src/model/constraints.ts +1 -0
  240. package/src/model/demo.ts +2 -1
  241. package/src/model/elevation.ts +4 -4
  242. package/src/model/geom.ts +10 -0
  243. package/src/model/history.ts +4 -1
  244. package/src/model/links.ts +14 -3
  245. package/src/model/measurement.ts +3 -3
  246. package/src/model/openings.ts +18 -0
  247. package/src/model/planGeom.ts +70 -10
  248. package/src/model/project.ts +6 -2
  249. package/src/model/rooms.ts +151 -63
  250. package/src/model/scale.ts +107 -0
  251. package/src/model/settings.ts +1 -2
  252. package/src/model/sketch.ts +62 -11
  253. package/src/model/stroke.ts +2 -2
  254. package/src/model/trim.ts +188 -0
  255. package/src/model/types.ts +57 -9
  256. package/src/react/BottomBar.tsx +177 -52
  257. package/src/react/CadEditor.tsx +19 -9
  258. package/src/react/Drawer.tsx +17 -12
  259. package/src/react/Duck.tsx +3 -3
  260. package/src/react/KeypadSheet.tsx +2 -2
  261. package/src/react/MarkupPanel.tsx +1 -1
  262. package/src/react/PlanCanvas.native.tsx +60 -6
  263. package/src/react/PlanCanvas.web.tsx +17 -5
  264. package/src/react/Popovers.tsx +24 -9
  265. package/src/react/Rail.tsx +1 -1
  266. package/src/react/ShapeMenu.tsx +3 -2
  267. package/src/react/Sheet.tsx +3 -3
  268. package/src/react/ToolTip.tsx +3 -2
  269. package/src/react/TopBar.tsx +4 -3
  270. package/src/react/barScroll.ts +57 -0
  271. package/src/react/corners.ts +6 -3
  272. package/src/react/editorBox.ts +20 -0
  273. package/src/react/host.ts +1 -1
  274. package/src/react/motion.tsx +6 -6
  275. package/src/react/pageLock.native.ts +2 -0
  276. package/src/react/pageLock.web.ts +12 -3
  277. package/src/react/ports.ts +7 -2
  278. package/src/react/saving.ts +10 -2
  279. package/src/react/store.ts +5 -2
  280. package/src/render/draw.ts +169 -118
  281. package/src/render/elevation.ts +5 -4
  282. package/src/render/figmaIcons.ts +17 -1
  283. package/src/render/hit.ts +21 -4
  284. package/src/render/icons.ts +27 -20
  285. package/src/render/lens.ts +52 -0
  286. package/src/render/linkTile.ts +436 -190
  287. package/src/render/snap.ts +87 -17
  288. package/src/render/svgPath.ts +95 -0
  289. package/src/render/tileIcons.ts +75 -0
  290. package/src/render/view.ts +43 -7
  291. package/src/skia/SkiaDrawCtx.ts +26 -4
  292. package/src/solver/equations.ts +52 -22
  293. package/src/solver/gauss-newton.ts +4 -1
  294. package/src/styles.css +3 -2
  295. package/src/tools/annotate.ts +1 -18
  296. package/src/tools/chamfer.ts +3 -0
  297. package/src/tools/dim.ts +26 -52
  298. package/src/tools/fillet.ts +3 -0
  299. package/src/tools/freehand.ts +1 -3
  300. package/src/tools/line.ts +12 -11
  301. package/src/tools/modify.ts +210 -70
  302. package/src/tools/registry.ts +18 -1
  303. package/src/tools/select.ts +144 -36
  304. package/src/tools/shapes.ts +9 -4
  305. package/src/ui/ctx.ts +4 -3
  306. package/src/ui/drawer.ts +8 -2
  307. package/src/view3d/build.ts +11 -10
  308. package/dist/react/LaserTray.d.ts +0 -4
  309. package/dist/react/LaserTray.js +0 -26
  310. package/dist/react/LaserTray.js.map +0 -1
  311. package/src/react/LaserTray.tsx +0 -42
@@ -1,25 +1,58 @@
1
1
  import { hyp, normAng, pointInPoly, polyArea } from './geom';
2
2
  import { uid } from './ids';
3
- import { linesBetween } from './sketch';
3
+ import { circleEdge, linesBetween, onCircle } from './sketch';
4
4
  import type { Room, Sketch, XY } from './types';
5
5
 
6
6
  /*
7
- Rooms are faces of the drawing: closed loops of walls and lines, straight or curved.
8
- A loop is a list of corner points plus (for loops that include curves) the edge — line or arc id —
9
- between each corner and the next. Curves are measured as densely sampled polylines.
7
+ Rooms are faces of the drawing: closed loops of walls and lines, straight or curved — and circles (the user's call,
8
+ 2026-10-29: "a space can be created by all closed shapes").
9
+ A loop is a list of corner points plus (for loops that include curves) the edge between each corner and the next:
10
+ a line's or arc's id — or, along a circle, the circle's id and which way round the loop runs on it: '<id>+'
11
+ counter-clockwise, '<id>-' clockwise (circleEdge). A circle that is a room's whole outline has no corners: loop [],
12
+ edges ['<id>+']. Where a line or an arc ends on a circle, the circle is divided there, as lines are at a corner.
13
+ Curves are measured as densely sampled polylines; a round room's area and perimeter exactly.
10
14
  */
11
15
 
12
16
  /** Arcs are sampled every 5° for room outlines (≥ 8 pieces). */
13
17
  const ARC_STEP = Math.PI / 36;
14
18
 
15
- /** An edge of the drawing that can bound a room: a non-construction line or arc. */
16
- interface Edge { id: string; a: string; b: string }
19
+ /**
20
+ * An edge of the drawing that can bound a room: a non-construction line or arc, or a piece of a non-construction
21
+ * circle between two points on it (`circle`: counter-clockwise from a to b).
22
+ */
23
+ interface Edge { id: string; a: string; b: string; circle?: string }
17
24
 
18
- function edgesOf(sk: Sketch): Edge[] {
19
- const out: Edge[] = [];
20
- for (const id in sk.lines) { const l = sk.lines[id]; if (!l.constr) out.push({ id, a: l.a, b: l.b }); }
21
- for (const id in sk.arcs) { const a = sk.arcs[id]; if (!a.constr) out.push({ id, a: a.a, b: a.b }); }
22
- return out;
25
+ /**
26
+ * The drawing as edges between points, for walking its faces. A circle is in pieces between the points on it where a
27
+ * line or an arc ends; one with fewer than two gets the walk's own points (opposite each other, or the one there), so
28
+ * it's a loop like any other. They're not the drawing's, and never left in a room (stored).
29
+ */
30
+ function graph(sk: Sketch): { edges: Edge[]; pos: (p: string) => XY } {
31
+ const edges: Edge[] = [], ends = new Set<string>();
32
+ for (const id in sk.lines) { const l = sk.lines[id]; if (!l.constr) { edges.push({ id, a: l.a, b: l.b }); ends.add(l.a); ends.add(l.b); } }
33
+ for (const id in sk.arcs) { const a = sk.arcs[id]; if (!a.constr) { edges.push({ id, a: a.a, b: a.b }); ends.add(a.a); ends.add(a.b); } }
34
+ const own = new Map<string, XY>();
35
+ for (const cid in sk.circles) {
36
+ const ci = sk.circles[cid], o = sk.points[ci.c];
37
+ if (ci.constr || !o || !(ci.r > 0)) continue;
38
+ const on = [...ends].filter(p => onCircle(sk, cid, p)).map(p => ({ p, t: normAng(Math.atan2(sk.points[p].y - o.y, sk.points[p].x - o.x)) }));
39
+ const add = (t: number) => { const id = '~' + cid + '~' + on.length; own.set(id, { x: o.x + ci.r * Math.cos(t), y: o.y + ci.r * Math.sin(t) }); on.push({ p: id, t: normAng(t) }); };
40
+ if (!on.length) add(0);
41
+ if (on.length === 1) add(on[0].t + Math.PI);
42
+ on.sort((u, v) => u.t - v.t);
43
+ on.forEach((u, i) => edges.push({ id: cid + '#' + i, a: u.p, b: on[(i + 1) % on.length].p, circle: cid }));
44
+ }
45
+ return { edges, pos: p => sk.points[p] ?? own.get(p)! };
46
+ }
47
+
48
+ /** Round a circle (centre o, radius r) from A to B, counter-clockwise (dir 1) or clockwise (−1), sampled; all the way round when they're the same point. */
49
+ function roundCircle(o: XY, r: number, A: XY, B: XY, dir: 1 | -1): XY[] {
50
+ const t0 = Math.atan2(A.y - o.y, A.x - o.x), t1 = Math.atan2(B.y - o.y, B.x - o.x);
51
+ const sweep = normAng(dir * (t1 - t0)) || 2 * Math.PI, n = Math.max(8, Math.ceil(sweep / ARC_STEP));
52
+ const pts: XY[] = [];
53
+ for (let i = 0; i <= n; i++) { const t = t0 + (dir * sweep * i) / n; pts.push({ x: o.x + r * Math.cos(t), y: o.y + r * Math.sin(t) }); }
54
+ pts[0] = A; pts[n] = B;
55
+ return pts;
23
56
  }
24
57
 
25
58
  /** Ids of the lines and arcs joining two points. */
@@ -29,10 +62,18 @@ export function edgesBetween(sk: Sketch, a: string, b: string): string[] {
29
62
  return out;
30
63
  }
31
64
 
32
- /** An edge as a polyline starting at point `from` (lines: 2 points; arcs: sampled along the curve). */
33
- export function edgePolyline(sk: Sketch, id: string, from: string): XY[] {
65
+ /**
66
+ * A room edge as a polyline starting at point `from` (lines: 2 points; arcs: sampled along the curve). Along a
67
+ * circle ('<id>±'), round it to point `to` — or, with no corners (a whole circle), from its rightmost point back to it.
68
+ */
69
+ export function edgePolyline(sk: Sketch, id: string, from?: string, to?: string): XY[] {
70
+ const ce = circleEdge(id);
71
+ if (ce) {
72
+ const ci = sk.circles[ce.cid], o = sk.points[ci.c], A = from ? sk.points[from] : { x: o.x + ci.r, y: o.y };
73
+ return roundCircle(o, ci.r, A, to ? sk.points[to] : A, ce.dir);
74
+ }
34
75
  const l = sk.lines[id];
35
- if (l) { const other = l.a === from ? l.b : l.a; return [sk.points[from], sk.points[other]]; }
76
+ if (l) { const other = l.a === from ? l.b : l.a; return [sk.points[from!], sk.points[other]]; }
36
77
  const ar = sk.arcs[id], o = sk.points[ar.c], s = sk.points[ar.a], e = sk.points[ar.b];
37
78
  const r = hyp(s.x - o.x, s.y - o.y), t0 = Math.atan2(s.y - o.y, s.x - o.x);
38
79
  const sweep = normAng(Math.atan2(e.y - o.y, e.x - o.x) - t0) || 2 * Math.PI; // arcs run counter-clockwise a → b
@@ -44,15 +85,14 @@ export function edgePolyline(sk: Sketch, id: string, from: string): XY[] {
44
85
  }
45
86
 
46
87
  const edgeThick = (sk: Sketch, id: string): number => {
47
- const l = sk.lines[id];
48
- if (l) return l.kind === 'wall' ? l.thick : 0;
49
- const a = sk.arcs[id];
50
- return a && a.kind === 'wall' ? a.thick || 4.5 : 0;
88
+ const ce = circleEdge(id);
89
+ const o = ce ? sk.circles[ce.cid] : sk.lines[id] ?? sk.arcs[id];
90
+ return o && o.kind === 'wall' ? (o as { thick?: number }).thick || 4.5 : 0;
51
91
  };
52
92
 
53
93
  /** The edges of a room loop: stored ones, or (older rooms of straight lines) the line joining each pair. */
54
94
  export function roomEdges(sk: Sketch, r: Room): string[] | null {
55
- if (r.edges && r.edges.length === r.loop.length) return r.edges;
95
+ if (r.edges && (r.edges.length === r.loop.length || (!r.loop.length && r.edges.length === 1))) return r.edges;
56
96
  const out: string[] = [];
57
97
  for (let i = 0; i < r.loop.length; i++) {
58
98
  const e = edgesBetween(sk, r.loop[i], r.loop[(i + 1) % r.loop.length])[0];
@@ -66,59 +106,90 @@ export function roomEdges(sk: Sketch, r: Room): string[] | null {
66
106
  function outline(sk: Sketch, loop: string[], edges: string[]): { pts: XY[]; half: number[] } {
67
107
  const pts: XY[] = [], half: number[] = [];
68
108
  edges.forEach((id, i) => {
69
- const pl = edgePolyline(sk, id, loop[i]), t = edgeThick(sk, id) / 2;
109
+ const pl = edgePolyline(sk, id, loop[i], loop[(i + 1) % loop.length]), t = edgeThick(sk, id) / 2;
70
110
  for (let k = 0; k < pl.length - 1; k++) { pts.push(pl[k]); half.push(t); }
71
111
  });
72
112
  return { pts, half };
73
113
  }
74
114
 
75
- /** Find the smallest closed face of lines and arcs around (wx, wy): its corner points and the edge after each. */
115
+ /**
116
+ * A face as it's walked, as it's kept: a circle's pieces as the circle the way round it's walked ('<id>±'), and no
117
+ * corner between two stretches of one circle the same way round — not the walk's own points, nor where something
118
+ * meets the circle from outside (the room would go with it). All the way round one circle: no corners at all.
119
+ */
120
+ function kept(loop: string[], edges: Edge[]): { loop: string[]; edges: string[] } {
121
+ const L = loop.slice(), E = edges.map((e, i) => (e.circle ? e.circle + (L[i] === e.a ? '+' : '-') : e.id));
122
+ for (let again = true; again && L.length > 1;) {
123
+ again = false;
124
+ for (let i = 0; i < L.length; i++) {
125
+ if (E[(i - 1 + L.length) % L.length] === E[i] && circleEdge(E[i])) { L.splice(i, 1); E.splice(i, 1); again = true; break; }
126
+ }
127
+ }
128
+ return L.length === 1 && circleEdge(E[0]) ? { loop: [], edges: E } : { loop: L, edges: E };
129
+ }
130
+
131
+ /**
132
+ * Find the smallest closed face of lines, arcs and circles around (wx, wy): its corner points and the edge after
133
+ * each. A ray to +x crosses the face's outline; the nearest crossing is on it unless it's an island inside the face
134
+ * (a column), whose own outline doesn't hold the point — then the next.
135
+ */
76
136
  export function findFace(sk: Sketch, wx: number, wy: number): { loop: string[]; edges: string[] } | null {
77
- const edges = edgesOf(sk), byId = new Map(edges.map(e => [e.id, e]));
137
+ const g = graph(sk), byId = new Map(g.edges.map(e => [e.id, e]));
78
138
  const adj = new Map<string, Edge[]>();
79
- for (const e of edges) for (const p of [e.a, e.b]) { if (!adj.has(p)) adj.set(p, []); adj.get(p)!.push(e); }
80
- // cast a ray to +x; the nearest crossing edge is on the face boundary
81
- let best: { x: number; e: Edge; forward: boolean } | null = null;
82
- for (const e of edges) {
83
- const pl = edgePolyline(sk, e.id, e.a);
139
+ for (const e of g.edges) for (const p of [e.a, e.b]) { if (!adj.has(p)) adj.set(p, []); adj.get(p)!.push(e); }
140
+ /** An edge as a polyline from one of its ends. */
141
+ const along = (e: Edge, from: string): XY[] => {
142
+ if (!e.circle) return edgePolyline(sk, e.id, from);
143
+ const ci = sk.circles[e.circle], pts = roundCircle(sk.points[ci.c], ci.r, g.pos(e.a), g.pos(e.b), 1);
144
+ return from === e.a ? pts : pts.reverse();
145
+ };
146
+ // every crossing of the ray, nearest first: walking the edge the way that has the point on its left
147
+ const hits: { x: number; e: Edge; forward: boolean }[] = [];
148
+ for (const e of g.edges) {
149
+ const pl = along(e, e.a);
84
150
  for (let k = 0; k < pl.length - 1; k++) {
85
151
  const A = pl[k], B = pl[k + 1];
86
152
  if ((A.y > wy) === (B.y > wy)) continue;
87
153
  const x = A.x + ((wy - A.y) * (B.x - A.x)) / (B.y - A.y);
88
- if (x <= wx || (best && x >= best.x)) continue;
89
- // walk the edge in the direction that has the tap point on its left
90
- const cr = (B.x - A.x) * (wy - A.y) - (B.y - A.y) * (wx - A.x);
91
- best = { x, e, forward: cr >= 0 };
154
+ if (x <= wx) continue;
155
+ hits.push({ x, e, forward: (B.x - A.x) * (wy - A.y) - (B.y - A.y) * (wx - A.x) >= 0 });
92
156
  }
93
157
  }
94
- if (!best) return null;
95
- const start = best.forward ? best.e.a : best.e.b;
96
- const dirFrom = (id: string, p: string) => { const pl = edgePolyline(sk, id, p); return Math.atan2(pl[1].y - pl[0].y, pl[1].x - pl[0].x); };
97
- const loop = [start], loopEdges = [best.e.id];
98
- let inEdge = best.e, cur = best.forward ? best.e.b : best.e.a, guard = 0;
99
- // walk the face, always taking the tightest turn (direction of travel measured along the curve at the corner)
100
- while (cur !== start && guard++ < 400) {
101
- loop.push(cur);
102
- const nb = adj.get(cur) || [];
103
- const a0 = dirFrom(inEdge.id, cur);
104
- let next: Edge | null = null, bd = Infinity;
105
- for (const e of nb) {
106
- if (e.id === inEdge.id && nb.length > 1) continue;
107
- let d = a0 - dirFrom(e.id, cur);
108
- while (d <= 1e-9) d += 2 * Math.PI;
109
- while (d > 2 * Math.PI + 1e-9) d -= 2 * Math.PI;
110
- if (d < bd) { bd = d; next = e; }
158
+ hits.sort((p, q) => p.x - q.x);
159
+ const dirFrom = (e: Edge, p: string) => { const pl = along(e, p); return Math.atan2(pl[1].y - pl[0].y, pl[1].x - pl[0].x); };
160
+ const tried = new Set<string>();
161
+ for (const h of hits) {
162
+ const key = h.e.id + (h.forward ? '>' : '<');
163
+ if (tried.has(key)) continue;
164
+ tried.add(key);
165
+ const start = h.forward ? h.e.a : h.e.b;
166
+ const loop = [start], loopEdges = [h.e];
167
+ let inEdge = h.e, cur = h.forward ? h.e.b : h.e.a, guard = 0;
168
+ // walk the face, always taking the tightest turn (direction of travel measured along the curve at the corner)
169
+ while (cur !== start && guard++ < 400) {
170
+ loop.push(cur);
171
+ const nb = adj.get(cur) || [];
172
+ const a0 = dirFrom(inEdge, cur);
173
+ let next: Edge | null = null, bd = Infinity;
174
+ for (const e of nb) {
175
+ if (e.id === inEdge.id && nb.length > 1) continue;
176
+ let d = a0 - dirFrom(e, cur);
177
+ while (d <= 1e-9) d += 2 * Math.PI;
178
+ while (d > 2 * Math.PI + 1e-9) d -= 2 * Math.PI;
179
+ if (d < bd) { bd = d; next = e; }
180
+ }
181
+ if (!next) break;
182
+ loopEdges.push(next);
183
+ cur = next.a === cur ? next.b : next.a;
184
+ inEdge = byId.get(next.id)!;
111
185
  }
112
- if (!next) return null;
113
- loopEdges.push(next.id);
114
- cur = next.a === cur ? next.b : next.a;
115
- inEdge = byId.get(next.id)!;
186
+ if (cur !== start) continue;
187
+ const face = kept(loop, loopEdges);
188
+ if (face.loop.some(p => !sk.points[p])) continue;
189
+ const poly = outline(sk, face.loop, face.edges).pts;
190
+ if (polyArea(poly) > 0 && pointInPoly({ x: wx, y: wy }, poly)) return face;
116
191
  }
117
- if (cur !== start) return null;
118
- const poly = outline(sk, loop, loopEdges).pts;
119
- if (polyArea(poly) <= 0) return null;
120
- if (!pointInPoly({ x: wx, y: wy }, poly)) return null;
121
- return { loop, edges: loopEdges };
192
+ return null;
122
193
  }
123
194
 
124
195
  export interface RoomGeom {
@@ -153,9 +224,12 @@ export function roomGeom(sk: Sketch, r: Room): RoomGeom {
153
224
  const t = ((e2.p.x - e1.p.x) * e2.d.y - (e2.p.y - e1.p.y) * e2.d.x) / den;
154
225
  inner.push({ x: e1.p.x + e1.d.x * t, y: e1.p.y + e1.d.y * t });
155
226
  }
156
- const area = Math.abs(polyArea(inner));
227
+ let area = Math.abs(polyArea(inner));
157
228
  let per = 0;
158
229
  for (let i = 0; i < inner.length; i++) { const a = inner[i], b = inner[(i + 1) % inner.length]; per += hyp(b.x - a.x, b.y - a.y); }
230
+ // a round room: its area and perimeter exactly, to the inside face of its wall (not the sampled outline's)
231
+ const whole = !r.loop.length && edges?.length === 1 ? circleEdge(edges[0]) : null, ci = whole && sk.circles[whole.cid];
232
+ if (ci) { const ri = Math.max(0, ci.r - edgeThick(sk, edges![0]) / 2); area = Math.PI * ri * ri; per = 2 * Math.PI * ri; }
159
233
  let cx = 0, cy = 0, A = 0;
160
234
  for (let i = 0; i < n; i++) {
161
235
  const a = poly[i], b = poly[(i + 1) % n];
@@ -167,16 +241,30 @@ export function roomGeom(sk: Sketch, r: Room): RoomGeom {
167
241
  }
168
242
 
169
243
  /**
170
- * Add a room for a closed loop (or return the existing one). `edges` names the line/arc after each corner;
171
- * leave it out for loops of straight lines. Null if it's under 1 sq ft.
244
+ * A room's outline for telling what's inside it: its corners (cheap, and what it's always been) — or, for one that
245
+ * runs round a circle (it has too few corners to say), its outline as drawn.
246
+ */
247
+ export function roomOutline(sk: Sketch, r: Room): XY[] {
248
+ return r.loop.length >= 3 && !(r.edges ?? []).some(e => circleEdge(e)) ? r.loop.map(p => sk.points[p]) : roomGeom(sk, r).poly;
249
+ }
250
+
251
+ /**
252
+ * Add a room for a closed loop (or return the existing one). `edges` names the line/arc (or way round a circle) after
253
+ * each corner; leave it out for loops of straight lines. A whole circle has no corners: loop [] and its one edge. Null
254
+ * if it's under 1 sq ft.
172
255
  */
173
256
  export function maybeRoom(sk: Sketch, loop: string[], edges?: string[]): string | null {
174
- if (loop.length < 2 || 'cabs' in sk) return null; // spaces are the plan's: not on a wall face
257
+ if ('cabs' in sk) return null; // spaces are the plan's: not on a wall face
258
+ const round = !loop.length && edges?.length === 1 && !!circleEdge(edges[0]);
259
+ if (loop.length < 2 && !round) return null;
175
260
  const es = edges || roomEdges(sk, { loop, name: '' });
176
261
  if (!es) return null;
177
262
  if (Math.abs(polyArea(outline(sk, loop, es).pts)) < 144) return null;
178
- const key = [...es].sort().join('|');
179
- for (const id in sk.rooms) { const r = sk.rooms[id], re = roomEdges(sk, r); if (re && [...re].sort().join('|') === key) return id; }
263
+ // (the same room: the same edges — round a circle, from the same corner: a circle cut by a line bounds two rooms
264
+ // of the same edges, the two ways round)
265
+ const keyOf = (l: string[], e: string[]) => e.map((id, i) => (circleEdge(id) ? id + '@' + (l[i] ?? '') : id)).sort().join('|');
266
+ const key = keyOf(loop, es);
267
+ for (const id in sk.rooms) { const r = sk.rooms[id], re = roomEdges(sk, r); if (re && keyOf(r.loop, re) === key) return id; }
180
268
  const id = uid('r');
181
269
  sk.rooms[id] = { loop: loop.slice(), name: 'Space ' + sk.seq.room++, ...(edges ? { edges: edges.slice() } : {}) };
182
270
  return id;
@@ -0,0 +1,107 @@
1
+ import { measure } from './constraints';
2
+ import { deep } from './ids';
3
+ import type { Constraint, Page, Sketch, View } from './types';
4
+
5
+ /*
6
+ A new page has no scale until its first length (the user's call, 2026-10-29: "first needing to set a dimension
7
+ before going into 3d, objects, or walls"). People sketch in proportion, not to size, so the inches a stroke gets are
8
+ only how far the finger went at that zoom. The first length entered (typed, lasered or linked) tells the editor what
9
+ they are: the whole sketch is scaled so that line is that long, and the view zoomed by the inverse at the same
10
+ moment, so nothing moves on the screen — only the numbers become real. Later lengths move geometry as before.
11
+
12
+ What's scaled is where things are: points, radii, notes, pen marks, link tiles, images, where dimensions sit. What
13
+ isn't is what has a real size of its own: a wall's thickness and height, a door's or window's size, a cabinet's.
14
+ */
15
+
16
+ /** The page's drawing has no scale yet. */
17
+ export const isUnscaled = (sk: Sketch): boolean => !!sk.unscaled;
18
+
19
+ /** A length a person enters (a line's, a distance, a diameter): what can give the drawing its scale. */
20
+ export const isEnteredLength = (c: Constraint): boolean =>
21
+ (c.type === 'dist' || c.type === 'pldist' || c.type === 'diam') && c.driving !== false && !c.auto && !c.elev && !c.anchor && (c.value ?? 0) > 0;
22
+
23
+ /**
24
+ * A drawing that comes in marked without its scale but with lengths entered all the same — edited by a version from
25
+ * before this, which kept a mark it didn't know — has its scale: the mark goes, so no length is taken for its first
26
+ * (that would scale a drawing that's already to size). Run on every drawing loaded or arriving.
27
+ */
28
+ export function settleScale(sk: Sketch): Sketch {
29
+ if (sk?.unscaled && Object.values(sk.cons ?? {}).some(isEnteredLength)) delete sk.unscaled;
30
+ return sk;
31
+ }
32
+
33
+ /** Scale everything drawn on a sketch by k about the origin: where it is, not what has a size of its own. */
34
+ export function scaleSketch(sk: Sketch, k: number): void {
35
+ for (const p of Object.values(sk.points)) { p.x *= k; p.y *= k; }
36
+ for (const c of Object.values(sk.circles)) c.r *= k;
37
+ for (const o of Object.values(sk.openings)) {
38
+ o.at *= k;
39
+ if (o.wOff != null) o.wOff *= k;
40
+ for (const d of o.dims ?? []) { d.value *= k; if (d.off != null) d.off *= k; }
41
+ }
42
+ for (const n of sk.notes) {
43
+ if (n.pts) n.pts = n.pts.map(([x, y]) => [x * k, y * k]);
44
+ if (n.x != null) n.x *= k;
45
+ if (n.y != null) n.y *= k;
46
+ if (n.leader) n.leader = [n.leader[0] * k, n.leader[1] * k];
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
+ if (n.ts != null) n.ts *= k;
49
+ }
50
+ // (and what the tiles' 100% is, so their percent holds)
51
+ if (sk.tileRef != null) sk.tileRef *= k;
52
+ for (const c of Object.values(sk.cons)) {
53
+ if (c.off != null) c.off *= k;
54
+ // (none should be here before the first length; one that is keeps measuring what it measures, met as it was)
55
+ if (c.type === 'dist' || c.type === 'pldist' || c.type === 'diam') c.value = measure(sk, c);
56
+ }
57
+ }
58
+
59
+ /** Zoom a view by the inverse of a scale of the drawing about the origin: everything stays where it was on the screen. */
60
+ export function zoomViewBy(v: View, k: number): void {
61
+ v.s /= k;
62
+ v.cx *= k;
63
+ v.cy *= k;
64
+ }
65
+
66
+ /** Scale a page — its drawing, its images and its view — by k, so nothing moves on the screen. */
67
+ export function scalePage(pg: Page, k: number): void {
68
+ scaleSketch(pg.sk, k);
69
+ for (const u of pg.underlays) {
70
+ u.x *= k; u.y *= k; u.w *= k;
71
+ if (u.h != null) u.h *= k;
72
+ }
73
+ if (pg.view) zoomViewBy(pg.view, k);
74
+ }
75
+
76
+ /**
77
+ * The factor that makes what `c` measures `v`: `v` over what it measures now — or, for a wall's face (which is its
78
+ * centreline's length or distance, less or more a thickness that doesn't scale), what makes that face `v`. What a
79
+ * dimension measures is linear in the scale (a + b·k), so two measures find it. Null when there's nothing to measure.
80
+ */
81
+ export function factorFor(sk: Sketch, c: Constraint, v: number): number | null {
82
+ const now = measure(sk, c);
83
+ if (!(now > 1e-9) || !(v > 0)) return null;
84
+ const k1 = v / now;
85
+ if (Math.abs(k1 - 1) < 1e-12) return 1;
86
+ const trial = deep(sk);
87
+ scaleSketch(trial, k1);
88
+ const at1 = measure(trial, c), b = (at1 - now) / (k1 - 1), a = now - b, k = (v - a) / b;
89
+ return b > 1e-9 && k > 0 && Number.isFinite(k) ? k : k1;
90
+ }
91
+
92
+ /** Give an unscaled page its scale: scaled by k (1: as it is), its view zoomed to match. */
93
+ export function setScale(pg: Page, k: number): void {
94
+ if (Math.abs(k - 1) > 1e-12) scalePage(pg, k);
95
+ delete pg.sk.unscaled;
96
+ pg.sk.scaledBy = k;
97
+ }
98
+
99
+ /**
100
+ * A drawing replaced by one from either side of the step that gave it its scale (undo, redo, or the scaled one
101
+ * arriving from elsewhere): the view zooms back, or again, so the drawing stays where it was on the screen.
102
+ */
103
+ export function rezoomAcrossScale(pg: Page, was: Sketch, now: Sketch): void {
104
+ if (!pg.view || !!was.unscaled === !!now.unscaled) return;
105
+ const k = now.unscaled ? 1 / (was.scaledBy || 1) : now.scaledBy || 1;
106
+ if (Math.abs(k - 1) > 1e-12) zoomViewBy(pg.view, k);
107
+ }
@@ -5,7 +5,6 @@ export interface Layers {
5
5
  underlay: boolean;
6
6
  construction: boolean;
7
7
  dims: boolean;
8
- print: boolean;
9
8
  rooms: boolean;
10
9
  annot: boolean;
11
10
  /** cabinets, doors and windows snap to corners and each other as they're placed and dragged */
@@ -27,7 +26,7 @@ export interface Settings extends UnitPrefs {
27
26
  wallH: number;
28
27
  }
29
28
 
30
- export const defaultLayers = (): Layers => ({ underlay: true, construction: true, dims: true, print: true, rooms: true, annot: true, cabinets: true, objSnap: true });
29
+ export const defaultLayers = (): Layers => ({ underlay: true, construction: true, dims: true, rooms: true, annot: true, cabinets: true, objSnap: true });
31
30
 
32
31
  export const settings = sessionState((): Settings => ({
33
32
  unit: 'ftin', denom: 16, snapGrid: false, autoCons: true, toolTips: true, showCons: true, theme: 'system', welcomed: false,
@@ -57,6 +57,21 @@ export function refExists(sk: Sketch, id: string): boolean {
57
57
  export const pinnedPoints = new Set<string>();
58
58
  onSessionReset(() => pinnedPoints.clear());
59
59
 
60
+ /**
61
+ * An arc whose ends are one point — dragged all the way round onto its start (the user's call, 2026-10-29: "if u fully
62
+ * rotate it to reach around - it should create a circle") — becomes that circle. It keeps the arc's id, so what refers
63
+ * to it (its radius, a tangent, Equal radius, Concentric) refers to the circle, and its look (a wall, its thickness, a
64
+ * guide). The point its ends were stays on it, for whatever else it's an end of (a line): gc lets it go if nothing is.
65
+ */
66
+ export function closeArc(sk: Sketch, id: string): void {
67
+ const a = sk.arcs[id], o = sk.points[a.c], p = sk.points[a.a];
68
+ if (!o || !p) return;
69
+ const { c, constr, kind, thick } = a;
70
+ sk.circles[id] = { c, r: hyp(p.x - o.x, p.y - o.y), ...(constr ? { constr } : {}), ...(kind ? { kind } : {}), ...(thick ? { thick } : {}) };
71
+ delete sk.arcs[id];
72
+ addCons(sk, { type: 'oncircle', p: a.a, c: id });
73
+ }
74
+
60
75
  /** Remove dangling entities, constraints and points, repeating until stable. */
61
76
  export function gc(sk: Sketch): void {
62
77
  let changed = true, guard = 0;
@@ -70,6 +85,8 @@ export function gc(sk: Sketch): void {
70
85
  for (const id in sk.arcs) {
71
86
  const a = sk.arcs[id];
72
87
  if (!sk.points[a.c] || !sk.points[a.a] || !sk.points[a.b]) { delete sk.arcs[id]; changed = true; }
88
+ // its ends one point (dragged all the way round and joined): all the way round, a circle
89
+ else if (a.a === a.b) { closeArc(sk, id); changed = true; }
73
90
  }
74
91
  for (const id in sk.cons) {
75
92
  const c = sk.cons[id];
@@ -167,12 +184,33 @@ export function linesBetween(sk: Sketch, a: string, b: string): string[] {
167
184
  return out;
168
185
  }
169
186
 
187
+ /**
188
+ * A room's edge round a circle (model/rooms.ts): '<circle id>+' runs counter-clockwise from the corner to the next,
189
+ * '<circle id>-' clockwise. Null for a line's or an arc's id.
190
+ */
191
+ export function circleEdge(id: string): { cid: string; dir: 1 | -1 } | null {
192
+ const k = id[id.length - 1];
193
+ return k === '+' || k === '-' ? { cid: id.slice(0, -1), dir: k === '+' ? 1 : -1 } : null;
194
+ }
195
+
196
+ /** A point is on a circle (not its centre): where a line or an arc ends on it, or what's held to it. */
197
+ export function onCircle(sk: Sketch, cid: string, pid: string): boolean {
198
+ const ci = sk.circles[cid], o = ci && sk.points[ci.c], p = sk.points[pid];
199
+ return !!o && !!p && pid !== ci.c && Math.abs(hyp(p.x - o.x, p.y - o.y) - ci.r) <= 1e-3;
200
+ }
201
+
170
202
  export function roomValid(sk: Sketch, r: Room): boolean {
171
- if (!r.loop || r.loop.length < 2 || (!r.edges && r.loop.length < 3)) return false;
203
+ if (!r.loop) return false;
204
+ // a whole circle: no corners — the circle still there
205
+ if (!r.loop.length) { const ce = r.edges?.length === 1 ? circleEdge(r.edges[0]) : null; return !!ce && !!sk.circles[ce.cid] && !!sk.points[sk.circles[ce.cid].c]; }
206
+ if (r.loop.length < 2 || (!r.edges && r.loop.length < 3)) return false;
172
207
  for (let i = 0; i < r.loop.length; i++) {
173
208
  const a = r.loop[i], b = r.loop[(i + 1) % r.loop.length];
174
209
  if (!sk.points[a]) return false;
175
210
  if (r.edges) {
211
+ // round a circle: both corners still on it
212
+ const ce = circleEdge(r.edges[i]);
213
+ if (ce) { if (!onCircle(sk, ce.cid, a) || !onCircle(sk, ce.cid, b)) return false; continue; }
176
214
  // each stored edge (line or arc) must still join these two corners
177
215
  const id = r.edges[i], l = sk.lines[id], ar = sk.arcs[id];
178
216
  const ends = l ? [l.a, l.b] : ar ? [ar.a, ar.b] : null;
@@ -254,13 +292,19 @@ export function lineLen(sk: Sketch, l: Line): number {
254
292
  return hyp(b.x - a.x, b.y - a.y);
255
293
  }
256
294
 
257
- /** The driving/reference length dimension attached to a line (print dims excluded). */
295
+ /**
296
+ * The length dimension attached to a line: its driving one, or else a reference one. (A drawing from before print
297
+ * dimensions were dropped can have both on a line: its print one now reads as a reference.)
298
+ */
258
299
  export function lengthDimOf(sk: Sketch, lid: string): string | null {
300
+ let ref: string | null = null;
259
301
  for (const id in sk.cons) {
260
302
  const c = sk.cons[id];
261
- if (c.type === 'dist' && c.line === lid && c.layer !== 'print') return id;
303
+ if (c.type !== 'dist' || c.line !== lid) continue;
304
+ if (c.driving !== false) return id;
305
+ ref ??= id;
262
306
  }
263
- return null;
307
+ return ref;
264
308
  }
265
309
 
266
310
  export interface PosSnapshot { p: Record<string, [number, number]>; r: Record<string, number> }
@@ -277,23 +321,30 @@ export function restorePos(sk: Sketch, s: PosSnapshot): void {
277
321
  for (const id in s.r) if (sk.circles[id]) sk.circles[id].r = s.r[id];
278
322
  }
279
323
 
280
- /** 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
+ */
281
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 };
282
333
  // on a wall face, the plan's corners and edges aren't part of a shape: nothing joins through them
283
- const pts = new Set<string>(), lines = new Set(lineIds), arcs = new Set(arcIds);
284
- const join = (p: string) => { if (!sk.points[p]?.anchor) pts.add(p); };
285
- lineIds.forEach(id => { const l = sk.lines[id]; if (l) { join(l.a); join(l.b); } });
286
- 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); });
287
338
  let grow = true;
288
339
  while (grow) {
289
340
  grow = false;
290
341
  for (const id in sk.lines) {
291
342
  const l = sk.lines[id];
292
- 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; }
293
344
  }
294
345
  for (const id in sk.arcs) {
295
346
  const a = sk.arcs[id];
296
- 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; }
297
348
  }
298
349
  }
299
350
  return { lines: [...lines], arcs: [...arcs] };
@@ -2,8 +2,8 @@ import { hyp, rdp } from './geom';
2
2
  import type { XY } from './types';
3
3
 
4
4
  /*
5
- Stroke cleanup for Draw to shape and the markup pen's To lines: simplify a freehand stroke into the corners it
6
- most likely meant, then square it up to level, plumb and 45° diagonals. Pure geometry (no sketch, no view).
5
+ Stroke cleanup for Draw to shape: simplify a freehand stroke into the corners it most likely meant, then square it
6
+ up to level, plumb and 45° diagonals. Pure geometry (no sketch, no view).
7
7
  */
8
8
 
9
9
  /** Screen-pixel tolerances (the result doesn't depend on zoom). Generous, for wobbly lines drawn on site. */