@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
@@ -0,0 +1,32 @@
1
+ import { isUnscaled } from '../model/scale';
2
+ import { planSK } from '../model/store';
3
+ import { fmtLen } from '../units';
4
+ import { toast } from './ports';
5
+
6
+ /*
7
+ Before a new page's first length (model/scale.ts), its sizes are placeholders. So nothing shows one as if it were
8
+ real, and what needs real sizes waits: the 3D view, the wall view, doors, windows and cabinets (the user's call,
9
+ 2026-10-29), and the tools whose typed size has nothing drawn to be compared with (offset, fillet, chamfer).
10
+ */
11
+
12
+ /** The page being drawn has no scale yet: its sizes are placeholders. */
13
+ export const noScaleYet = (): boolean => isUnscaled(planSK());
14
+
15
+ export const NEEDS_SCALE = 'Enter a length first: select a line and tap +. It sets the size of the whole drawing.';
16
+
17
+ /** What needs real sizes, asked for before the page has its scale: says what to do first, and true (wait). */
18
+ export function waitForScale(): boolean {
19
+ if (!noScaleYet()) return false;
20
+ toast(NEEDS_SCALE, { warn: true });
21
+ return true;
22
+ }
23
+
24
+ /** Tools that place something with a real size, or take one typed: doors, windows and cabinets (the plan's and the wall view's), offsets. */
25
+ const SIZED_TOOLS = new Set(['door', 'window', 'wdoor', 'wwindow', 'cabinet', 'height', 'offset']);
26
+ export const toolWaitsForScale = (t: string): boolean => SIZED_TOOLS.has(t);
27
+
28
+ /** A length being entered, as its label shows it: what's typed, else what it measures now — nothing while that's a placeholder. */
29
+ export const pendingLabel = (typed: number | null, now: number): string => (typed != null ? fmtLen(typed) : noScaleYet() ? '' : fmtLen(now));
30
+
31
+ /** A keypad's starting value: what it measures now, or nothing while that's only a placeholder. */
32
+ export const startingValue = (v: number): { initial: string; value?: number } => (noScaleYet() ? { initial: '' } : { initial: fmtLen(v), value: v });
@@ -11,9 +11,13 @@ import { faceLength } from '../model/wallfaces';
11
11
  import { planSK, SK } from '../model/store';
12
12
  import type { Sketch } from '../model/types';
13
13
  import { fmtArea, fmtLen } from '../units';
14
+ import { noScaleYet } from './scaleGate';
14
15
 
15
16
  /* What is selected, grouped by kind, and the one-line hint describing it (bold parts in <b>). */
16
17
 
