@reekon-tools/boldr-cad 1.4.0 → 1.5.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 (240) 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 +3 -5
  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 +149 -169
  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 +2 -2
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/linkActions.d.ts +22 -3
  23. package/dist/editor/linkActions.js +95 -13
  24. package/dist/editor/linkActions.js.map +1 -1
  25. package/dist/editor/mode.js +4 -3
  26. package/dist/editor/mode.js.map +1 -1
  27. package/dist/editor/project.js +1 -2
  28. package/dist/editor/project.js.map +1 -1
  29. package/dist/editor/scaleGate.d.ts +13 -0
  30. package/dist/editor/scaleGate.js +27 -0
  31. package/dist/editor/scaleGate.js.map +1 -0
  32. package/dist/editor/selection.js +13 -9
  33. package/dist/editor/selection.js.map +1 -1
  34. package/dist/editor/tooltips.js +9 -0
  35. package/dist/editor/tooltips.js.map +1 -1
  36. package/dist/editor/view3d.d.ts +4 -1
  37. package/dist/editor/view3d.js +6 -2
  38. package/dist/editor/view3d.js.map +1 -1
  39. package/dist/export/pdf.d.ts +5 -2
  40. package/dist/export/pdf.js +12 -8
  41. package/dist/export/pdf.js.map +1 -1
  42. package/dist/export/plan.d.ts +1 -1
  43. package/dist/export/plan.js +14 -9
  44. package/dist/export/plan.js.map +1 -1
  45. package/dist/model/demo.d.ts +1 -0
  46. package/dist/model/demo.js +2 -1
  47. package/dist/model/demo.js.map +1 -1
  48. package/dist/model/elevation.d.ts +1 -1
  49. package/dist/model/elevation.js +3 -3
  50. package/dist/model/elevation.js.map +1 -1
  51. package/dist/model/history.js +4 -1
  52. package/dist/model/history.js.map +1 -1
  53. package/dist/model/links.d.ts +4 -1
  54. package/dist/model/links.js +9 -2
  55. package/dist/model/links.js.map +1 -1
  56. package/dist/model/measurement.d.ts +2 -2
  57. package/dist/model/measurement.js +3 -3
  58. package/dist/model/measurement.js.map +1 -1
  59. package/dist/model/openings.d.ts +10 -0
  60. package/dist/model/openings.js +18 -0
  61. package/dist/model/openings.js.map +1 -1
  62. package/dist/model/planGeom.d.ts +37 -2
  63. package/dist/model/planGeom.js +72 -9
  64. package/dist/model/planGeom.js.map +1 -1
  65. package/dist/model/project.js +5 -2
  66. package/dist/model/project.js.map +1 -1
  67. package/dist/model/rooms.d.ts +18 -5
  68. package/dist/model/rooms.js +178 -73
  69. package/dist/model/rooms.js.map +1 -1
  70. package/dist/model/scale.d.ts +30 -0
  71. package/dist/model/scale.js +120 -0
  72. package/dist/model/scale.js.map +1 -0
  73. package/dist/model/settings.d.ts +0 -1
  74. package/dist/model/settings.js +1 -1
  75. package/dist/model/settings.js.map +1 -1
  76. package/dist/model/sketch.d.ts +21 -1
  77. package/dist/model/sketch.js +58 -4
  78. package/dist/model/sketch.js.map +1 -1
  79. package/dist/model/stroke.js +2 -2
  80. package/dist/model/types.d.ts +38 -7
  81. package/dist/react/BottomBar.d.ts +2 -1
  82. package/dist/react/BottomBar.js +94 -25
  83. package/dist/react/BottomBar.js.map +1 -1
  84. package/dist/react/Drawer.js +3 -2
  85. package/dist/react/Drawer.js.map +1 -1
  86. package/dist/react/Duck.js.map +1 -1
  87. package/dist/react/KeypadSheet.js.map +1 -1
  88. package/dist/react/MarkupPanel.js +1 -1
  89. package/dist/react/PlanCanvas.native.js +54 -7
  90. package/dist/react/PlanCanvas.native.js.map +1 -1
  91. package/dist/react/Popovers.js +14 -3
  92. package/dist/react/Popovers.js.map +1 -1
  93. package/dist/react/Rail.js +1 -1
  94. package/dist/react/Sheet.d.ts +2 -2
  95. package/dist/react/Sheet.js.map +1 -1
  96. package/dist/react/barScroll.d.ts +23 -0
  97. package/dist/react/barScroll.js +47 -0
  98. package/dist/react/barScroll.js.map +1 -0
  99. package/dist/react/corners.d.ts +6 -2
  100. package/dist/react/corners.js.map +1 -1
  101. package/dist/react/motion.d.ts +1 -1
  102. package/dist/react/motion.js +1 -1
  103. package/dist/react/motion.js.map +1 -1
  104. package/dist/render/draw.d.ts +21 -4
  105. package/dist/render/draw.js +184 -149
  106. package/dist/render/draw.js.map +1 -1
  107. package/dist/render/elevation.js +3 -3
  108. package/dist/render/elevation.js.map +1 -1
  109. package/dist/render/figmaIcons.d.ts +6 -0
  110. package/dist/render/figmaIcons.js +16 -1
  111. package/dist/render/figmaIcons.js.map +1 -1
  112. package/dist/render/hit.d.ts +8 -1
  113. package/dist/render/hit.js +23 -4
  114. package/dist/render/hit.js.map +1 -1
  115. package/dist/render/icons.js +27 -20
  116. package/dist/render/icons.js.map +1 -1
  117. package/dist/render/lens.d.ts +27 -0
  118. package/dist/render/lens.js +41 -0
  119. package/dist/render/lens.js.map +1 -0
  120. package/dist/render/linkTile.d.ts +20 -2
  121. package/dist/render/linkTile.js +476 -246
  122. package/dist/render/linkTile.js.map +1 -1
  123. package/dist/render/snap.d.ts +5 -1
  124. package/dist/render/snap.js +3 -1
  125. package/dist/render/snap.js.map +1 -1
  126. package/dist/render/svgPath.d.ts +29 -0
  127. package/dist/render/svgPath.js +114 -0
  128. package/dist/render/svgPath.js.map +1 -0
  129. package/dist/render/tileIcons.d.ts +18 -0
  130. package/dist/render/tileIcons.js +67 -0
  131. package/dist/render/tileIcons.js.map +1 -0
  132. package/dist/render/view.d.ts +18 -1
  133. package/dist/render/view.js +31 -4
  134. package/dist/render/view.js.map +1 -1
  135. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  136. package/dist/skia/SkiaDrawCtx.js +27 -5
  137. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  138. package/dist/solver/equations.d.ts +6 -0
  139. package/dist/solver/equations.js +23 -6
  140. package/dist/solver/equations.js.map +1 -1
  141. package/dist/solver/gauss-newton.js +6 -1
  142. package/dist/solver/gauss-newton.js.map +1 -1
  143. package/dist/tools/annotate.d.ts +0 -2
  144. package/dist/tools/annotate.js +1 -22
  145. package/dist/tools/annotate.js.map +1 -1
  146. package/dist/tools/chamfer.js +4 -0
  147. package/dist/tools/chamfer.js.map +1 -1
  148. package/dist/tools/dim.d.ts +3 -3
  149. package/dist/tools/dim.js +26 -65
  150. package/dist/tools/dim.js.map +1 -1
  151. package/dist/tools/fillet.js +4 -0
  152. package/dist/tools/fillet.js.map +1 -1
  153. package/dist/tools/freehand.js +1 -4
  154. package/dist/tools/freehand.js.map +1 -1
  155. package/dist/tools/line.js +15 -11
  156. package/dist/tools/line.js.map +1 -1
  157. package/dist/tools/modify.js +6 -1
  158. package/dist/tools/modify.js.map +1 -1
  159. package/dist/tools/registry.d.ts +5 -0
  160. package/dist/tools/registry.js +6 -1
  161. package/dist/tools/registry.js.map +1 -1
  162. package/dist/tools/select.d.ts +19 -0
  163. package/dist/tools/select.js +116 -17
  164. package/dist/tools/select.js.map +1 -1
  165. package/dist/tools/shapes.d.ts +1 -1
  166. package/dist/tools/shapes.js +12 -5
  167. package/dist/tools/shapes.js.map +1 -1
  168. package/dist/view3d/build.js +9 -8
  169. package/dist/view3d/build.js.map +1 -1
  170. package/package.json +9 -9
  171. package/src/app/edit.ts +25 -1
  172. package/src/app/state.ts +3 -5
  173. package/src/data/sync.ts +7 -2
  174. package/src/editor/actions.ts +126 -112
  175. package/src/editor/elevation.ts +13 -16
  176. package/src/editor/gestures.ts +83 -13
  177. package/src/editor/keys.ts +2 -2
  178. package/src/editor/linkActions.ts +90 -13
  179. package/src/editor/mode.ts +4 -3
  180. package/src/editor/project.ts +1 -2
  181. package/src/editor/scaleGate.ts +32 -0
  182. package/src/editor/selection.ts +13 -7
  183. package/src/editor/tooltips.ts +8 -0
  184. package/src/editor/view3d.ts +6 -2
  185. package/src/export/pdf.ts +7 -4
  186. package/src/export/plan.ts +14 -10
  187. package/src/model/demo.ts +2 -1
  188. package/src/model/elevation.ts +4 -4
  189. package/src/model/history.ts +4 -1
  190. package/src/model/links.ts +8 -2
  191. package/src/model/measurement.ts +3 -3
  192. package/src/model/openings.ts +18 -0
  193. package/src/model/planGeom.ts +70 -10
  194. package/src/model/project.ts +6 -2
  195. package/src/model/rooms.ts +151 -63
  196. package/src/model/scale.ts +105 -0
  197. package/src/model/settings.ts +1 -2
  198. package/src/model/sketch.ts +48 -4
  199. package/src/model/stroke.ts +2 -2
  200. package/src/model/types.ts +38 -7
  201. package/src/react/BottomBar.tsx +114 -29
  202. package/src/react/Drawer.tsx +14 -10
  203. package/src/react/Duck.tsx +3 -3
  204. package/src/react/KeypadSheet.tsx +2 -2
  205. package/src/react/MarkupPanel.tsx +1 -1
  206. package/src/react/PlanCanvas.native.tsx +60 -6
  207. package/src/react/Popovers.tsx +24 -9
  208. package/src/react/Rail.tsx +1 -1
  209. package/src/react/Sheet.tsx +3 -3
  210. package/src/react/barScroll.ts +57 -0
  211. package/src/react/corners.ts +6 -3
  212. package/src/react/motion.tsx +6 -6
  213. package/src/render/draw.ts +156 -116
  214. package/src/render/elevation.ts +5 -4
  215. package/src/render/figmaIcons.ts +17 -1
  216. package/src/render/hit.ts +21 -4
  217. package/src/render/icons.ts +27 -20
  218. package/src/render/lens.ts +52 -0
  219. package/src/render/linkTile.ts +436 -190
  220. package/src/render/snap.ts +7 -2
  221. package/src/render/svgPath.ts +95 -0
  222. package/src/render/tileIcons.ts +75 -0
  223. package/src/render/view.ts +37 -5
  224. package/src/skia/SkiaDrawCtx.ts +26 -4
  225. package/src/solver/equations.ts +22 -6
  226. package/src/solver/gauss-newton.ts +4 -1
  227. package/src/styles.css +3 -2
  228. package/src/tools/annotate.ts +1 -18
  229. package/src/tools/chamfer.ts +3 -0
  230. package/src/tools/dim.ts +26 -52
  231. package/src/tools/fillet.ts +3 -0
  232. package/src/tools/freehand.ts +1 -3
  233. package/src/tools/line.ts +12 -11
  234. package/src/tools/modify.ts +5 -1
  235. package/src/tools/registry.ts +10 -1
  236. package/src/tools/select.ts +99 -18
  237. package/src/tools/shapes.ts +9 -4
  238. package/src/ui/ctx.ts +4 -3
  239. package/src/ui/drawer.ts +8 -2
  240. package/src/view3d/build.ts +11 -10
