@reekon-tools/boldr-cad 1.3.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 (252) hide show
  1. package/README.md +21 -4
  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/index.d.ts +1 -1
  46. package/dist/index.js +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/model/demo.d.ts +1 -0
  49. package/dist/model/demo.js +2 -1
  50. package/dist/model/demo.js.map +1 -1
  51. package/dist/model/elevation.d.ts +1 -1
  52. package/dist/model/elevation.js +3 -3
  53. package/dist/model/elevation.js.map +1 -1
  54. package/dist/model/history.js +4 -1
  55. package/dist/model/history.js.map +1 -1
  56. package/dist/model/links.d.ts +4 -1
  57. package/dist/model/links.js +9 -2
  58. package/dist/model/links.js.map +1 -1
  59. package/dist/model/measurement.d.ts +2 -2
  60. package/dist/model/measurement.js +3 -3
  61. package/dist/model/measurement.js.map +1 -1
  62. package/dist/model/openings.d.ts +10 -0
  63. package/dist/model/openings.js +18 -0
  64. package/dist/model/openings.js.map +1 -1
  65. package/dist/model/planGeom.d.ts +37 -2
  66. package/dist/model/planGeom.js +72 -9
  67. package/dist/model/planGeom.js.map +1 -1
  68. package/dist/model/project.js +5 -2
  69. package/dist/model/project.js.map +1 -1
  70. package/dist/model/rooms.d.ts +18 -5
  71. package/dist/model/rooms.js +178 -73
  72. package/dist/model/rooms.js.map +1 -1
  73. package/dist/model/scale.d.ts +30 -0
  74. package/dist/model/scale.js +120 -0
  75. package/dist/model/scale.js.map +1 -0
  76. package/dist/model/settings.d.ts +0 -1
  77. package/dist/model/settings.js +1 -1
  78. package/dist/model/settings.js.map +1 -1
  79. package/dist/model/sketch.d.ts +21 -1
  80. package/dist/model/sketch.js +58 -4
  81. package/dist/model/sketch.js.map +1 -1
  82. package/dist/model/stroke.js +2 -2
  83. package/dist/model/types.d.ts +38 -7
  84. package/dist/react/BottomBar.d.ts +2 -1
  85. package/dist/react/BottomBar.js +94 -25
  86. package/dist/react/BottomBar.js.map +1 -1
  87. package/dist/react/Drawer.js +3 -2
  88. package/dist/react/Drawer.js.map +1 -1
  89. package/dist/react/Duck.js.map +1 -1
  90. package/dist/react/KeypadSheet.js.map +1 -1
  91. package/dist/react/MarkupPanel.js +1 -1
  92. package/dist/react/PlanCanvas.native.js +54 -7
  93. package/dist/react/PlanCanvas.native.js.map +1 -1
  94. package/dist/react/Popovers.js +14 -3
  95. package/dist/react/Popovers.js.map +1 -1
  96. package/dist/react/Rail.js +1 -1
  97. package/dist/react/Sheet.d.ts +2 -2
  98. package/dist/react/Sheet.js.map +1 -1
  99. package/dist/react/barScroll.d.ts +23 -0
  100. package/dist/react/barScroll.js +47 -0
  101. package/dist/react/barScroll.js.map +1 -0
  102. package/dist/react/corners.d.ts +6 -2
  103. package/dist/react/corners.js.map +1 -1
  104. package/dist/react/host.d.ts +7 -0
  105. package/dist/react/host.js +14 -1
  106. package/dist/react/host.js.map +1 -1
  107. package/dist/react/motion.d.ts +1 -1
  108. package/dist/react/motion.js +1 -1
  109. package/dist/react/motion.js.map +1 -1
  110. package/dist/render/draw.d.ts +21 -4
  111. package/dist/render/draw.js +184 -149
  112. package/dist/render/draw.js.map +1 -1
  113. package/dist/render/elevation.js +3 -3
  114. package/dist/render/elevation.js.map +1 -1
  115. package/dist/render/figmaIcons.d.ts +6 -0
  116. package/dist/render/figmaIcons.js +16 -1
  117. package/dist/render/figmaIcons.js.map +1 -1
  118. package/dist/render/hit.d.ts +8 -1
  119. package/dist/render/hit.js +23 -4
  120. package/dist/render/hit.js.map +1 -1
  121. package/dist/render/icons.js +27 -20
  122. package/dist/render/icons.js.map +1 -1
  123. package/dist/render/lens.d.ts +27 -0
  124. package/dist/render/lens.js +41 -0
  125. package/dist/render/lens.js.map +1 -0
  126. package/dist/render/linkTile.d.ts +20 -2
  127. package/dist/render/linkTile.js +476 -246
  128. package/dist/render/linkTile.js.map +1 -1
  129. package/dist/render/snap.d.ts +5 -1
  130. package/dist/render/snap.js +3 -1
  131. package/dist/render/snap.js.map +1 -1
  132. package/dist/render/svgPath.d.ts +29 -0
  133. package/dist/render/svgPath.js +114 -0
  134. package/dist/render/svgPath.js.map +1 -0
  135. package/dist/render/tileIcons.d.ts +18 -0
  136. package/dist/render/tileIcons.js +67 -0
  137. package/dist/render/tileIcons.js.map +1 -0
  138. package/dist/render/view.d.ts +18 -1
  139. package/dist/render/view.js +31 -4
  140. package/dist/render/view.js.map +1 -1
  141. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  142. package/dist/skia/SkiaDrawCtx.js +27 -5
  143. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  144. package/dist/solver/equations.d.ts +6 -0
  145. package/dist/solver/equations.js +23 -6
  146. package/dist/solver/equations.js.map +1 -1
  147. package/dist/solver/gauss-newton.js +6 -1
  148. package/dist/solver/gauss-newton.js.map +1 -1
  149. package/dist/tools/annotate.d.ts +0 -2
  150. package/dist/tools/annotate.js +1 -22
  151. package/dist/tools/annotate.js.map +1 -1
  152. package/dist/tools/chamfer.js +4 -0
  153. package/dist/tools/chamfer.js.map +1 -1
  154. package/dist/tools/dim.d.ts +3 -3
  155. package/dist/tools/dim.js +26 -65
  156. package/dist/tools/dim.js.map +1 -1
  157. package/dist/tools/fillet.js +4 -0
  158. package/dist/tools/fillet.js.map +1 -1
  159. package/dist/tools/freehand.js +1 -4
  160. package/dist/tools/freehand.js.map +1 -1
  161. package/dist/tools/line.js +15 -11
  162. package/dist/tools/line.js.map +1 -1
  163. package/dist/tools/modify.js +6 -1
  164. package/dist/tools/modify.js.map +1 -1
  165. package/dist/tools/registry.d.ts +5 -0
  166. package/dist/tools/registry.js +6 -1
  167. package/dist/tools/registry.js.map +1 -1
  168. package/dist/tools/select.d.ts +19 -0
  169. package/dist/tools/select.js +116 -17
  170. package/dist/tools/select.js.map +1 -1
  171. package/dist/tools/shapes.d.ts +1 -1
  172. package/dist/tools/shapes.js +12 -5
  173. package/dist/tools/shapes.js.map +1 -1
  174. package/dist/tools/survey.d.ts +6 -0
  175. package/dist/tools/survey.js +28 -0
  176. package/dist/tools/survey.js.map +1 -1
  177. package/dist/view3d/build.js +9 -8
  178. package/dist/view3d/build.js.map +1 -1
  179. package/package.json +9 -9
  180. package/src/app/edit.ts +25 -1
  181. package/src/app/state.ts +3 -5
  182. package/src/data/sync.ts +7 -2
  183. package/src/editor/actions.ts +126 -112
  184. package/src/editor/elevation.ts +13 -16
  185. package/src/editor/gestures.ts +83 -13
  186. package/src/editor/keys.ts +2 -2
  187. package/src/editor/linkActions.ts +90 -13
  188. package/src/editor/mode.ts +4 -3
  189. package/src/editor/project.ts +1 -2
  190. package/src/editor/scaleGate.ts +32 -0
  191. package/src/editor/selection.ts +13 -7
  192. package/src/editor/tooltips.ts +8 -0
  193. package/src/editor/view3d.ts +6 -2
  194. package/src/export/pdf.ts +7 -4
  195. package/src/export/plan.ts +14 -10
  196. package/src/index.ts +1 -1
  197. package/src/model/demo.ts +2 -1
  198. package/src/model/elevation.ts +4 -4
  199. package/src/model/history.ts +4 -1
  200. package/src/model/links.ts +8 -2
  201. package/src/model/measurement.ts +3 -3
  202. package/src/model/openings.ts +18 -0
  203. package/src/model/planGeom.ts +70 -10
  204. package/src/model/project.ts +6 -2
  205. package/src/model/rooms.ts +151 -63
  206. package/src/model/scale.ts +105 -0
  207. package/src/model/settings.ts +1 -2
  208. package/src/model/sketch.ts +48 -4
  209. package/src/model/stroke.ts +2 -2
  210. package/src/model/types.ts +38 -7
  211. package/src/react/BottomBar.tsx +114 -29
  212. package/src/react/Drawer.tsx +14 -10
  213. package/src/react/Duck.tsx +3 -3
  214. package/src/react/KeypadSheet.tsx +2 -2
  215. package/src/react/MarkupPanel.tsx +1 -1
  216. package/src/react/PlanCanvas.native.tsx +60 -6
  217. package/src/react/Popovers.tsx +24 -9
  218. package/src/react/Rail.tsx +1 -1
  219. package/src/react/Sheet.tsx +3 -3
  220. package/src/react/barScroll.ts +57 -0
  221. package/src/react/corners.ts +6 -3
  222. package/src/react/host.ts +14 -1
  223. package/src/react/motion.tsx +6 -6
  224. package/src/render/draw.ts +156 -116
  225. package/src/render/elevation.ts +5 -4
  226. package/src/render/figmaIcons.ts +17 -1
  227. package/src/render/hit.ts +21 -4
  228. package/src/render/icons.ts +27 -20
  229. package/src/render/lens.ts +52 -0
  230. package/src/render/linkTile.ts +436 -190
  231. package/src/render/snap.ts +7 -2
  232. package/src/render/svgPath.ts +95 -0
  233. package/src/render/tileIcons.ts +75 -0
  234. package/src/render/view.ts +37 -5
  235. package/src/skia/SkiaDrawCtx.ts +26 -4
  236. package/src/solver/equations.ts +22 -6
  237. package/src/solver/gauss-newton.ts +4 -1
  238. package/src/styles.css +3 -2
  239. package/src/tools/annotate.ts +1 -18
  240. package/src/tools/chamfer.ts +3 -0
  241. package/src/tools/dim.ts +26 -52
  242. package/src/tools/fillet.ts +3 -0
  243. package/src/tools/freehand.ts +1 -3
  244. package/src/tools/line.ts +12 -11
  245. package/src/tools/modify.ts +5 -1
  246. package/src/tools/registry.ts +10 -1
  247. package/src/tools/select.ts +99 -18
  248. package/src/tools/shapes.ts +9 -4
  249. package/src/tools/survey.ts +21 -0
  250. package/src/ui/ctx.ts +4 -3
  251. package/src/ui/drawer.ts +8 -2
  252. package/src/view3d/build.ts +11 -10