18
+ /** A size in a hint, after a space — none on a drawing without its scale yet (a placeholder isn't a size). */
19
+ const sized = (text: string): string => (noScaleYet() ? '' : ' ' + text);
20
+
17
21
  export function selInfo() {
18
22
  const g = (t: string) => ed.sel.filter(s => s.type === t).map(s => s.id);
19
23
  const circles = g('circle'), arcs = g('arc');
@@ -54,13 +58,13 @@ export function selectionHint(): string {
54
58
  if (s.n === 1 && s.lines.length && sk.lines[s.lines[0]]?.anchor) return '<b>Set by the plan</b> · pick a point or line too, to constrain or dimension to it';
55
59
  if (s.n === 1 && s.lines.length) {
56
60
  const l = sk.lines[s.lines[0]];
57
- return '<b>' + (l.kind === 'wall' ? 'Wall' : l.constr ? 'Construction line' : 'Line') + ' ' + fmtLen(lineLen(sk, l)) + '</b> · ' + (lineDefined(ed.ana, l) ? 'fully defined' : lengthDimOf(sk, s.lines[0]) ? 'pick a constraint below or drag to adjust' : 'tap <b>+</b> below ' + forLength() + ', or pick a constraint');
61
+ return '<b>' + (l.kind === 'wall' ? 'Wall' : l.constr ? 'Construction line' : 'Line') + sized(fmtLen(lineLen(sk, l))) + '</b> · ' + (lineDefined(ed.ana, l) ? 'fully defined' : lengthDimOf(sk, s.lines[0]) ? 'pick a constraint below or drag to adjust' : 'tap <b>+</b> below ' + forLength() + ', or pick a constraint');
58
62
  }
59
63
  if (s.n === 1 && s.wfaces.length) {
60
64
  const [id, side] = edgeOf(s.wfaces[0]);
61
65
  // (a curved wall's: its diameter across it)
62
- if (!sk.lines[id]) return '<b>' + edgeLabel(sk, id, side) + ' Ø ' + fmtLen(measure(sk, { type: 'diam', c: id, face: side })) + '</b> · tap <b>+</b> for its diameter';
63
- return '<b>' + edgeLabel(sk, id, side) + ' ' + fmtLen(faceLength(sk, id, side)) + '</b> · tap <b>+</b> ' + forLength() + ', or pick another edge or a point to measure from it';
66
+ if (!sk.lines[id]) return '<b>' + edgeLabel(sk, id, side) + sized('Ø ' + fmtLen(measure(sk, { type: 'diam', c: id, face: side }))) + '</b> · tap <b>+</b> for its diameter';
67
+ return '<b>' + edgeLabel(sk, id, side) + sized(fmtLen(faceLength(sk, id, side))) + '</b> · tap <b>+</b> ' + forLength() + ', or pick another edge or a point to measure from it';
64
68
  }
65
69
  if (edgePair(s)) {
66
70
  const one = s.wfaces.length === 2 && edgeOf(s.wfaces[0])[0] === edgeOf(s.wfaces[1])[0];
@@ -75,6 +79,7 @@ export function selectionHint(): string {
75
79
  if (s.n === 1 && s.cons.length) return '<b>' + consLabel(sk.cons[s.cons[0]]) + '</b> constraint · delete it to free the shape';
76
80
  if (s.n === 1 && s.rooms.length) {
77
81
  const r = sk.rooms[s.rooms[0]], g = roomGeom(sk, r);
82
+ if (noScaleYet()) return '<b>' + r.name + '</b> · its area shows once the drawing has a length';
78
83
  return '<b>' + r.name + '</b> · ' + fmtArea(g.area) + ' · ' + fmtLen(g.per) + ' perimeter';
79
84
  }
80
85
  if (s.n === 1 && s.openings.length) {
@@ -86,7 +91,6 @@ export function selectionHint(): string {
86
91
  }
87
92
  if (s.n === 1 && s.dims.length) {
88
93
  const c = sk.cons[s.dims[0]];
89
- if (c.layer === 'print') return '<b>Print dimension</b> · drag to move the label';
90
94
  if (c.measurementRef) return '<b>' + consLabel(c) + '</b> · linked to <b>' + linkName(c.measurementRef) + '</b> · tap <b>+</b> to change it';
91
95
  return '<b>' + consLabel(c) + '</b> · tap <b>+</b> to change it';
92
96
  }
@@ -96,9 +100,11 @@ export function selectionHint(): string {
96
100
  if (part === 'w') return '<b>' + (planSK().openings[s.odims[0].split('|')[0]]?.type === 'door' ? 'Door' : 'Window') + ' width</b> · tap <b>+</b> to change it';
97
101
  return part.startsWith('d:') ? '<b>Door/window dimension</b> · tap <b>+</b> to change it' : '<b>Distance to the wall end</b> · tap <b>+</b> to lock it';
98
102
  }
99
- if (s.n === 1 && s.image) return imageById(ed.sel[0].id)?.locked
100
- ? '<b>Image</b> · locked — tap Unlock to move or resize it'
101
- : '<b>Image</b> · drag to move · corners scale, side handles stretch';
103
+ if (s.n === 1 && s.image) {
104
+ // (a photo of the job's, expanded from its tile: its link opens it, and Expand folds it back)
105
+ const u = imageById(ed.sel[0].id), what = u?.link ? '<b>Photo</b>' : '<b>Image</b>', link = u?.link ? ' · its link opens it' : '';
106
+ return u?.locked ? what + ' · locked — tap Unlock to move or resize it' + link : what + ' · drag to move · corners scale, side handles crop' + link;
107
+ }
102
108
  if (s.n === 1 && s.notes.length && SK().notes.find(n => n.id === s.notes[0])?.type === 'link') return '<b>Link</b> · drag to move · open it, or point it at the drawing with a leader, below · Size sizes every tile on the page';
103
109
  if (s.notes.length) return 'Drag to move. Turn a pen stroke into lines below.';
104
110
  return '<b>' + s.n + ' selected</b> · choose a constraint below';
@@ -1,5 +1,6 @@
1
1
  import { ed } from '../app/state';
2
2
  import { settings } from '../model/settings';
3
+ import { noScaleYet, toolWaitsForScale } from './scaleGate';
3
4
 
4
5
  /*
5
6
  What a tool does, said in a line when it's picked from the rail (a quick note under the Annotate / Draft tabs that
@@ -14,6 +15,7 @@ const TIPS: Record<string, string> = {
14
15
  rect: 'Tap or drag corner to corner.',
15
16
  circle: 'Tap the center, then the edge.',
16
17
  arc: 'Tap both ends, then drag to bend.',
18
+ trim: 'Tap or drag across the pieces to cut away.',
17
19
  door: 'Tap a wall to add a 3\'-0" door.',
18
20
  window: 'Tap a wall to add a 3\'-0" window.',
19
21
  room: 'Tap inside a closed shape.',
@@ -34,14 +36,21 @@ const TIPS: Record<string, string> = {
34
36
  text: 'Tap where it goes, then type.',
35
37
  };
36
38
 
39
+ /** The tools that draw shapes: on a page without its scale, what they draw is a sketch. */
40
+ const SKETCHING = new Set(['line', 'freehand', 'rect', 'circle', 'arc']);
41
+
37
42
  /**
38
43
  * The line for a rail item just picked (its name in bold), or null if it has none (Undo, a colour, an action) or the
39
44
  * notes are turned off (••• → Tool tips).
40
45
  */
41
46
  export function toolTip(id: string, label: string): string | null {
42
47
  if (settings.toolTips === false) return null;
48
+ // (on a page without its scale a door, a window or an offset isn't picked: a toast says to enter a length first)
49
+ if (toolWaitsForScale(id) && noScaleYet()) return null;
43
50
  if (id === 'constr') return ed.drawConstr ? '<b>Construction lines on</b> · new lines are dashed guides.' : '<b>Construction lines off</b> · new lines are solid.';
44
51
  if (id === 'wall') return ed.drawWalls ? '<b>Walls on</b> · what you draw is walls.' : '<b>Walls off</b> · what you draw is lines.';
45
52
  const t = TIPS[id];
53
+ // drawing on a page without its scale: sketched in proportion, sized by its first length (editor/scaleGate.ts)
54
+ if (t && SKETCHING.has(id) && noScaleYet()) return '<b>' + label + '</b> · ' + t + ' Your first length sets the size.';
46
55
  return t ? '<b>' + label + '</b> · ' + t : null;
47
56
  }
@@ -16,6 +16,7 @@ import type { KeyIn } from './keys';
16
16
  import { setMode } from './mode';
17
17
  import { dismissConflict, dismissPopovers, refreshModes, updateCtx, updateHint, updateRail } from './ports';
18
18
  import { command3d, pick3d, v3 } from './view3dState';
19
+ import { waitForScale } from './scaleGate';
19
20
 
20
21
  /*
21
22
  The 3D tab: opening and leaving it, its rail's views, what the bar shows for something tapped in it (its sizes, the
@@ -30,9 +31,12 @@ import { command3d, pick3d, v3 } from './view3dState';
30
31
  export const view3dKey = (): string =>
31
32
  [store.curPageId, ed.editSeq, settings.wallH, v3.cutaway, v3.cmd.seq, v3.pick ? v3.pick.kind + ':' + v3.pick.id : ''].join('|');
32
33
 
33
- /** Show the 3D tab: whatever tool was in hand is put down, and nothing is selected. */
34
+ /**
35
+ * Show the 3D tab: whatever tool was in hand is put down, and nothing is selected. Not before the page's first length:
36
+ * its walls' heights are real and their lengths only placeholders until then (editor/scaleGate.ts).
37
+ */
34
38
  export function open3d(): void {
35
- if (v3.open) return;
39
+ if (v3.open || waitForScale()) return;
36
40
  cancelTool();
37
41
  dismissPopovers();
38
42
  if (elevOpen()) closeElevation();
package/src/export/pdf.ts CHANGED
@@ -20,10 +20,13 @@ const SCALES: [k: number, label: string][] = [
20
20
  [3 / 16, '3/16"'], [1 / 8, '1/8"'], [3 / 32, '3/32"'], [1 / 16, '1/16"'], [1 / 32, '1/32"'],
21
21
  ];
22
22
 
23
- /** Pick a scale for a drawing w × h inches: points per world inch, and its label. */
24
- export function pickScale(w: number, h: number): { s: number; label: string } {
23
+ /**
24
+ * Pick a scale for a drawing w × h inches: points per world inch, and its label. A sketch without a scale yet (its
25
+ * sizes only placeholders: model/scale.ts) is fitted to the sheet, not to scale.
26
+ */
27
+ export function pickScale(w: number, h: number, sketchOnly = false): { s: number; label: string } {
25
28
  const aw = AREA.x1 - AREA.x0, ah = AREA.y1 - AREA.y0;
26
- for (const [k, label] of SCALES) {
29
+ if (!sketchOnly) for (const [k, label] of SCALES) {
27
30
  const s = (72 * k) / 12;
28
31
  if (w * s <= aw && h * s <= ah) return { s, label: label + " = 1'-0\"" };
29
32
  }
@@ -166,7 +169,7 @@ export function sketchToPdf(sk: Sketch, opts: PdfOptions = {}): string {
166
169
  const rec = new Recorder();
167
170
  emitDrawing(sk, rec, opts);
168
171
  const bb = rec.bbox();
169
- const { s, label } = bb ? pickScale(bb.x1 - bb.x0, bb.y1 - bb.y0) : { s: 1, label: 'Not to scale' };
172
+ const { s, label } = bb ? pickScale(bb.x1 - bb.x0, bb.y1 - bb.y0, !!sk.unscaled) : { s: 1, label: 'Not to scale' };
170
173
  const cx = bb ? (bb.x0 + bb.x1) / 2 : 0, cy = bb ? (bb.y0 + bb.y1) / 2 : 0;
171
174
  const ox = (AREA.x0 + AREA.x1) / 2, oy = (AREA.y0 + AREA.y1) / 2;
172
175
  const T = (p: XY): XY => ({ x: ox + (p.x - cx) * s, y: oy + (p.y - cy) * s });
@@ -1,10 +1,10 @@
1
1
  import { isDim, measure } from '../model/constraints';
2
2
  import { hyp, norm2, pointInPoly } from '../model/geom';
3
- import { edgeS, measureOpeningDim, openingSpan, refPoint, refS } from '../model/openings';
3
+ import { edgeS, hingeEdge, measureOpeningDim, openingSpan, refPoint, refS } from '../model/openings';
4
4
  import { roomGeom } from '../model/rooms';
5
5
  import { smoothPolyline } from '../model/smooth';
6
6
  import { roomValid } from '../model/sketch';
7
- import { dimEnds, dimRadius } from '../model/planGeom';
7
+ import { angleLabel, dimEnds, dimRadius } from '../model/planGeom';
8
8
  import { wallPieces } from '../model/wallshape';
9
9
  import { cabinetFootprints } from '../model/facesolve';
10
10
  import { isSegment, noteLook, noteTextIn, shapeOutline } from '../model/markup';
@@ -41,7 +41,7 @@ export interface Plan {
41
41
  }
42
42
 
43
43
  export interface DrawOptions {
44
- /** include dimensions (driving, reference, print, door/window) — default true */
44
+ /** include dimensions (driving, reference, door/window) — default true */
45
45
  dims?: boolean;
46
46
  /** include markup (pen, lines, arrows, shapes, clouds, notes) — default true */
47
47
  markup?: boolean;
@@ -59,7 +59,7 @@ const defaultLinkLabel = (t: LinkTarget): { name: string; value?: string } => {
59
59
  const info = linkInfoOf(t);
60
60
  return { name: (info?.exists === false ? 'Removed: ' : '') + linkName(t), value: t.kind === 'measurement' ? info?.text ?? t.text : undefined };
61
61
  };
62
- const KIND_WORD: Record<string, string> = { photo: 'Photo', pdf: 'PDF', note: 'Note', calculator: 'Calculator', label: 'Label', drawing: 'Drawing' };
62
+ const KIND_WORD: Record<string, string> = { photo: 'Photo', pdf: 'PDF', note: 'Note', calculator: 'Calculator', conversion: 'Conversion', label: 'Label', drawing: 'Sketch', cad: 'CAD' };
63
63
 
64
64
  /**
65
65
  * A name on paper, where nothing can be measured (a DXF has no fonts): about `perLine` characters a line, broken at a
@@ -201,7 +201,8 @@ export function emitDrawing(sk: Sketch, d: Plan, opts: DrawOptions = {}): void {
201
201
  if (o.type === 'window') {
202
202
  for (const k of [t / 6, -t / 6]) d.line(layer, Q(s0, k), Q(s1, k));
203
203
  } else {
204
- const f = o.flip ? -1 : 1, hinge = Q(s0, (f * t) / 2), leaf = Q(s0, f * (t / 2 + g.w)), shut = Q(s1, (f * t) / 2);
204
+ const f = o.flip ? -1 : 1, [hs, ls] = hingeEdge(o) === 's1' ? [s1, s0] : [s0, s1];
205
+ const hinge = Q(hs, (f * t) / 2), leaf = Q(hs, f * (t / 2 + g.w)), shut = Q(ls, (f * t) / 2);
205
206
  d.line(layer, hinge, leaf);
206
207
  // swing: from the closed position to the open leaf, the short way round
207
208
  const a0 = Math.atan2(shut.y - hinge.y, shut.x - hinge.x), a1 = Math.atan2(leaf.y - hinge.y, leaf.x - hinge.x);
@@ -219,14 +220,15 @@ export function emitDrawing(sk: Sketch, d: Plan, opts: DrawOptions = {}): void {
219
220
  if (!roomValid(sk, r)) continue;
220
221
  const g = roomGeom(sk, r);
221
222
  d.text('SPACES', { x: g.c.x, y: g.c.y + 4 }, 6, r.name);
222
- d.text('SPACES', { x: g.c.x, y: g.c.y - 4 }, TEXT_H, fmtArea(g.area));
223
+ // (a sketch without its scale yet: its area is only a placeholder)
224
+ if (!sk.unscaled) d.text('SPACES', { x: g.c.x, y: g.c.y - 4 }, TEXT_H, fmtArea(g.area));
223
225
  }
224
226
 
225
227
  if (opts.dims !== false) {
226
228
  for (const id in sk.cons) {
227
229
  const c = sk.cons[id];
228
230
  if (!isDim(c)) continue;
229
- const ref = c.driving === false && c.layer !== 'print';
231
+ const ref = c.driving === false;
230
232
  const v = c.driving === false ? measure(sk, c) : c.value!;
231
233
  const wrap = (s: string) => (ref ? '(' + s + ')' : s);
232
234
  if (c.type === 'dist') {
@@ -241,11 +243,11 @@ export function emitDrawing(sk: Sketch, d: Plan, opts: DrawOptions = {}): void {
241
243
  const off = c.off || (c.line ? WALL_DIM_OFF * outsideSide(sk, c.line) : 0);
242
244
  linearDim(d, a, b, n, off, wrap(fmtLen(v)));
243
245
  } else if (c.type === 'pldist') {
244
- // (between walls' faces: face to face)
246
+ // (between walls' faces: face to face, where it was slid to along them)
245
247
  const e = dimEnds(sk, c);
246
248
  if (!e) continue;
247
249
  const n = norm2(-(e.b.y - e.a.y), e.b.x - e.a.x);
248
- linearDim(d, e.a, e.b, n, 0, wrap(fmtLen(v)));
250
+ linearDim(d, e.a, e.b, n, c.off || 0, wrap(fmtLen(v)));
249
251
  } else if (c.type === 'diam') {
250
252
  // (across a curved wall's face: on that face)
251
253
  const g = dimRadius(sk, c);
@@ -261,7 +263,9 @@ export function emitDrawing(sk: Sketch, d: Plan, opts: DrawOptions = {}): void {
261
263
  if (!sh) continue;
262
264
  const s = P(sh), o1 = P(l1.a === sh ? l1.b : l1.a), o2 = P(l2.a === sh ? l2.b : l2.a);
263
265
  const u1 = norm2(o1.x - s.x, o1.y - s.y), u2 = norm2(o2.x - s.x, o2.y - s.y), bis = norm2(u1[0] + u2[0], u1[1] + u2[1]);
264
- d.text('DIMENSIONS', { x: s.x + bis[0] * 18, y: s.y + bis[1] * 18 }, TEXT_H, wrap(fmtAng(v)));
266
+ // (pulled off its corner: where it was left)
267
+ const moved = angleLabel(sk, c);
268
+ d.text('DIMENSIONS', moved ? { x: moved.vtx.x + Math.cos(moved.ang) * moved.r, y: moved.vtx.y + Math.sin(moved.ang) * moved.r } : { x: s.x + bis[0] * 18, y: s.y + bis[1] * 18 }, TEXT_H, wrap(fmtAng(v)));
265
269
  }
266
270
  }
267
271
  // door/window dimensions: placed ones, and the width
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export type { CadDataProvider, CadFileMeta, CadMeasurements, CadMeasurementValue
10
10
  export type { Unit } from './units';
11
11
  export { memoryProvider } from './data/memory';
12
12
  export { setFileSaver } from './react/ports';
13
+ export { BESIDE as CAD_BESIDE } from './react/pageLock';
13
14
  export { laserReading, setLaserConnected, surveyClose, surveyCursor, surveyPoint } from './react/host';
14
15
  export type { HostKeypad, HostKeypadExtra, HostKeypadRequest, MeasurementList, MeasurementListPage } from './react/hostKeypad';
15
16
  export type { CadLinkPick, CadLinks, CadSaveMeasurement, MeasurementKind } from './editor/links';
@@ -16,6 +16,7 @@ export function consLabel(c: Constraint): string {
16
16
  if (c.type === 'dist' || c.type === 'pldist') return (c.type === 'dist' ? (c.line ? 'Length ' : 'Distance ') : 'Offset ') + fmtLen(c.value!);
17
17
  if (c.type === 'diam') return c.rad ? 'Radius ' + fmtLen(c.value! / 2) : 'Diameter ' + fmtLen(c.value!);
18
18
  if (c.type === 'angle') return 'Angle ' + fmtAng(c.value!);
19
+ if (c.type === 'online' && c.face) return 'Point on face';
19
20
  return CONS_LABEL[c.type] || c.type;
20
21
  }
21
22
 
package/src/model/demo.ts CHANGED
@@ -24,8 +24,9 @@ export function demoSketch(): Sketch {
24
24
  return sk;
25
25
  }
26
26
 
27
+ /** A page: a copy's drawing (`sk`), or a new one's — without a scale until its first length (model/scale.ts). */
27
28
  export function newPage(name: string, sk?: Sketch): Page {
28
- return { id: uid('pg'), name, sk: sk || emptySketch(), underlays: [], view: null };
29
+ return { id: uid('pg'), name, sk: sk || { ...emptySketch(), unscaled: true }, underlays: [], view: null };
29
30
  }
30
31
 
31
32
  export function seedProject(): Project {
@@ -1,5 +1,5 @@
1
1
  import { hyp, pointInPoly } from './geom';
2
- import { edgeS, openingSpan } from './openings';
2
+ import { edgeS, hingeEdge, openingSpan } from './openings';
3
3
  import { roomGeom } from './rooms';
4
4
  import { settings } from './settings';
5
5
  import { roomValid } from './sketch';
@@ -87,7 +87,7 @@ export function elevFrame(sk: Sketch, lid: string, flip = false): ElevFrame | nu
87
87
 
88
88
  export interface ElevOpening {
89
89
  id: string; type: Opening['type']; u0: number; u1: number; sill: number; head: number;
90
- /** the door's hinge edge as seen (doors hinge at their s0 edge) */
90
+ /** the door's hinge edge as seen (Opening.hinge) */
91
91
  hinge: 'l' | 'r';
92
92
  /** the opening's edge (anchor) at u0 and at u1 */
93
93
  e0: OpeningEdge; e1: OpeningEdge;
@@ -101,8 +101,8 @@ export function elevOpenings(sk: Sketch, f: ElevFrame): ElevOpening[] {
101
101
  if (o.line !== f.lid) continue;
102
102
  const g = openingSpan(sk, o);
103
103
  if (!g) continue;
104
- const a = toU(f, edgeS(o, g.w, 's0')), b = toU(f, edgeS(o, g.w, 's1'));
105
- out.push({ id, type: o.type, u0: Math.min(a, b), u1: Math.max(a, b), ...openingHeights(o), hinge: a <= b ? 'l' : 'r', e0: a <= b ? 's0' : 's1', e1: a <= b ? 's1' : 's0' });
104
+ const a = toU(f, edgeS(o, g.w, 's0')), b = toU(f, edgeS(o, g.w, 's1')), left = (hingeEdge(o) === 's1' ? b <= a : a <= b);
105
+ out.push({ id, type: o.type, u0: Math.min(a, b), u1: Math.max(a, b), ...openingHeights(o), hinge: left ? 'l' : 'r', e0: a <= b ? 's0' : 's1', e1: a <= b ? 's1' : 's0' });
106
106
  }
107
107
  return out.sort((p, q) => p.u0 - q.u0);
108
108
  }
package/src/model/geom.ts CHANGED
@@ -34,6 +34,16 @@ export function lineCircleT(A: XY, B: XY, c: XY, r: number): number[] {
34
34
  return [(-b - s) / (2 * a), (-b + s) / (2 * a)].filter(t => t > 1e-6 && t < 1 - 1e-6);
35
35
  }
36
36
 
37
+ /** Where circle (c1, r1) crosses circle (c2, r2): none, one where they touch, or two. */
38
+ export function circleCircle(c1: XY, r1: number, c2: XY, r2: number): XY[] {
39
+ const dx = c2.x - c1.x, dy = c2.y - c1.y, d = hyp(dx, dy);
40
+ if (d < 1e-12 || d > r1 + r2 + 1e-9 || d < Math.abs(r1 - r2) - 1e-9) return [];
41
+ const a = (r1 * r1 - r2 * r2 + d * d) / (2 * d), h2 = r1 * r1 - a * a, h = h2 > 0 ? Math.sqrt(h2) : 0;
42
+ const mx = c1.x + (dx * a) / d, my = c1.y + (dy * a) / d;
43
+ if (h < 1e-12) return [{ x: mx, y: my }];
44
+ return [{ x: mx - (dy * h) / d, y: my + (dx * h) / d }, { x: mx + (dy * h) / d, y: my - (dx * h) / d }];
45
+ }
46
+
37
47
  /** Circumcenter of three points, or null if they are collinear. */
38
48
  export function circum(a: XY, b: XY, c: XY): XY | null {
39
49
  const d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
@@ -1,4 +1,5 @@
1
1
  import { page, store } from './store';
2
+ import { rezoomAcrossScale } from './scale';
2
3
  import type { Underlay } from './types';
3
4
  import { onSessionReset } from './session';
4
5
 
@@ -20,8 +21,10 @@ function take(): Snap {
20
21
  }
21
22
 
22
23
  function restore(s: Snap): void {
23
- const pg = page();
24
+ const pg = page(), was = pg.sk;
24
25
  pg.sk = JSON.parse(s.sk);
26
+ // the step that gave the drawing its scale zoomed the view to match (views aren't kept here): zoom back, or again
27
+ rezoomAcrossScale(pg, was, pg.sk);
25
28
  // an image uploaded since keeps its stored address (not the local copy it had when this was taken)
26
29
  const now = new Map(pg.underlays.map(u => [u.id, u.src]));
27
30
  pg.underlays = s.imgs.map(u => ({ ...u, src: stored(u.src, now.get(u.id)) }));
@@ -22,8 +22,13 @@ export const isLink = (n: Note): n is Note & { link: LinkTarget } => n.type ===
22
22
 
23
23
  /** How big a tile is on the screen as it's placed (points): its side on the drawing is what that is at the zoom then. */
24
24
  export const LINK_TILE_PX = 96;
25
- /** How big a tile can be on the drawing, inches; unset (never, once placed), this. */
25
+ /** How big a tile can be on the drawing as it's placed, inches; unset (never, once placed), this. */
26
26
  export const LINK_MIN_IN = 6, LINK_MAX_IN = 480, LINK_SIDE_IN = 24;
27
+ /**
28
+ * How big the Tile size slider can make one, inches: wide enough that its 10%–1000% of the size tiles came in at is
29
+ * there to reach, and inside what the tile's drawing clips to (render/linkTile.ts: clipToBar).
30
+ */
31
+ export const LINK_HARD_MIN_IN = 1, LINK_HARD_MAX_IN = 2400;
27
32
  /**
28
33
  * On the screen, points: from LINK_FULL_PX across a tile shows its picture or icon and its name; under LINK_PIN_PX it's
29
34
  * a pin of that size, however far out the drawing is.
@@ -64,10 +69,16 @@ export function sketchLinks(sk: Sketch): LinkTarget[] {
64
69
  return out;
65
70
  }
66
71
 
67
- /** Everything a project links to, each once: its pages' tiles and the measurements its dimensions show. */
72
+ /**
73
+ * Everything a project links to, each once: its pages' tiles, the photos expanded from tiles among their images, and
74
+ * the measurements its dimensions show.
75
+ */
68
76
  export function linkTargets(p: Project): LinkTarget[] {
69
77
  const out = new Map<string, LinkTarget>();
70
- for (const pg of p.pages) for (const t of sketchLinks(pg.sk)) if (!out.has(linkKey(t))) out.set(linkKey(t), t);
78
+ for (const pg of p.pages) {
79
+ for (const t of sketchLinks(pg.sk)) if (!out.has(linkKey(t))) out.set(linkKey(t), t);
80
+ for (const u of pg.underlays ?? []) if (u.link && !out.has(linkKey(u.link))) out.set(linkKey(u.link), u.link);
81
+ }
71
82
  return [...out.values()];
72
83
  }
73
84
 
@@ -9,11 +9,11 @@ import type { Constraint } from './types';
9
9
  const LENGTHS = new Set<Constraint['type']>(['dist', 'pldist', 'diam']);
10
10
 
11
11
  /**
12
- * Dimension `c`'s value (or `value`, one it's about to have) as a measurement. Null for what isn't one: reference and
13
- * print dimensions, measured-only ones, and constraints without a value.
12
+ * Dimension `c`'s value (or `value`, one it's about to have) as a measurement. Null for what isn't one: reference
13
+ * dimensions, measured-only ones, and constraints without a value.
14
14
  */
15
15
  export function dimMeasurement(c: Constraint, value = c.value): CadMeasurementValue | null {
16
- if (c.driving === false || c.layer === 'print' || value == null || c.auto) return null;
16
+ if (c.driving === false || value == null || c.auto) return null;
17
17
  if (c.type === 'angle') return { kind: 'angle', value: Math.round(value * 100) / 100 };
18
18
  // a fillet's radius is kept as its diameter; BOLDR keeps the radius
19
19
  if (LENGTHS.has(c.type)) return { kind: 'length', value: Math.round((c.rad ? value / 2 : value) * 25400) };
@@ -28,6 +28,24 @@ export function openingSpan(sk: Sketch, o: Opening): Span | null {
28
28
  /** Position of an edge, inches along the opening's line. */
29
29
  export const edgeS = (o: Opening, w: number, edge: OpeningEdge): number => (edge === 's0' ? o.at - w / 2 : o.at + w / 2);
30
30
 
31
+ /** The jamb a door hangs on (Opening.hinge): the plan, the wall view, 3D and exports all draw its leaf from there. */
32
+ export const hingeEdge = (o: Opening): OpeningEdge => o.hinge ?? 's0';
33
+
34
+ /**
35
+ * A door turned to the next of its four ways round, by Swing in the bar (the user's call, 2026-10-27: "4 options of flippings instead of 2
36
+ * (inner, outer, inner left, outer left) pressing the one button should toggle between the different options"):
37
+ * swinging into the room, it swings out on the same jamb; swinging out, it swings back in, hung on its other jamb. So
38
+ * inner, outer, inner left, outer left, and inner again, from whichever it was. `inside`: the side of its wall the
39
+ * room is on (+1: the left of its line's start → end).
40
+ */
41
+ export function flipDoor(o: Opening, inside: 1 | -1): void {
42
+ if ((o.flip ? -1 : 1) !== inside) {
43
+ if (hingeEdge(o) === 's0') o.hinge = 's1';
44
+ else delete o.hinge;
45
+ }
46
+ o.flip = !o.flip;
47
+ }
48
+
31
49
  /** World position of an opening edge anchor. */
32
50
  export function edgePoint(sk: Sketch, opId: string, edge: OpeningEdge): XY | null {
33
51
  const o = sk.openings[opId], g = o && openingSpan(sk, o);
@@ -1,6 +1,6 @@
1
1
  import { clamp, hyp, norm2, normAng, pointInPoly } from './geom';
2
- import { roomGeom } from './rooms';
3
- import { frameMemo, roomValid } from './sketch';
2
+ import { roomGeom, roomOutline } from './rooms';
3
+ import { circleEdge, frameMemo, roomValid } from './sketch';
4
4
  import type { Arc, Constraint, LineKind, Sketch, XY } from './types';
5
5
  import { faceCorner } from './wallfaces';
6
6
  import { getUnitPrefs, isMetric, type Unit } from '../units';
@@ -52,9 +52,16 @@ export function openingFrame(sk: Sketch, id: string): OpeningFrame | null {
52
52
  return { A, ux, uy, nx: -uy, ny: ux, s0: at - w / 2, s1: at + w / 2, at, w, t: l.kind === 'wall' ? l.thick : 2 };
53
53
  }
54
54
 
55
- export function arcAngles(sk: Sketch, a: Arc): { o: XY; r: number; t0: number; t1: number } {
55
+ /**
56
+ * An arc's centre, radius and angles: it runs counter-clockwise from t0 (its start, a) to t1 (its end, b). `full`: its
57
+ * ends have met — dragged all the way round onto its start — so it goes all the way round (t1 is t0 + 2π), as the
58
+ * circle it becomes when they join (select.ts; gc in model/sketch.ts), not nothing.
59
+ */
60
+ export function arcAngles(sk: Sketch, a: Arc): { o: XY; r: number; t0: number; t1: number; full: boolean } {
56
61
  const o = sk.points[a.c], s = sk.points[a.a], e = sk.points[a.b];
57
- return { o, r: hyp(s.x - o.x, s.y - o.y), t0: Math.atan2(s.y - o.y, s.x - o.x), t1: Math.atan2(e.y - o.y, e.x - o.x) };
62
+ const r = hyp(s.x - o.x, s.y - o.y), t0 = Math.atan2(s.y - o.y, s.x - o.x);
63
+ const full = a.a === a.b || hyp(e.x - s.x, e.y - s.y) <= r * 1e-7;
64
+ return { o, r, t0, t1: full ? t0 + 2 * Math.PI : Math.atan2(e.y - o.y, e.x - o.x), full };
58
65
  }
59
66
 
60
67
  /** +1 or -1: the side of a line facing away from any room (where opening dimensions go). */
@@ -65,7 +72,7 @@ export function outsideSide(sk: Sketch, lid: string): number {
65
72
  // (asked for every door and window on every frame: the rooms' outlines, and the boxes around them, are worked out
66
73
  // once a frame — a room the box rules out isn't looked at more closely)
67
74
  const rooms = frameMemo(sk, 'roomOutlines', () => Object.values(sk.rooms).filter(r => roomValid(sk, r)).map(r => {
68
- const poly = r.loop.map(p => sk.points[p]), xs = poly.map(p => p.x), ys = poly.map(p => p.y);
75
+ const poly = roomOutline(sk, r), xs = poly.map(p => p.x), ys = poly.map(p => p.y);
69
76
  return { poly, x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) };
70
77
  }));
71
78
  for (const r of rooms) if (m.x >= r.x0 && m.x <= r.x1 && m.y >= r.y0 && m.y <= r.y1 && pointInPoly(m, r.poly)) return -1;
@@ -88,13 +95,14 @@ export function curveFrame(sk: Sketch, id: string): { o: XY; r: number; arc?: {
88
95
  const a = sk.arcs[id];
89
96
  if (!a || !sk.points[a.c] || !sk.points[a.a] || !sk.points[a.b]) return null;
90
97
  const g = arcAngles(sk, a);
91
- return { o: g.o, r: g.r, arc: { t0: g.t0, t1: g.t1 } };
98
+ // (its ends met: all the way round, framed as the circle it's becoming)
99
+ return g.full ? { o: g.o, r: g.r } : { o: g.o, r: g.r, arc: { t0: g.t0, t1: g.t1 } };
92
100
  }
93
101
 
94
102
  /**
95
103
  * The space against one side of a wall, if any (a point just past its face there is inside it): its id. A straight
96
- * wall's sides are the left (+1) and right (−1) of its A → B; a curved one's, its outer (+1) and inner (−1) faces —
97
- * nothing is inside a circle (a space has corners: it can't be bounded by one).
104
+ * wall's sides are the left (+1) and right (−1) of its A → B; a curved one's, its outer (+1) and inner (−1) faces — a
105
+ * circle's inside too, a round space (model/rooms.ts).
98
106
  */
99
107
  export function spaceBeside(sk: Sketch, id: string, side: 1 | -1): string | null {
100
108
  const l = sk.lines[id];
@@ -105,21 +113,25 @@ export function spaceBeside(sk: Sketch, id: string, side: 1 | -1): string | null
105
113
  const m = { x: (a.x + b.x) / 2 + n[0] * k, y: (a.y + b.y) / 2 + n[1] * k };
106
114
  for (const rid in sk.rooms) {
107
115
  const r = sk.rooms[rid];
108
- if (roomValid(sk, r) && pointInPoly(m, r.loop.map(p => sk.points[p]))) return rid;
116
+ if (roomValid(sk, r) && pointInPoly(m, roomOutline(sk, r))) return rid;
109
117
  }
110
118
  return null;
111
119
  }
112
120
 
113
121
  function curveSpaceBeside(sk: Sketch, id: string, side: 1 | -1): string | null {
114
122
  const f = curveFrame(sk, id), w = sk.circles[id] ?? sk.arcs[id];
115
- if (!f || !w || (side === -1 && !f.arc)) return null;
123
+ if (!f || !w) return null;
116
124
  // (just past the face at the middle of the arc, or anywhere around a circle)
117
125
  const at = f.arc ? f.arc.t0 + normAng(f.arc.t1 - f.arc.t0) / 2 : 0, rr = f.r + side * (wallThick(w) / 2 + 1);
118
126
  if (rr <= 0) return null;
119
127
  const m = { x: f.o.x + Math.cos(at) * rr, y: f.o.y + Math.sin(at) * rr };
128
+ // inside a circle: only a space it bounds — a round column's inside isn't the room it stands in (a space has no
129
+ // holes: the room's outline goes over it)
130
+ const own = !f.arc && side === -1;
120
131
  // (a space's outline as it's drawn: its arcs as arcs, not chords)
121
132
  for (const rid in sk.rooms) {
122
133
  const r = sk.rooms[rid];
134
+ if (own && !(r.edges ?? []).some(e => circleEdge(e)?.cid === id)) continue;
123
135
  if (roomValid(sk, r) && pointInPoly(m, roomGeom(sk, r).poly)) return rid;
124
136
  }
125
137
  return null;
@@ -188,6 +200,54 @@ export function dimEnds(sk: Sketch, c: Constraint): { a: XY; b: XY } | null {
188
200
  return null;
189
201
  }
190
202
 
203
+ /**
204
+ * An angle dimension's corner: where its two lines meet, with `u1`/`u2` along each away from it (`len1`/`len2`: as far
205
+ * as each goes), and the bisector. For two lines that don't meet: the middle of the first, its label straight up.
206
+ */
207
+ export function angleCorner(sk: Sketch, c: Constraint): { vtx: XY; bis: [number, number]; u1?: [number, number]; u2?: [number, number]; len1?: number; len2?: number } | null {
208
+ const l1 = c.l1 ? sk.lines[c.l1] : undefined, l2 = c.l2 ? sk.lines[c.l2] : undefined;
209
+ if (!l1 || !l2) return null;
210
+ const P = (id: string) => sk.points[id];
211
+ const sh = [l1.a, l1.b].find(p => p === l2.a || p === l2.b);
212
+ if (sh) {
213
+ const s = P(sh), o1 = P(l1.a === sh ? l1.b : l1.a), o2 = P(l2.a === sh ? l2.b : l2.a);
214
+ const u1 = norm2(o1.x - s.x, o1.y - s.y), u2 = norm2(o2.x - s.x, o2.y - s.y);
215
+ return { vtx: s, bis: norm2(u1[0] + u2[0], u1[1] + u2[1]), u1, u2, len1: hyp(o1.x - s.x, o1.y - s.y), len2: hyp(o2.x - s.x, o2.y - s.y) };
216
+ }
217
+ const a = P(l1.a), b = P(l1.b);
218
+ return { vtx: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, bis: [0, 1] };
219
+ }
220
+
221
+ const wrapPi = (x: number) => { while (x > Math.PI) x -= 2 * Math.PI; while (x < -Math.PI) x += 2 * Math.PI; return x; };
222
+
223
+ /** The short way round from `u1` to `u2`: where it starts and how far it turns, world radians (negative: clockwise). */
224
+ export function angleSweep(u1: [number, number], u2: [number, number]): { t1: number; sweep: number } {
225
+ const t1 = Math.atan2(u1[1], u1[0]);
226
+ return { t1, sweep: wrapPi(Math.atan2(u2[1], u2[0]) - t1) };
227
+ }
228
+
229
+ /** Direction `a` (world radians) kept within a sweep: past either end of it, the nearer end. In (-π, π]. */
230
+ export function inSweep(a: number, t1: number, sweep: number): number {
231
+ const rel = wrapPi(a - t1), lo = Math.min(0, sweep), hi = Math.max(0, sweep);
232
+ if (rel >= lo && rel <= hi) return wrapPi(t1 + rel);
233
+ return wrapPi(t1 + (Math.abs(wrapPi(rel - lo)) <= Math.abs(wrapPi(rel - hi)) ? lo : hi));
234
+ }
235
+
236
+ /**
237
+ * An angle dimension pulled off its corner (the user's call, 2026-10-24: angles pull like lengths): its arc's radius
238
+ * (`off`), and its label's direction (`ang`), kept between its two lines, in the angle it measures. Null for one that
239
+ * hasn't been moved: it's drawn as it always was, a little arc at the corner and the label out along the bisector.
240
+ */
241
+ export function angleLabel(sk: Sketch, c: Constraint): { vtx: XY; r: number; ang: number } | null {
242
+ if (c.type !== 'angle' || c.off == null) return null;
243
+ const g = angleCorner(sk, c);
244
+ if (!g) return null;
245
+ const want = c.ang ?? Math.atan2(g.bis[1], g.bis[0]);
246
+ if (!g.u1 || !g.u2) return { vtx: g.vtx, r: c.off, ang: want };
247
+ const { t1, sweep } = angleSweep(g.u1, g.u2);
248
+ return { vtx: g.vtx, r: c.off, ang: inSweep(want, t1, sweep) };
249
+ }
250
+
191
251
  /**
192
252
  * Where a diameter (or radius) dimension's arrows go: its curve's centre, and the radius it measures at — the
193
253
  * centreline's, or on a curved wall's face, that face's.
@@ -1,15 +1,19 @@
1
1
  import { normalizeImages } from './images';
2
2
  import { newPage } from './demo';
3
+ import { settleScale } from './scale';
3
4
  import type { Page, Project } from './types';
4
5
 
6
+ /** A page as it's loaded: its images in today's form, and its scale settled (model/scale.ts). */
7
+ const tidy = (pg: Page): Page => { const n = normalizeImages(pg); settleScale(n.sk); return n; };
8
+
5
9
  /**
6
10
  * Older saves group pages into spaces/levels (Project → Space → Page). Flatten them, in order; when the same page
7
11
  * name appears in more than one level, it's prefixed with the level's name ("Level 2 · Floor plan").
8
12
  */
9
13
  export function migrateProject(p: { name: string; pages?: Page[]; spaces?: { name: string; pages: Page[] }[] }): Project {
10
- if (p.pages) return { name: p.name, pages: p.pages.map(normalizeImages) };
14
+ if (p.pages) return { name: p.name, pages: p.pages.map(tidy) };
11
15
  const spaces = p.spaces || [], count: Record<string, number> = {};
12
16
  for (const s of spaces) for (const pg of s.pages) count[pg.name] = (count[pg.name] || 0) + 1;
13
- const pages = spaces.flatMap(s => s.pages.map(pg => normalizeImages(count[pg.name] > 1 ? { ...pg, name: s.name + ' · ' + pg.name } : pg)));
17
+ const pages = spaces.flatMap(s => s.pages.map(pg => tidy(count[pg.name] > 1 ? { ...pg, name: s.name + ' · ' + pg.name } : pg)));
14
18
  return { name: p.name, pages: pages.length ? pages : [newPage('Floor plan')] };
15
19
  }