package/src/tools/dim.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { afterEdit, holdGroups, tryAddConstraint } from '../app/edit';
2
2
  import { ed } from '../app/state';
3
3
  import { measure } from '../model/constraints';
4
- import { hyp, norm2, pointInPoly, projT } from '../model/geom';
4
+ import { hyp, norm2, projT } from '../model/geom';
5
5
  import { dropHistory, pushHistory } from '../model/history';
6
- import { addCons, lengthDimOf, lineLen, roomValid } from '../model/sketch';
6
+ import { lengthDimOf, lineLen } from '../model/sketch';
7
7
  import { SK } from '../model/store';
8
8
  import type { Constraint, LinkTarget, OpeningEdge, Sketch, WallFace, XY } from '../model/types';
9
9
  import { askLinkedChange, changesValue } from '../editor/linkWrite';
@@ -25,6 +25,7 @@ import type { PEv } from './registry';
25
25
  import { keypadFresh, keypadOpen, keypadSetReading } from '../editor/keypad';
26
26
  import { cornerLines } from '../editor/selection';
27
27
  import { closeKeypad, kpTyped, openKeypad, showConflict, toast, updateCtx, updateHint, type KeypadSpec } from '../editor/ports';
28
+ import { noScaleYet, pendingLabel, startingValue } from '../editor/scaleGate';
28
29
 