@@ -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,7 +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 { laserReading, setLaserConnected, surveyCursor, surveyPoint } from './react/host';
13
+ export { laserReading, setLaserConnected, surveyClose, surveyCursor, surveyPoint } from './react/host';
14
14
  export type { HostKeypad, HostKeypadExtra, HostKeypadRequest, MeasurementList, MeasurementListPage } from './react/hostKeypad';
15
15
  export type { CadLinkPick, CadLinks, CadSaveMeasurement, MeasurementKind } from './editor/links';
16
16
  export type { LinkInfo, LinkTarget } from './model/types';
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
  }
@@ -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)) }));
@@ -64,10 +64,16 @@ export function sketchLinks(sk: Sketch): LinkTarget[] {
64
64
  return out;
65
65
  }
66
66
 
67
- /** Everything a project links to, each once: its pages' tiles and the measurements its dimensions show. */
67
+ /**
68
+ * Everything a project links to, each once: its pages' tiles, the photos expanded from tiles among their images, and
69
+ * the measurements its dimensions show.
70
+ */
68
71
  export function linkTargets(p: Project): LinkTarget[] {
69
72
  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);
73
+ for (const pg of p.pages) {
74
+ for (const t of sketchLinks(pg.sk)) if (!out.has(linkKey(t))) out.set(linkKey(t), t);
75
+ for (const u of pg.underlays ?? []) if (u.link && !out.has(linkKey(u.link))) out.set(linkKey(u.link), u.link);
76
+ }
71
77
  return [...out.values()];
72
78
  }
73
79
 
@@ -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
  }