29
30
  export const dimTool = {
30
31
  /** angle: the first pick is a corner whose angle keypad is open (the next tap closes it) */
@@ -66,7 +67,7 @@ export const dimTool = {
66
67
  move(ev: PEv) { this.hover(ev); },
67
68
  up(ev: PEv) {
68
69
  const h = pickForDim(ev);
69
- const print = ed.dimLayer === 'print', st = this.st;
70
+ const st = this.st;
70
71
  // a corner's angle keypad is open: the next tap closes it and goes on (a second point measures from the corner)
71
72
  if (st.angle) { st.angle = false; if (keypadOpen()) closeKeypad(); }
72
73
  if (!h || h.type === 'room') { st.first = null; ed.pickedAnchor = null; ed.sel = []; ed.preview = null; updateHint(); requestDraw(); return; }
@@ -74,7 +75,6 @@ export const dimTool = {
74
75
  const picked = ed.pickedAnchor;
75
76
  if (h.type === 'oanchor') {
76
77
  const edge = h.part as OpeningEdge;
77
- if (print) { toast('Print dimensions can’t attach to doors or windows yet. Switch to Driving in the bar below.', { warn: true }); return; }
78
78
  if (st.first) {
79
79
  const p = st.first; st.first = null; ed.sel = [];
80
80
  addOpeningDim(h.id, h.weak ? nearerEdge(SK(), h.id, p) : edge, { pt: p });
@@ -101,10 +101,10 @@ export const dimTool = {
101
101
  if (!st.first) {
102
102
  st.first = h.id; ed.sel = [{ type: 'point', id: h.id }];
103
103
  // a corner: its angle straight away (close the keypad to measure from the corner to another point instead)
104
- if (!print) st.angle = cornerAngle(h.id, () => { this.st.first = null; this.st.angle = false; ed.sel = []; updateHint(); requestDraw(); }, true);
104
+ st.angle = cornerAngle(h.id, () => { this.st.first = null; this.st.angle = false; ed.sel = []; updateHint(); requestDraw(); }, true);
105
105
  updateHint(); requestDraw(); return;
106
106
  }
107
- if (h.id !== st.first) dimPoints(st.first, h.id, print);
107
+ if (h.id !== st.first) dimPoints(st.first, h.id);
108
108
  st.first = null; updateHint();
109
109
  return;
110
110
  }
@@ -113,14 +113,14 @@ export const dimTool = {
113
113
  if (h.type === 'wface') {
114
114
  const [lid, side] = edgeOf(h.id);
115
115
  if (!SK().lines[lid]) { dimCurve(lid, side); return; }
116
- if (st.first) { const p = st.first; st.first = null; if (print) dimPointLine(p, lid); else dimEdgePoint(lid, side, p); updateHint(); return; }
117
- dimLine(lid, print, side);
116
+ if (st.first) { const p = st.first; st.first = null; dimEdgePoint(lid, side, p); updateHint(); return; }
117
+ dimLine(lid, side);
118
118
  return;
119
119
  }
120
120
  if (h.type === 'line') {
121
121
  if (h.side) ed.wallTap = { id: h.id, side: h.side };
122
122
  if (st.first) { dimPointLine(st.first, h.id); st.first = null; updateHint(); return; }
123
- dimLine(h.id, print);
123
+ dimLine(h.id);
124
124
  return;
125
125
  }
126
126
  if (h.type === 'circle' || h.type === 'arc') {
@@ -131,7 +131,7 @@ export const dimTool = {
131
131
  hint() {
132
132
  if (ed.pickedAnchor) return 'Tap a corner or point to measure from. The door or window locks at that distance.';
133
133
  if (this.st.first) return 'Tap a second point, a door or window edge, or a line for the offset distance.';
134
- return ed.dimLayer === 'print' ? 'Print dimensions are labels for the crew — they never move the drawing. Tap a line or two points.' : 'Tap a line, a circle, or two points — door and window edges count as points. Then type the value.';
134
+ return 'Tap a line, a circle, or two points — door and window edges count as points. Then type the value.';
135
135
  },
136
136
  };
137
137
 
@@ -153,34 +153,15 @@ function pickForDim(ev: PEv): Hit | null {
153
153
  return a ? { type: 'oanchor', id: a.id, part: a.edge, weak: true } : h;
154
154
  }
155
155
 
156
- /** +1 or -1: the side of a line that faces away from any room (for print dimension offsets). */
157
- function sideAwayFromRooms(lid: string): number {
158
- const sk = SK(), l = sk.lines[lid], a = sk.points[l.a], b = sk.points[l.b];
159
- const n = norm2(-(b.y - a.y), b.x - a.x);
160
- const m = { x: (a.x + b.x) / 2 + n[0] * 12, y: (a.y + b.y) / 2 + n[1] * 12 };
161
- for (const id in sk.rooms) { const r = sk.rooms[id]; if (roomValid(sk, r) && pointInPoly(m, r.loop.map(p => sk.points[p]))) return -1; }
162
- return 1;
163
- }
164
-
165
156
  /**
166
157
  * Tap on a line (or its + chip): open the keypad for its length, or edit the existing length. A wall's is measured on
167
158
  * `side`, the edge picked — or, picked whole, on the side a space is on (the side it was tapped on, between two), and
168
159
  * along its centerline where there's no space; the keypad can switch it.
169
160
  */
170
- export function dimLine(lid: string, print: boolean, side?: 1 | -1): void {
161
+ export function dimLine(lid: string, side?: 1 | -1): void {
171
162
  const sk = SK(), l = sk.lines[lid];
172
163
  if (!l) return;
173
164
  const wall = faced(sk, lid);
174
- if (print) {
175
- pushHistory();
176
- // (on a wall's edge: that face's length, labelled on that side)
177
- const f = wall ? side : undefined;
178
- const off = (36 / V().s) * (f ?? sideAwayFromRooms(lid));
179
- addCons(sk, { type: 'dist', a: l.a, b: l.b, kind: 'al', value: f ? faceLength(sk, lid, f) : lineLen(sk, l), line: lid, driving: false, layer: 'print', off, ...(f ? { face: f } : {}) });
180
- afterEdit();
181
- toast('Print dimension added. Drag it to move it.');
182
- return;
183
- }
184
165
  const ex = lengthDimOf(sk, lid);
185
166
  if (ex) { editDim(ex, undefined, false, wall && side ? { face: side } : undefined); return; }
186
167
  let face: 0 | 1 | -1 = wall ? side ?? defaultFace(sk, lid, ed.wallTap?.id === lid ? ed.wallTap.side : undefined) ?? 0 : 0;
@@ -190,10 +171,10 @@ export function dimLine(lid: string, print: boolean, side?: 1 | -1): void {
190
171
  const show = () => { ed.sel = face ? [{ type: 'wface', id: edgeId(lid, face) }] : [{ type: 'line', id: lid }]; requestDraw(); };
191
172
  show();
192
173
  const spec: KeypadSpec = {
193
- title: 'Length', sub: wall ? lengthSub(sk, lid, face) : '', initial: fmtLen(len()), value: len(), selectAll: true, laser: true,
174
+ title: 'Length', sub: wall ? lengthSub(sk, lid, face) : '', ...startingValue(len()), selectAll: true, laser: true,
194
175
  extras: wall ? () => lengthChoices(sk, lid).map(ch => ({
195
176
  label: ch.label!, on: ch.face === face,
196
- onPress: () => { face = ch.face!; spec.sub = lengthSub(sk, lid, face); show(); if (keypadFresh()) keypadSetReading(len()); },
177
+ onPress: () => { face = ch.face!; spec.sub = lengthSub(sk, lid, face); show(); if (keypadFresh() && !noScaleYet()) keypadSetReading(len()); },
197
178
  })) : undefined,
198
179
  onClose: () => { ed.sel = was; updateCtx(); requestDraw(); },
199
180
  onDone: (v, capture) => { if (v > 0) addLength(v, capture !== undefined ? { capture } : {}); },
@@ -305,7 +286,7 @@ export function faceChoices(sk: Sketch, c: Constraint): FaceChoice[] {
305
286
  const l = sk.lines[c.l], a = sk.points[l.a], b = sk.points[l.b], H = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y) ? 'H' : '';
306
287
  return [
307
288
  { label: 'Inside faces', short: 'Inside', icon: 'spanIn' + H, faces: k(1) },
308
- { label: 'Centerlines', short: 'Centers', icon: 'spanMid' + H, faces: [0, 0] },
289
+ { label: 'Centerlines', short: 'Center', icon: 'spanMid' + H, faces: [0, 0] },
309
290
  { label: 'Outside faces', short: 'Outside', icon: 'spanOut' + H, faces: k(-1) },
310
291
  ];
311
292
  }
@@ -408,22 +389,16 @@ export function dimEdges(items: { type: string; id: string }[], thickness: (lid:
408
389
  else if (other.type === 'line') dimTwoLines(lid, other.id, { [lid]: side });
409
390
  }
410
391
 
411
- export function dimPoints(a: string, b: string, print: boolean): void {
392
+ export function dimPoints(a: string, b: string): void {
412
393
  const sk = SK(), A = sk.points[a], B = sk.points[b];
413
394
  const L = hyp(B.x - A.x, B.y - A.y);
414
- if (print) {
415
- pushHistory();
416
- addCons(sk, { type: 'dist', a, b, kind: 'al', value: L, driving: false, layer: 'print', off: 30 / V().s });
417
- afterEdit();
418
- return;
419
- }
420
395
  ed.sel = [];
421
396
  const n = norm2(-(B.y - A.y), B.x - A.x);
422
- ed.preview = (c, T) => drawPendingDim(c, T, { from: A, to: B, a: A, b: B, nx: n[0], ny: n[1], off: 0, label: fmtLen(kpTyped() ?? L) });
397
+ ed.preview = (c, T) => drawPendingDim(c, T, { from: A, to: B, a: A, b: B, nx: n[0], ny: n[1], off: 0, label: pendingLabel(kpTyped(), L) });
423
398
  requestDraw();
424
399
  openKeypad({
425
400
  onClose: () => { ed.preview = null; requestDraw(); },
426
- title: 'Distance', initial: fmtLen(L), value: L, selectAll: true, laser: true,
401
+ title: 'Distance', ...startingValue(L), selectAll: true, laser: true,
427
402
  onDone: (v, capture) => {
428
403
  pushHistory();
429
404
  const r = tryAddConstraint({ type: 'dist', a, b, kind: 'al', value: v, driving: true, off: 0, ...(capture !== undefined ? { capture } : {}) });
@@ -449,7 +424,7 @@ export function dimTwoLines(l1: string, l2: string, sides: Record<string, 0 | 1
449
424
  const kind = parallel ? 'pldist' : 'angle';
450
425
  const existing = Object.keys(sk.cons).find(id => {
451
426
  const c = sk.cons[id];
452
- if (c.type !== kind || c.layer === 'print') return false;
427
+ if (c.type !== kind) return false;
453
428
  if (kind === 'angle') return (c.l1 === l1 && c.l2 === l2) || (c.l1 === l2 && c.l2 === l1);
454
429
  return (c.l === l1 && (c.p === L2.a || c.p === L2.b)) || (c.l === l2 && (c.p === L1.a || c.p === L1.b));
455
430
  });
@@ -487,15 +462,15 @@ export function dimPointLine(p: string, lid: string, opts: { keepParallel?: stri
487
462
  applyFace(c, { faces: opts.faces ?? [faceToward(sk, lid, P), 0] });
488
463
  const choices = faceChoices(sk, c);
489
464
  ed.sel = [];
490
- ed.preview = (cx, T) => { const e = dimEnds(sk, c); if (e) drawPendingDim(cx, T, { from: e.a, to: e.b, a: e.a, b: e.b, nx: 0, ny: 0, off: 0, label: fmtLen(kpTyped() ?? measure(sk, c)) }); };
465
+ ed.preview = (cx, T) => { const e = dimEnds(sk, c); if (e) drawPendingDim(cx, T, { from: e.a, to: e.b, a: e.a, b: e.b, nx: 0, ny: 0, off: 0, label: pendingLabel(kpTyped(), measure(sk, c)) }); };
491
466
  requestDraw();
492
467
  const spec: KeypadSpec = {
493
468
  onClose: () => { ed.preview = null; requestDraw(); },
494
469
  // (which faces it's measured between: the choices beside the keys)
495
- title: opts.title || 'Distance to line', sub: opts.keepParallel ? 'Measured square to the lines' : '', initial: fmtLen(measure(sk, c)), value: measure(sk, c), selectAll: true,
470
+ title: opts.title || 'Distance to line', sub: opts.keepParallel ? 'Measured square to the lines' : '', ...startingValue(measure(sk, c)), selectAll: true,
496
471
  extras: choices.length ? () => choices.map(ch => ({
497
472
  label: ch.label!, on: onChoice(c, ch),
498
- onPress: () => { applyFace(c, ch); requestOverlayDraw(); if (keypadFresh()) keypadSetReading(measure(sk, c)); },
473
+ onPress: () => { applyFace(c, ch); requestOverlayDraw(); if (keypadFresh() && !noScaleYet()) keypadSetReading(measure(sk, c)); },
499
474
  })) : undefined,
500
475
  onDone: v => {
501
476
  pushHistory();
@@ -525,10 +500,10 @@ export function dimCurve(id: string, side?: 1 | -1): void {
525
500
  const show = () => { ed.sel = face ? [{ type: 'wface', id: edgeId(id, face) }] : [{ type, id }]; requestDraw(); };
526
501
  show();
527
502
  const spec: KeypadSpec = {
528
- title: 'Diameter', sub: wall ? lengthSub(sk, id, face) : '', initial: fmtLen(now()), value: now(), selectAll: true,
503
+ title: 'Diameter', sub: wall ? lengthSub(sk, id, face) : '', ...startingValue(now()), selectAll: true,
529
504
  extras: wall ? () => curveChoices(sk, id).map(ch => ({
530
505
  label: ch.label!, on: ch.face === face,
531
- onPress: () => { face = ch.face!; spec.sub = lengthSub(sk, id, face); show(); if (keypadFresh()) keypadSetReading(now()); },
506
+ onPress: () => { face = ch.face!; spec.sub = lengthSub(sk, id, face); show(); if (keypadFresh() && !noScaleYet()) keypadSetReading(now()); },
532
507
  })) : undefined,
533
508
  onClose: () => { ed.sel = was; requestDraw(); },
534
509
  onDone: v => {
@@ -549,7 +524,7 @@ export function cornerAngle(pid: string, done?: () => void, modeless = false): b
549
524
  const sk = SK(), pair = cornerLines(sk, pid);
550
525
  if (!pair) return false;
551
526
  // edit the corner's existing angle dimension rather than stacking a second one
552
- const ex = Object.keys(sk.cons).find(id => { const c = sk.cons[id]; return c.type === 'angle' && c.layer !== 'print' && ((c.l1 === pair[0] && c.l2 === pair[1]) || (c.l1 === pair[1] && c.l2 === pair[0])); });
527
+ const ex = Object.keys(sk.cons).find(id => { const c = sk.cons[id]; return c.type === 'angle' && ((c.l1 === pair[0] && c.l2 === pair[1]) || (c.l1 === pair[1] && c.l2 === pair[0])); });
553
528
  if (ex) editDim(ex, done, modeless); else dimAngle(pair[0], pair[1], done, modeless);
554
529
  return true;
555
530
  }
@@ -605,7 +580,7 @@ export function editDim(id: string, done?: () => void, modeless = false, pick?:
605
580
  // (which faces it's measured on: the choices beside the keys)
606
581
  const spec: KeypadSpec = {
607
582
  title,
608
- sub: c.driving === false ? (c.layer === 'print' ? 'Print dimension (label only)' : 'Reference — not driving the shape') : 'Driving dimension',
583
+ sub: c.driving === false ? 'Reference — not driving the shape' : 'Driving dimension',
609
584
  numeric: isAng, initial: isAng ? String(Math.round(now() * 10) / 10) : fmtLen(now()), selectAll: true, laser: !isAng, modeless,
610
585
  // (just the faces it's measured on: Reference and Delete are the bar's, for a dimension selected)
611
586
  extras: () => faceChoices(sk, withFace(c, face)).map(ch => ({
@@ -615,7 +590,7 @@ export function editDim(id: string, done?: () => void, modeless = false, pick?:
615
590
  onDone: (v, capture) => { setDimValue(id, v * k, face ?? undefined, { capture }); done?.(); },
616
591
  // a line's length or an angle: or one of the job's, from its list — the dimension takes it, linked to it (a
617
592
  // dimension already linked to another, re-linked)
618
- measurements: ((c.type === 'dist' && c.line) || isAng) && c.layer !== 'print' && offersJobMeasurements() ? {
593
+ measurements: ((c.type === 'dist' && c.line) || isAng) && offersJobMeasurements() ? {
619
594
  kind: isAng ? 'angle' : 'length', pick: t => {
620
595
  const v = jobValue(t, isAng ? 'angle' : 'length');
621
596
  if (v == null) return;
@@ -671,7 +646,6 @@ function takeLink(c: Constraint, o: DimValueOpts): void {
671
646
  export function setDimValue(id: string, v: number, face?: FaceChoice, opts: DimValueOpts = {}): boolean {
672
647
  const sk = SK(), c = sk.cons[id];
673
648
  if (!c) return false;
674
- if (c.layer === 'print') { toast('Print dimensions follow the drawing. Change a driving dimension instead.', { warn: true }); return false; }
675
649
  if (c.measurementRef && opts.link === undefined && !opts.asked && !opts.quiet && changesValue(c, v)) {
676
650
  askLinkedChange(c, v, unlink => setDimValue(id, v, face, { ...opts, asked: true, ...(unlink ? { link: null } : {}) }), opts.capture);
677
651
  return false;
@@ -9,6 +9,7 @@ import { requestDraw } from '../render/view';
9
9
  import { solver } from '../solver';
10
10
  import { fmtLen } from '../units';
11
11
  import { openKeypad, toast, updateCtx } from '../editor/ports';
12
+ import { waitForScale } from '../editor/scaleGate';
12
13
  import { cornerAt, cutCorner } from './corner';
13
14
 
14
15
  /*
@@ -33,6 +34,8 @@ export function maxFillet(sk: Sketch, pid: string): number {
33
34
  export function filletPrompt(pid: string): void {
34
35
  const sk = SK();
35
36
  if (!cornerAt(sk, pid)) { toast('Select a corner where two lines meet to round it off.', { warn: true }); return; }
37
+ // (a radius typed is real: nothing drawn says what it is in a sketch without its scale yet — editor/scaleGate.ts)
38
+ if (waitForScale()) return;
36
39
  const most = maxFillet(sk, pid), start = Math.min(6, most * 0.5);
37
40
  openKeypad({
38
41
  title: 'Fillet radius', sub: 'Up to ' + fmtLen(most) + ' fits this corner', initial: fmtLen(Math.round(start * 16) / 16), value: Math.round(start * 16) / 16, selectAll: true, laser: true,
@@ -3,7 +3,6 @@ import { ed } from '../app/state';
3
3
  import { wallBand, type PEv } from './registry';
4
4
  import { hyp } from '../model/geom';
5
5
  import { dropHistory, pushHistory } from '../model/history';
6
- import { maybeRoom } from '../model/rooms';
7
6
  import { settings } from '../model/settings';
8
7
  import { addLine, addPoint } from '../model/sketch';
9
8
  import { deg, JOIN_PX, simplifyStroke, SQUARE_DEG, squareUp, turn } from '../model/stroke';
@@ -20,7 +19,6 @@ import { drawsWalls } from '../editor/mode';
20
19
  simplified, corners cleaned up, closed if it ends where it began, every segment within 15° of level, plumb or a
21
20
  45° diagonal snapped exactly to it (most site sketches are 90s and 45s), joined to points it starts or ends on,
22
21
  and (with auto-constrain on) level/plumb segments and square corners constrained, ready to adjust.
23
- The same conversion turns a markup pen stroke into lines (convertNote).
24
22
  */
25
23
 
26
24
  /**
@@ -57,7 +55,7 @@ export function strokeToLines(stroke: XY[], opts: { constr?: boolean; kind?: Lin
57
55
  if (Math.abs(turn(p, q, r) - 90) < SQUARE_DEG) add({ type: 'perp', l1: lines[j], l2: lines[i] });
58
56
  }
59
57
  }
60
- if (closed && !opts.constr) maybeRoom(sk, ids);
58
+ // (a closed shape is only a shape: the Space tool makes it a space, as the line tool's)
61
59
  return { lines, closed };
62
60
  }
63
61
 
package/src/tools/line.ts CHANGED
@@ -2,15 +2,14 @@ import { afterEdit, analyze, tryAddConstraint } from '../app/edit';
2
2
  import { ed, type Guide, type Snap } from '../app/state';
3
3
  import { hyp, norm2 } from '../model/geom';
4
4
  import { pushHistory } from '../model/history';
5
- import { roomGeom, maybeRoom } from '../model/rooms';
6
5
  import { settings } from '../model/settings';
7
6
  import { addLine, gc, pinnedPoints } from '../model/sketch';
8
- import { SK, store } from '../model/store';
7
+ import { isUnscaled, setScale } from '../model/scale';
8
+ import { page, SK, store } from '../model/store';
9
9
  import type { LineKind } from '../model/types';
10
10
  import { TH } from '../render/theme';
11
11
  import { shiftSnap, snap45, snapAt } from '../render/snap';
12
12
  import { requestDraw, requestOverlayDraw, S } from '../render/view';
13
- import { fmtArea } from '../units';
14
13
  import { commitPoint, applyPending, lengthPreview, PT, rememberHover, wallBand, type PEv, type Tool } from './registry';
15
14
  import { openKeypad, toast, updateCtx, updateHint } from '../editor/ports';
16
15
  import type { KeyIn } from '../editor/keys';
@@ -138,19 +137,17 @@ export function makeLineTool(): LineTool {
138
137
  const closed = b.id === st.first && st.chain.length >= 3;
139
138
  st.chain.push(b.id); st.prev = lid; st.start = b.id;
140
139
  pinnedPoints.clear(); pinnedPoints.add(b.id);
141
- if (closed) {
142
- const loop = st.chain.slice(0, -1);
143
- const sk = SK();
144
- const rid = kindNow() === 'wall' || !ed.drawConstr ? maybeRoom(sk, loop) : null;
145
- this.reset();
146
- if (rid) toast(sk.rooms[rid].name + ' added — ' + fmtArea(roomGeom(sk, sk.rooms[rid]).area));
147
- }
140
+ // a closed shape is only a shape: it's made a space by the Space tool, when the person picks it (the user's call,
141
+ // 2026-10-26: "By default i dont want spaces create at all")
142
+ if (closed) this.reset();
148
143
  },
149
144
  typeLength(initial: string) {
150
145
  const st = this.st;
151
146
  if (!st.start) return;
152
147
  const dirs: [string, [number, number]][] = [['→', [1, 0]], ['←', [-1, 0]], ['↑', [0, 1]], ['↓', [0, -1]]];
153
148
  let dir = st.dir || [1, 0];
149
+ // how long the segment is drawn so far (to the pointer: nothing, on a touch screen, after a tap)
150
+ const from = PT(st.start), drawn = ed.snapState ? hyp(ed.snapState.x - from.x, ed.snapState.y - from.y) : 0;
154
151
  openKeypad({
155
152
  title: 'Segment length', sub: 'Direction sets where it goes', initial: initial || '',
156
153
  extras: () => dirs.map(([label, d]) => ({
@@ -161,11 +158,15 @@ export function makeLineTool(): LineTool {
161
158
  })),
162
159
  onDone: v => {
163
160
  if (v <= 0) return;
161
+ pushHistory();
162
+ // the drawing's first length: the sketch is scaled so the segment, as drawn, is that long — as it is, when
163
+ // nothing was drawn to go by (model/scale.ts). Before it's read where the segment starts: that moves too.
164
+ const pg = page();
165
+ if (isUnscaled(pg.sk) && SK() === pg.sk) { setScale(pg, drawn > 1e-6 ? v / drawn : 1); requestDraw(); }
164
166
  const a = PT(st.start!);
165
167
  const guides: Guide[] = [];
166
168
  if (Math.abs(dir[1]) < 1e-9) guides.push({ kind: 'H', from: st.start! });
167
169
  else if (Math.abs(dir[0]) < 1e-9) guides.push({ kind: 'V', from: st.start! });
168
- pushHistory();
169
170
  this.segmentTo({ x: a.x + dir[0] * v, y: a.y + dir[1] * v, kind: 'free', guides }, v);
170
171
  afterEdit();
171
172
  },
@@ -15,6 +15,7 @@ import { placeTargets, snapSlide } from '../editor/elevation';
15
15
  import { fmtArea, fmtLen } from '../units';
16
16
  import type { PEv } from './registry';
17
17
  import { openKeypad, toast } from '../editor/ports';
18
+ import { noScaleYet, waitForScale } from '../editor/scaleGate';
18
19
 
19
20
  /** Hover helper: highlight only geometry of the given types. */
20
21
  const hoverOnly = (types: string[]) => (ev: PEv) => {
@@ -118,6 +119,8 @@ export const offsetTool = {
118
119
  };
119
120
 
120
121
  export function offsetPrompt(lid: string, side: number): void {
122
+ // (a distance typed is real: nothing drawn says what it is in a sketch without its scale yet — editor/scaleGate.ts)
123
+ if (waitForScale()) return;
121
124
  ed.sel = [{ type: 'line', id: lid }];
122
125
  requestDraw();
123
126
  openKeypad({ title: 'Offset distance', sub: 'Copies the line, kept parallel', initial: '', onDone: v => { if (!v) return; offsetLine(lid, v * side); } });
@@ -185,7 +188,8 @@ export const roomTool = {
185
188
  if (!id) { dropHistory(); toast('That area is too small for a space.', { warn: true }); return; }
186
189
  ed.sel = [];
187
190
  afterEdit();
188
- toast(sk.rooms[id].name + ' — ' + fmtArea(roomGeom(sk, sk.rooms[id]).area));
191
+ // (its area once the drawing has its scale: before, only a placeholder)
192
+ toast(sk.rooms[id].name + (noScaleYet() ? '' : ' — ' + fmtArea(roomGeom(sk, sk.rooms[id]).area)));
189
193
  },
190
194
  hint() { return 'Tap inside a closed shape to make a space. Area is measured to the inside face of walls.'; },
191
195
  };
@@ -10,6 +10,7 @@ import { requestDraw, S, type Xform } from '../render/view';
10
10
  import { fmtLen } from '../units';
11
11
  import { onSessionReset } from '../model/session';
12
12
  import { updateCtx, updateHint, updateRail } from '../editor/ports';
13
+ import { noScaleYet, toolWaitsForScale, waitForScale } from '../editor/scaleGate';
13
14
  import type { KeyIn } from '../editor/keys';
14
15
  import type { DrawCtx } from '../render/ctx';
15
16
  import { TH } from '../render/theme';
@@ -36,6 +37,11 @@ export interface Tool {
36
37
  hint?(): string;
37
38
  /** something half-done that Esc should cancel first (a line in progress, a picked first point…) */
38
39
  busy?(): boolean;
40
+ /**
41
+ * whether a finger or pencil pressed and moving is placing or dragging something, for a device's magnifier
42
+ * (render/lens.ts). Unset: whenever one is (a drawing tool places at it all the time).
43
+ */
44
+ magnify?(): boolean;
39
45
  }
40
46
 
41
47
  export const tools: Record<string, Tool> = {};
@@ -53,6 +59,8 @@ export function cancelTool(): void {
53
59
  }
54
60
 
55
61
  export function setTool(t: string): void {
62
+ // (doors, windows, cabinets and offsets have real sizes: they wait for the page's first length — editor/scaleGate.ts)
63
+ if (toolWaitsForScale(t) && waitForScale()) return;
56
64
  if (t === ed.tool && t !== 'select' && t !== 'aselect') { cancelTool(); updateCtx(); updateHint(); return; }
57
65
  cancelTool();
58
66
  ed.tool = t;
@@ -88,7 +96,8 @@ export function rememberHover(s: Snap | null): void {
88
96
  export function lengthPreview(c: DrawCtx, T: Xform, a: XY, b: XY, col?: string): void {
89
97
  const [x1, y1] = S(T, a.x, a.y), [x2, y2] = S(T, b.x, b.y);
90
98
  const L = hyp(b.x - a.x, b.y - a.y);
91
- if (L * T.s < 6) return;
99
+ // (not on a page without its scale: the length is only a placeholder)
100
+ if (L * T.s < 6 || noScaleYet()) return;
92
101
  chip(c, fmtLen(L), (x1 + x2) / 2, (y1 + y2) / 2 - 16, { bg: '#fff', fg: '#0066FF', border: [col || '#0066FF', 1.5] });
93
102
  }
94
103
 
@@ -1,16 +1,16 @@
1
- import { dimEnds, dimRadius, edgeOf, gridSteps, openingFrame } from '../model/planGeom';
1
+ import { angleCorner, angleSweep, arcAngles, dimEnds, dimRadius, edgeOf, gridSteps, inSweep, openingFrame } from '../model/planGeom';
2
2
  import { imageById } from '../model/images';
3
3
  import { editNoteText } from '../editor/markup';
4
4
  import { afterEdit, resolveOpeningsNow, solveSketch, tryAddConstraint } from '../app/edit';
5
5
  import { ed, isSel, type BoxSel, type Hit, type Snap } from '../app/state';
6
- import { clamp, hyp, norm2, projT, segInter } from '../model/geom';
6
+ import { clamp, hyp, norm2, normAng, projT, segInter } from '../model/geom';
7
7
  import { dropHistory, pushHistory } from '../model/history';
8
8
  import { deep } from '../model/ids';
9
9
  import { settings } from '../model/settings';
10
10
  import { openingSpan, slideGroup, type SlideGroup } from '../model/openings';
11
- import { connectedGeometry, joinPoints, mergePoints, restorePos, snapshotPos } from '../model/sketch';
11
+ import { circleEdge, connectedGeometry, joinPoints, mergePoints, restorePos, snapshotPos } from '../model/sketch';
12
12
  import { planSK, replaceSK, SK } from '../model/store';
13
- import type { Note, SelItem, Sketch, Underlay, WallFace } from '../model/types';
13
+ import type { Note, SelItem, Sketch, Underlay, WallFace, XY } from '../model/types';
14
14
  import { cabOfPoint, cabPoints, faceEdit, openingOfPoint } from '../model/faces';
15
15
  import { pushedOut, syncFace, type Overflow } from '../model/facesolve';
16
16
  import { snapCabinetMove } from '../editor/face';
@@ -24,9 +24,16 @@ import type { PEv } from './registry';
24
24
  import { onSessionReset } from '../model/session';
25
25
  import { openDrawer, setCursor, toast, updateCtx, updateHint } from '../editor/ports';
26
26
  import { selectionHint } from '../editor/selection';
27
+ import { openImageLink } from '../editor/linkActions';
28
+
29
+ /**
30
+ * An arc the dragged point is an end of: its other end, which end the point is, how far round it goes (kept as it's
31
+ * dragged), and whether it's closed — dragged all the way round onto its other end (see closeArcs).
32
+ */
33
+ interface ArcEnd { id: string; other: string; end: 'a' | 'b'; sweep: number; closed: boolean }
27
34
 
28
35
  type Drag =
29
- | { kind: 'point'; id: string; start: { x: number; y: number }; grab: { x: number; y: number }; snap: Snap | null }
36
+ | { kind: 'point'; id: string; start: { x: number; y: number }; grab: { x: number; y: number }; snap: Snap | null; arcs: ArcEnd[] }
30
37
  /** cab: dragging a cabinet (its box where it started): it snaps to corners, the floor, other cabinets */
31
38
  | { kind: 'multi'; ids: string[]; start: { x: number; y: number }[]; grab: { x: number; y: number }; cab?: { id: string; box: { u0: number; u1: number; z0: number; z1: number } }; via?: string; gst?: number; gx?: number; gy?: number }
32
39
  /** size a circle by its edge; grab: how far out from its centreline it was picked up (a wall's outer face, say) */
@@ -34,6 +41,8 @@ type Drag =
34
41
  | { kind: 'dimoff'; id: string }
35
42
  /** swing a diameter's line around its circle and move the label in/out */
36
43
  | { kind: 'diamlabel'; id: string }
44
+ /** pull an angle's label off its corner: out (its arc's radius) and round between its two lines */
45
+ | { kind: 'anglelabel'; id: string }
37
46
  /** slide a door/window dimension (width or a placed one) toward or away from its wall */
38
47
  | { kind: 'odimoff'; id: string; part: string }
39
48
  /** slide a door/window (or the free chain of openings it's locked into) along its wall; grab: s where it was picked up */
@@ -179,6 +188,8 @@ export const selectTool = {
179
188
  else if (st.dragging && ed.boxSel) { boxSelect(ed.boxSel, ev.shift); ed.boxSel = null; }
180
189
  else if (st.hit) {
181
190
  const h = st.hit, now = performance.now();
191
+ // an expanded photo's badge, tapped: open what it's for
192
+ if (h.type === 'underlay' && h.part === 'link') { this.st = {}; openImageLink(h.id); updateCtx(); requestDraw(); return; }
182
193
  // a cabinet's footprint in the plan: open its wall, the cabinet selected
183
194
  if (h.type === 'cabfoot') { const [key, cab] = h.id.split('#'); this.st = {}; openCabinet(key, cab); return; }
184
195
  // with a finger or pencil, tapping the selected line again selects everything joined to it (as a double tap does)
@@ -220,6 +231,8 @@ export const selectTool = {
220
231
  this.st = {};
221
232
  updateCtx(); updateHint(); requestDraw();
222
233
  },
234
+ /** (something being dragged: not a box drawn round things, nor a press that hasn't moved anything) */
235
+ magnify() { return !!this.st.drag; },
223
236
  hint() {
224
237
  if (ed.sel.length) return selectionHint();
225
238
  return ed.lastPointerTouch
@@ -355,7 +368,11 @@ function beginDrag(h: Hit, down: PEv): Drag | null {
355
368
  if (it.type === 'point') s.add(it.id);
356
369
  if (it.type === 'arc') addArc(it.id);
357
370
  if (it.type === 'circle') { const ci = sk.circles[it.id]; if (ci) s.add(ci.c); }
358
- if (it.type === 'room') { const r = sk.rooms[it.id]; if (r) r.loop.forEach(p => s.add(p)); }
371
+ if (it.type === 'room') {
372
+ const r = sk.rooms[it.id];
373
+ // (its corners — and the centre of a circle it runs round, which has none of its own)
374
+ if (r) { r.loop.forEach(p => s.add(p)); for (const e of r.edges ?? []) { const ce = circleEdge(e), ci = ce && sk.circles[ce.cid]; if (ci) s.add(ci.c); } }
375
+ }
359
376
  }
360
377
  } else {
361
378
  if (h.type === 'line') addL(h.id);
@@ -373,7 +390,7 @@ function beginDrag(h: Hit, down: PEv): Drag | null {
373
390
  if (p.fixed) { toast(p.origin ? 'The origin stays put. Lock or unlock other points from the bar below.' : p.anchor ? 'This corner is set by the plan — move the wall, door or window there.' : 'This point is locked. Unlock it to move it.', { warn: true }); return null; }
374
391
  // (one of several selected: the selection moves, as by one of its lines)
375
392
  if (several) return multi(ptsOf());
376
- return { kind: 'point', id: h.id, start: { x: p.x, y: p.y }, grab: { x: wx, y: wy }, snap: null };
393
+ return { kind: 'point', id: h.id, start: { x: p.x, y: p.y }, grab: { x: wx, y: wy }, snap: null, arcs: arcEnds(sk, h.id) };
377
394
  }
378
395
  if (h.type === 'cab') {
379
396
  // a cabinet moves as a whole (and the rest of the selection with it, when it's part of it)
@@ -403,7 +420,10 @@ function beginDrag(h: Hit, down: PEv): Drag | null {
403
420
  if (h.type === 'dim') {
404
421
  const c = sk.cons[h.id];
405
422
  if (c.type === 'diam') return { kind: 'diamlabel', id: h.id };
406
- if (c.type !== 'dist') return null;
423
+ if (c.type === 'angle') return { kind: 'anglelabel', id: h.id };
424
+ // (a distance between two parallel lines or walls slides along them: the user's call, 2026-10-24, when one made
425
+ // by selecting two walls across from each other wouldn't leave where it was first drawn)
426
+ if (c.type !== 'dist' && c.type !== 'pldist') return null;
407
427
  return { kind: 'dimoff', id: h.id };
408
428
  }
409
429
  if (h.type === 'opening') {
@@ -449,6 +469,41 @@ function groupDrag(sk: Sketch, h: Hit, wx: number, wy: number): Drag | null {
449
469
  /** How far things ran past their walls when each drag began: it may not go further. */
450
470
  const dragBase = new WeakMap<Drag, Overflow>();
451
471
 
472
+ /** The arcs a point is an end of (not their centre), each with how far round it goes now. */
473
+ function arcEnds(sk: Sketch, pid: string): ArcEnd[] {
474
+ const out: ArcEnd[] = [];
475
+ for (const id in sk.arcs) {
476
+ const a = sk.arcs[id];
477
+ if (a.a === a.b || a.c === pid || (a.a !== pid && a.b !== pid)) continue;
478
+ const g = arcAngles(sk, a);
479
+ out.push({ id, other: a.a === pid ? a.b : a.a, end: a.a === pid ? 'a' : 'b', sweep: g.full ? 2 * Math.PI : normAng(g.t1 - g.t0), closed: false });
480
+ }
481
+ return out;
482
+ }
483
+
484
+ /**
485
+ * An arc's end dragged all the way round onto its other end — or on past it — closes it (the user's call, 2026-10-29:
486
+ * "if u fully rotate it to reach around - it should create a circle"): it's held on the other end, drawn all the way
487
+ * round (arcAngles' `full`), and joined into a circle when it's let go (endDrag). Back the way it came, it opens again.
488
+ * Only from more than half way round: dragged back onto its start the short way, an arc shrinks — it isn't snapped onto
489
+ * its own other end, which would close it. `s` is where the end would go otherwise, `raw` the pointer; returns where it goes.
490
+ */
491
+ function closeArcs(sk: Sketch, arcs: ArcEnd[], s: Snap, raw: XY, tol: number): Snap {
492
+ for (const t of arcs) {
493
+ const a = sk.arcs[t.id], o = a && sk.points[a.c], q = a && sk.points[t.other];
494
+ if (!a || !o || !q) continue;
495
+ // how far round it would go with the end at the pointer (from its other end, the way it runs: counter-clockwise a → b)
496
+ const th = Math.atan2(raw.y - o.y, raw.x - o.x), tq = Math.atan2(q.y - o.y, q.x - o.x);
497
+ const sweep = t.end === 'b' ? normAng(th - tq) : normAng(tq - th), near = hyp(raw.x - q.x, raw.y - q.y) <= tol;
498
+ if (t.closed) { if (sweep > 1.5 * Math.PI && !near) t.closed = false; }
499
+ else if ((near && t.sweep > Math.PI) || (t.sweep > 1.5 * Math.PI && sweep < 0.5 * Math.PI)) t.closed = true;
500
+ if (t.closed) return { x: q.x, y: q.y, kind: 'point', ref: t.other, label: 'Endpoint', guides: [] };
501
+ t.sweep = sweep;
502
+ if (s.kind === 'point' && s.ref === t.other) return { x: raw.x, y: raw.y, kind: 'free', ref: null, guides: [] };
503
+ }
504
+ return s;
505
+ }
506
+
452
507
  function dragTo(d: Drag, ev: PEv): void {
453
508
  const sk = SK();
454
509
  const [wx, wy] = s2w(ev.sx, ev.sy);
@@ -482,16 +537,22 @@ function dragTo(d: Drag, ev: PEv): void {
482
537
  // (not a line already held level or plumb: its direction can't change)
483
538
  const squared = (l: { a: string; b: string }) => Object.values(sk.cons).some(c => (c.type === 'h' || c.type === 'v') && ((c.a === l.a && c.b === l.b) || (c.a === l.b && c.b === l.a)));
484
539
  const ends = ev.shift ? Object.values(sk.lines).filter(l => !l.anchor && (l.a === d.id || l.b === d.id) && !squared(l)).map(l => sk.points[l.a === d.id ? l.b : l.a]) : [];
540
+ let locked = false;
485
541
  if (ends.length) {
486
542
  const q = ends.map(o => snap45(o, { x: wx, y: wy })).reduce((m, c) => (hyp(c.x - wx, c.y - wy) < hyp(m.x - wx, m.y - wy) ? c : m));
487
543
  // where the drawing can't take it there (other constraints), it's an ordinary drag — never stuck
488
- if (go([d.id], [{ x: q.x, y: q.y }])) { d.snap = null; ed.snapState = null; return; }
489
- restoreFit(sk, held!);
544
+ locked = go([d.id], [{ x: q.x, y: q.y }]);
545
+ if (locked) { d.snap = null; ed.snapState = null; } else restoreFit(sk, held!);
546
+ }
547
+ // (either way on to what every step of a drag does below — its doors and windows, and drawing it again: returning
548
+ // here left a Shift-drag frozen on the screen until the pointer lifted)
549
+ if (!locked) {
550
+ // (an arc's end, round onto its other end: closed — see closeArcs)
551
+ const s = closeArcs(sk, d.arcs, snapAt(ev.sx, ev.sy, { exclude: new Set([d.id]), touch: ev.touch }), { x: wx, y: wy }, (ev.touch ? 16 : 10) / mainT().s);
552
+ d.snap = s.kind === 'point' || s.kind === 'mid' || s.kind === 'online' || s.kind === 'oncircle' ? s : null;
553
+ ed.snapState = d.snap ? s : null;
554
+ go([d.id], [{ x: s.x, y: s.y }]);
490
555
  }
491
- const s = snapAt(ev.sx, ev.sy, { exclude: new Set([d.id]), touch: ev.touch });
492
- d.snap = s.kind === 'point' || s.kind === 'mid' || s.kind === 'online' || s.kind === 'oncircle' ? s : null;
493
- ed.snapState = d.snap ? s : null;
494
- go([d.id], [{ x: s.x, y: s.y }]);
495
556
  } else if (d.kind === 'multi') {
496
557
  let mx = wx - d.grab.x, my = wy - d.grab.y;
497
558
  // Shift: moved level, plumb or 45° only
@@ -529,10 +590,20 @@ function dragTo(d: Drag, ev: PEv): void {
529
590
  c.ang = Math.atan2(wy - g.o.y, wx - g.o.x);
530
591
  c.off = Math.max(0, hyp(wx - g.o.x, wy - g.o.y) - g.r - 40 / mainT().s);
531
592
  }
593
+ } else if (d.kind === 'anglelabel') {
594
+ // (where the label's dragged: as far out from its corner, and round it as far as its two lines; never into it)
595
+ const c = sk.cons[d.id], g = angleCorner(sk, c);
596
+ if (g) {
597
+ const a = Math.atan2(wy - g.vtx.y, wx - g.vtx.x);
598
+ c.off = Math.max(24 / mainT().s, hyp(wx - g.vtx.x, wy - g.vtx.y));
599
+ c.ang = g.u1 && g.u2 ? (({ t1, sweep }) => inSweep(a, t1, sweep))(angleSweep(g.u1, g.u2)) : a;
600
+ }
532
601
  } else if (d.kind === 'dimoff') {
533
- // (off the line it's drawn from: a wall's face, for a length measured on one)
534
- const c = sk.cons[d.id], e = dimEnds(sk, c), a = e ? e.a : sk.points[c.a!], b = e ? e.b : sk.points[c.b!];
535
- const n = norm2(-(b.y - a.y), b.x - a.x);
602
+ // (off the span it measures: a line, a wall's face for a length measured on one — or, for a distance between
603
+ // parallel lines or walls, along them)
604
+ const c = sk.cons[d.id], e = dimEnds(sk, c) ?? (c.a && c.b ? { a: sk.points[c.a], b: sk.points[c.b] } : null);
605
+ if (!e) return;
606
+ const { a, b } = e, n = norm2(-(b.y - a.y), b.x - a.x);
536
607
  c.off = (wx - (a.x + b.x) / 2) * n[0] + (wy - (a.y + b.y) / 2) * n[1];
537
608
  } else if (d.kind === 'wopening') {
538
609
  // a locked door won't move: say so once, then nothing
@@ -594,7 +665,17 @@ function endDrag(d: Drag): void {
594
665
  const sk = SK();
595
666
  ed.snapState = null;
596
667
  if (d.kind === 'wopening' && !d.drag.started) dropHistory(); // it didn't move
597
- if (d.kind === 'point' && d.snap && settings.autoCons) {
668
+ // an arc dragged all the way round onto its start: its ends joined, it's a circle (gc: closeArc). It's what the drag
669
+ // was, so whether or not auto-constrain is on
670
+ const closed = d.kind === 'point' ? d.arcs.find(t => t.closed) : undefined;
671
+ if (closed && d.kind === 'point') {
672
+ const was = JSON.stringify(sk);
673
+ if (mergePoints(sk, closed.other, d.id)) {
674
+ const r = solveSketch();
675
+ if (!r.ok) { replaceSK(JSON.parse(was) as Sketch); toast('Can’t close that arc — it conflicts with other constraints.', { warn: true }); }
676
+ else { ed.sel = sk.circles[closed.id] ? [{ type: 'circle', id: closed.id }] : []; toast('Closed into a circle'); }
677
+ }
678
+ } else if (d.kind === 'point' && d.snap && settings.autoCons) {
598
679
  const s = d.snap;
599
680
  if (s.kind === 'point') {
600
681
  const snap = JSON.stringify(sk);