@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
package/src/app/edit.ts CHANGED
@@ -6,6 +6,7 @@ import { pushedOut, syncFaces } from '../model/facesolve';
6
6
  import { elevSyncFrame, fitState, overflow, pastWallMsg, restoreFit } from '../editor/elevation';
7
7
  import { resolveOpenings } from '../model/openings';
8
8
  import { gc, restorePos, snapshotPos } from '../model/sketch';
9
+ import { factorFor, isEnteredLength, isUnscaled, scalePage, setScale } from '../model/scale';
9
10
  import { page, planSK, saveSoon, SK } from '../model/store';
10
11
  import type { Constraint, SelItem, Sketch, WallFace } from '../model/types';
11
12
  import { cabOfPoint, cabPoints } from '../model/faces';
@@ -141,11 +142,34 @@ export function holdGroups(sk: Sketch, c: Constraint): string[][] {
141
142
  return groups;
142
143
  }
143
144
 
145
+ /**
146
+ * The drawing's first length (entered on an unscaled page's plan): the page is scaled so that what it measures is
147
+ * already that long, its view zoomed to match, so nothing moves on the screen (model/scale.ts). Returns how to take
148
+ * that back — the length was refused — or null when it isn't the first.
149
+ */
150
+ function scaleForFirstLength(c: Constraint): (() => void) | null {
151
+ const pg = page();
152
+ if (!isUnscaled(pg.sk) || SK() !== pg.sk || !isEnteredLength(c)) return null;
153
+ const k = factorFor(pg.sk, c, c.value!);
154
+ if (k == null) return null;
155
+ setScale(pg, k);
156
+ requestDraw();
157
+ // (in place, not a copy put back: callers keep using the sketch they had)
158
+ return () => { scalePage(pg, 1 / k); pg.sk.unscaled = true; delete pg.sk.scaledBy; requestDraw(); };
159
+ }
160
+
144
161
  /**
145
162
  * Add a constraint through the solver and explain the outcome:
146
163
  * reference dimension → toast, conflict → conflict sheet (unless silent).
147
164
  */
148
- export function tryAddConstraint(c: Constraint, { silent = false, onResolved = null as (() => void) | null } = {}): AddResult {
165
+ export function tryAddConstraint(c: Constraint, opts: { silent?: boolean; onResolved?: (() => void) | null } = {}): AddResult {
166
+ const unscale = scaleForFirstLength(c);
167
+ const r = addThroughSolver(c, opts);
168
+ if (unscale && !r.ok) unscale();
169
+ return r;
170
+ }
171
+
172
+ function addThroughSolver(c: Constraint, { silent = false, onResolved = null as (() => void) | null } = {}): AddResult {
149
173
  // nothing may be pushed past its wall (in the wall view, past the wall; in the plan, a wall made shorter than
150
174
  // what's on it): a constraint that would is taken back
151
175
  const sk = SK(), was = overflow(sk), held = fitState(sk);
package/src/app/state.ts CHANGED
@@ -12,13 +12,12 @@ export interface Hit {
12
12
  /** 'cabfoot': a cabinet's footprint in the plan (id: its wall face's key, '#', its id) */
13
13
  type: SelType | 'odim' | 'oanchor' | 'uhandle' | 'nhandle' | 'cabfoot';
14
14
  id: string;
15
- /** a print dimension label */
16
- print?: boolean;
17
15
  /** low-priority hit (room fill) */
18
16
  weak?: boolean;
19
17
  /**
20
18
  * 'odim': 'w' width, 'a'/'b' distance to a wall end, or 'd:<id>' a placed dimension. 'oanchor': 's0'/'s1' edge.
21
- * 'note', a link's tile: 'tip' its leader's tip, 'leader' along its leader (unset: its card).
19
+ * 'note', a link's tile: 'tip' its leader's tip, 'leader' along its leader (unset: its card). 'underlay', an expanded
20
+ * photo: 'link' its badge (a tap opens it).
22
21
  */
23
22
  part?: string;
24
23
  /** a wall picked whole: the side of its centreline the pointer was on (+1 the left of its A → B; a curved one's, outside) */
@@ -50,7 +49,7 @@ export interface Snap {
50
49
  /** Screen-space hit region registered while drawing chips, badges and labels. */
51
50
  export interface HitChip {
52
51
  /** 'elev': one of the wall view's dimension chips (id: what it measures, as JSON) */
53
- kind: 'dim' | 'pdim' | 'cons' | 'room' | 'odim' | 'elev';
52
+ kind: 'dim' | 'cons' | 'room' | 'odim' | 'elev';
54
53
  part?: string;
55
54
  id: string;
56
55
  x: number; y: number; w: number; h: number;
@@ -83,7 +82,6 @@ export const ed = sessionState(() => ({
83
82
  drawConstr: false,
84
83
  /** the rail's Walls toggle: what's drawn (lines, rectangles, circles, arcs, Draw to shape) is walls */
85
84
  drawWalls: false,
86
- dimLayer: 'drive' as 'drive' | 'print',
87
85
  openingType: 'door' as 'door' | 'window',
88
86
  /** new markup's look (the stroke panel sets them; selected markup changes with them) */
89
87
  noteColor: DEFAULT_NOTE_COLOR,
package/src/data/sync.ts CHANGED
@@ -8,6 +8,7 @@ import { newPage } from '../model/demo';
8
8
  import { allCons } from '../model/faces';
9
9
  import { syncFaces } from '../model/facesolve';
10
10
  import { clearPageHistory, imageStored } from '../model/history';
11
+ import { rezoomAcrossScale, settleScale } from '../model/scale';
11
12
  import { defaultLayers, settings } from '../model/settings';
12
13
  import { store, type Persistence } from '../model/store';
13
14
  import type { Constraint, Page, Project } from '../model/types';
@@ -61,7 +62,7 @@ export interface CadSync {
61
62
 
62
63
  /**
63
64
  * A driving dimension's value as BOLDR keeps it: lengths in micrometres, angles in degrees. Null for what isn't one of
64
- * the drawing's own measurements: reference and print dimensions, constraints without a value, and dimensions linked
65
+ * the drawing's own measurements: reference dimensions, constraints without a value, and dimensions linked
65
66
  * to a measurement of the job (the job's, not the drawing's: see editor/links.ts).
66
67
  */
67
68
  export function measurementOf(c: Constraint): CadMeasurementValue | null {
@@ -165,9 +166,12 @@ export function openCadDrawing(provider: CadDataProvider, opts: CadSyncOptions =
165
166
  for (const [id, doc] of Object.entries(pages)) {
166
167
  if (doc.rev?.clientId === clientId) continue; // our own write coming back
167
168
  if (writing.has(id)) continue; // ours is on its way: this snapshot is older than what's here
169
+ settleScale(doc.sk); // (before it's compared: settled, it's what was saved, not an edit here)
168
170
  const json = pageJson(doc), local = project.pages.find(p => p.id === id);
169
171
  if (local) {
170
172
  if (pageJson(local) !== saved.get(id) || json === saved.get(id)) continue; // edited here since, or nothing new
173
+ // (given its scale elsewhere, or taken back: the view here zooms to match, so the drawing stays put on screen)
174
+ rezoomAcrossScale(local, local.sk, doc.sk);
171
175
  local.name = doc.name; local.sk = doc.sk; local.underlays = imagesOf(doc);
172
176
  clearPageHistory(id);
173
177
  if (id === store.curPageId) curChanged = true;
@@ -263,7 +267,8 @@ export function openCadDrawing(provider: CadDataProvider, opts: CadSyncOptions =
263
267
  if (!file.meta && !opts.initialize) { stop(); reject(new NotACadDrawing('legacy')); return; }
264
268
  const order = file.meta?.pageOrder ?? [];
265
269
  const ids = [...order.filter(id => pages![id]), ...Object.keys(pages!).filter(id => !order.includes(id))];
266
- project = { name: file.name, pages: ids.map(id => ({ id, name: pages![id].name, sk: pages![id].sk, underlays: imagesOf(pages![id]), view: null })) };
270
+ // (each page's scale settled before what was saved is noted: model/scale.ts)
271
+ project = { name: file.name, pages: ids.map(id => ({ id, name: pages![id].name, sk: settleScale(pages![id].sk), underlays: imagesOf(pages![id]), view: null })) };
267
272
  for (const id of ids) saved.set(id, pageJson(pages![id]));
268
273
  if (file.meta) savedMeta = JSON.stringify(file.meta);
269
274
  // a new drawing (or one whose pages are all gone) starts with one empty page, saved straight away; its id is
@@ -2,14 +2,14 @@ import { afterEdit, holdFaces, solveSketch, tryAddConstraint, undo } from '../ap
2
2
  import { ed, type OpeningDimPart } from '../app/state';
3
3
  import { CONS_LABEL, measure } from '../model/constraints';
4
4
  import { dropHistory, pushHistory } from '../model/history';
5
- import { createsCycle, edgePoint, facingEdges, nearerEdge } from '../model/openings';
5
+ import { createsCycle, edgePoint, facingEdges, flipDoor, nearerEdge } from '../model/openings';
6
+ import { roomSideOf } from '../model/elevation';
6
7
  import { cabOfPoint } from '../model/faces';
7
8
  import { settings } from '../model/settings';
8
9
  import { connectedGeometry, curveInfo, deleteSel, joinPoints } from '../model/sketch';
9
10
  import { page, planSK, replaceSK, saveSoon, SK } from '../model/store';
10
- import type { Constraint, ConsType, OpeningEdge, Sketch, Underlay, WallFace } from '../model/types';
11
+ import type { Constraint, ConsType, OpeningEdge, Sketch, Underlay } from '../model/types';
11
12
  import { fitView, requestDraw } from '../render/view';
12
- import { convertNote } from '../tools/annotate';
13
13
  import { cornerAngle, dimCurve, dimEdges, dimLine, dimPointLine, dimPoints, dimTwoLines, editDim, faceChoices, onChoice, setDimFace, toggleRef } from '../tools/dim';
14
14
  import { edgeLabel, edgeOf } from '../model/planGeom';
15
15
  import type { LineTool } from '../tools/line';
@@ -20,13 +20,13 @@ import { chamferPrompt } from '../tools/chamfer';
20
20
  import { addOpeningDim, editOpeningDim, toggleOpeningDim } from '../tools/openings';
21
21
  import { setTool, tools } from '../tools/registry';
22
22
  import { fmtLen } from '../units';
23
- import { elevOpen, fitState, OPENING_SIZES, openElevation, openingLabel, overflow, restoreFit, TOO_SHORT, wallAction, wallBar, wallDelete, wallPlus } from './elevation';
23
+ import { elevOpen, fitState, OPENING_SIZES, openElevation, overflow, restoreFit, TOO_SHORT, wallAction, wallBar, wallDelete, wallPlus } from './elevation';
24
24
  import { pushedOut } from '../model/facesolve';
25
- import { editRoomName, openKeypad, openPalette, toast, toggleImageOpacity, toggleImageRotation, toggleTileSize, updateCtx, updateTopBar } from './ports';
25
+ import { dismissPopovers, editRoomName, openKeypad, openPalette, toast, toggleImageOpacity, toggleImageRotation, toggleTileSize, updateCtx, updateTopBar } from './ports';
26
26
  import { cornerLines, edgePair, selInfo } from './selection';
27
27
  import { restackImage } from './project';
28
28
  import { imageById } from '../model/images';
29
- import { canOpenLink, canSaveDim, linkSizeVisible, openDimLink, openSelectedLink, saveDimToGroup, selectedLink, toggleLeader, unlinkDim } from './linkActions';
29
+ import { canExpand, canOpenLink, canSaveDim, linkSizeVisible, openDimLink, openSelectedLink, saveDimToGroup, selectedLink, toggleExpand, toggleLeader, unlinkDim } from './linkActions';
30
30
 
31
31
  /*
32
32
  The contextual bottom bar's decisions: which actions fit the tool and the selection, what the yellow + measures,
@@ -34,7 +34,14 @@ import { canOpenLink, canSaveDim, linkSizeVisible, openDimLink, openSelectedLink
34
34
  barItems() / plusVisible() and calls applyCtxAction() / plusAction() / deleteSelection().
35
35
  */
36
36
 
37
- export interface CtxItem { ic: string; label: string; act: string; on?: boolean; key?: string; locked?: boolean }
37
+ /**
38
+ * A bar button; `lead`: the bar's first, its icon alone (see arrangeLead). `bare`: its icon alone too, wherever it is
39
+ * (Lock). Either way its name is its label for a screen reader.
40
+ */
41
+ export interface CtxItem { ic: string; label: string; act: string; on?: boolean; key?: string; locked?: boolean; lead?: boolean; bare?: boolean }
42
+
43
+ /** A bar button shown as its icon alone, no name under it (the lead, and Lock). */
44
+ export const iconAlone = (it: CtxItem): boolean => !!(it.lead || it.bare);
38
45
 
39
46
  /** Points a Lock on the current selection would pin (the origin is always fixed). */
40
47
  function lockTargets(): string[] {
@@ -45,21 +52,24 @@ function lockTargets(): string[] {
45
52
  }
46
53
 
47
54
  /**
48
- * Lock is always the first action, set off by a divider, and shows its state:
49
- * open padlock "Lock" (white) when free, closed padlock "Locked" (red) when locked.
55
+ * The bar leads with one button, its icon alone, set off by a divider (Figma's bars, 2528:101128: the user's call,
56
+ * 2026-10-27): Open, when what's selected is a link to something of the job's; otherwise Lock — an open padlock (white)
57
+ * when free, a closed one (red, "Locked") when locked. A link that can be locked too (an expanded photo) has Lock next,
58
+ * with a divider of its own. Lock is its icon alone wherever it is, locked or not (the user's call, 2026-10-30: "only
59
+ * display the icon - no text needed below it").
50
60
  */
51
- function arrangeLock(items: CtxEntry[]): CtxEntry[] {
52
- const i = items.findIndex(it => typeof it === 'object' && 'act' in it && (it.act === 'lock' || it.act === 'imgLock'));
53
- if (i < 0) return items;
54
- const it = items[i] as CtxItem;
55
- const sk = SK();
56
- const locked = it.act === 'imgLock' ? selImages().length > 0 && selImages().every(u => u.locked) : (() => { const l = lockTargets(); return l.length > 0 && l.every(id => sk.points[id].fixed); })();
57
- Object.assign(it, { ic: locked ? 'lock' : 'unlock', label: locked ? 'Locked' : 'Lock', on: undefined, locked });
58
- const rest = items.filter((_, k) => k !== i);
59
- const out: CtxEntry[] = [it];
60
- if (rest.length) out.push('sep');
61
- // tidy separators: none doubled, none leading or trailing
62
- for (const e of rest) if (!(e === 'sep' && (out[out.length - 1] === 'sep'))) out.push(e);
61
+ function arrangeLead(items: CtxEntry[]): CtxEntry[] {
62
+ const find = (acts: string[]) => items.find((it): it is CtxItem => typeof it === 'object' && 'act' in it && acts.includes(it.act));
63
+ const open = find(['linkOpen', 'dimLinkOpen']), lock = find(['lock', 'imgLock']);
64
+ if (lock) {
65
+ const sk = SK();
66
+ const locked = lock.act === 'imgLock' ? selImages().length > 0 && selImages().every(u => u.locked) : (() => { const l = lockTargets(); return l.length > 0 && l.every(id => sk.points[id].fixed); })();
67
+ Object.assign(lock, { ic: locked ? 'lock' : 'unlock', label: locked ? 'Locked' : 'Lock', on: undefined, locked, bare: true });
68
+ }
69
+ const lead = open ?? lock, out: CtxEntry[] = [];
70
+ if (lead) { lead.lead = true; out.push(lead, 'sep'); if (open && lock) out.push(lock, 'sep'); }
71
+ // the rest, with no divider doubled, first or last
72
+ for (const e of items) if (e !== lead && !(open && e === lock) && !(e === 'sep' && (!out.length || out[out.length - 1] === 'sep'))) out.push(e);
63
73
  while (out[out.length - 1] === 'sep') out.pop();
64
74
  return out;
65
75
  }
@@ -79,24 +89,22 @@ const PLACING = new Set(['door', 'window', 'wdoor', 'wwindow', 'cabinet']);
79
89
  function ctxItems(): CtxEntry[] {
80
90
  const items: CtxEntry[] = [], sk = SK(), s = selInfo();
81
91
  const add = (ic: string, label: string, act: string, o: Partial<CtxItem> = {}) => items.push(Object.assign({ ic, label, act }, o));
82
- /** To wall (all the selected lines and curves) — or To line + Thickness when they're all walls already. */
83
- const wallItems = () => {
84
- if (elevOpen()) return;
85
- const kinds = [...s.lines.map(id => sk.lines[id].kind), ...s.curves.map(id => curveInfo(sk, id)?.o.kind)];
86
- if (!kinds.length) return;
87
- if (kinds.every(k => k === 'wall')) { add('thick', 'Thickness', 'thick'); add('line', 'To line', 'kind'); }
88
- else add('wall', 'To wall', 'kind');
89
- };
90
92
  if (ed.mode === 'annotate') {
91
93
  const ims = selImages();
92
94
  if (ims.length && ims.length === s.n) {
93
95
  // an image (or several): lock, its own scale, and which goes in front where they overlap
94
96
  // (showing and hiding them all is the Layers menu's)
95
97
  add('lock', 'Lock', 'imgLock');
96
- if (ims.length === 1 && !ims[0].locked) add('calib', 'Set scale', 'calibtool');
98
+ // a photo of the job's, expanded from its tile: opening it, and Expand (on) to fold it back into its tile
99
+ const lk = ims.length === 1 ? ims[0].link : undefined;
100
+ if (lk && canOpenLink(lk)) add('open', 'Open', 'linkOpen');
101
+ // locked: unlocking it, and opening what it's a photo of — nothing that changes it (the user's call,
102
+ // 2026-10-30: "images in locked state: shouldn't have these options - only lock and link should be available")
103
+ if (ims.every(u => u.locked)) return items;
104
+ if (lk) add('expand', 'Expand', 'linkExpand', { on: true });
105
+ if (ims.length === 1) add('calib', 'Set scale', 'calibtool');
97
106
  if (ims.length === 1) add('opacity', 'Opacity', 'imgOpacity');
98
- // (turning moves it: not while it's locked, as it can't be moved or sized then)
99
- if (ims.length === 1 && !ims[0].locked) add('rotate', 'Rotate', 'imgRotate');
107
+ if (ims.length === 1) add('rotate', 'Rotate', 'imgRotate');
100
108
  if (ims.length === 1 && page().underlays.length > 1) {
101
109
  const at = page().underlays.indexOf(ims[0]), last = page().underlays.length - 1;
102
110
  if (at < last) add('tofront', 'To front', 'imgFront');
@@ -108,18 +116,17 @@ function ctxItems(): CtxEntry[] {
108
116
  if (lk) {
109
117
  // a link's tile: opening it, its leader, its size (its name is on the tile, not here: the user's call, 2026-10-21)
110
118
  if (canOpenLink(lk.link)) add('open', 'Open', 'linkOpen');
119
+ // (a photo's: the photo whole, as one of the page's images)
120
+ if (canExpand(lk)) add('expand', 'Expand', 'linkExpand', { on: false });
111
121
  add('leader', 'Leader', 'linkLeader', { on: !!lk.leader });
112
122
  // (its leader's colour, as all markup's, is the rail's colour dot's)
113
123
  // (every tile on the page: a slider above the bar)
114
124
  add('tilesize', 'Size', 'linkSize');
115
125
  return items;
116
126
  }
117
- if (s.notes.length) {
118
- // (no Color: the rail's colour dot styles what's selected)
119
- const n = sk.notes.find(n => n.id === s.notes[0]);
120
- if (n && n.type === 'pen' && s.notes.length === 1) add('convert', 'To lines', 'convert');
121
- if (linkSizeVisible()) add('tilesize', 'Size', 'linkSize');
122
- }
127
+ // markup: nothing of its own — a pen stroke as any other (no To lines: the user's call, 2026-10-27), and no Color
128
+ // (the rail's colour dot styles what's selected); with tiles among it, their size
129
+ if (s.notes.length && linkSizeVisible()) add('tilesize', 'Size', 'linkSize');
123
130
  // nothing selected: no bar (the markup colour is the rail's colour dot, for what's selected too; the image's own
124
131
  // options come with selecting it)
125
132
  return items;
@@ -138,7 +145,6 @@ function ctxItems(): CtxEntry[] {
138
145
  if (n >= 1) add('finish', 'Finish', 'finish');
139
146
  return items;
140
147
  }
141
- if (ed.tool === 'dim') { add('dim', 'Driving', 'dimDrive', { on: ed.dimLayer === 'drive' }); add('ref', 'Print', 'dimPrint', { on: ed.dimLayer === 'print' }); return items; }
142
148
  // nothing selected: the bar stays empty (Fit and Icons live in the top bar; grid snap and auto-constrain in Settings).
143
149
  // Just placed a door, window or cabinet (its tool still on): its bar, for its sizes
144
150
  if (!s.n || (ed.tool !== 'select' && !PLACING.has(ed.tool))) return items;
@@ -149,14 +155,31 @@ function ctxItems(): CtxEntry[] {
149
155
  for (const it of wb.items) add(it.ic, it.label, it.act);
150
156
  return items;
151
157
  }
158
+ /*
159
+ Figma's bars (2528:101128, the user's call, 2026-10-27): Lock first, its icon alone (arrangeLead); then what the lines
160
+ and curves are (kind); then what ties them, or edits them. The yellow + measures: no Distance of its own here.
161
+ */
152
162
  // on a wall face, walls don't exist: no To wall / Thickness
153
163
  const wall = elevOpen();
154
164
  const P = s.points.length, L = s.lines.length, C = s.curves.length;
155
- // Guide (dashed) is for lines: a wall is always solid (one that's a guide from before can still go back to Solid)
156
- const allWalls = L + C > 0 && s.lines.every(id => sk.lines[id].kind === 'wall' && !sk.lines[id].constr) && s.curves.every(id => { const o = curveInfo(sk, id)?.o; return o?.kind === 'wall' && !o.constr; });
157
- const guide = () => { if (!allWalls) add('constr', 'Guide', 'constr'); };
158
- // a wall's edge: named, with its wall view; with another edge, a line or a point, the distance between them (two
159
- // edges of one wall: its thickness)
165
+ /**
166
+ * What the selected lines and curves are, and its divider: walls — one straight wall's wall view, and their Thickness;
167
+ * otherwise To wall, and Guide (Solid, when they all are guides). A wall is always solid; on a wall face, Guide alone.
168
+ */
169
+ const kind = () => {
170
+ const objs = [...s.lines.map(id => sk.lines[id]), ...s.curves.map(id => curveInfo(sk, id)?.o)].filter((o): o is NonNullable<typeof o> => !!o);
171
+ if (!objs.length) return;
172
+ if (objs.every(o => o.kind === 'wall' && !o.constr)) {
173
+ if (wall) return;
174
+ if (s.n === 1 && L === 1) add('elev', 'Elevation', 'elev');
175
+ add('thick', 'Thickness', 'thick');
176
+ } else {
177
+ if (!wall) add('wall', 'To wall', 'kind');
178
+ add('constr', objs.every(o => o.constr) ? 'Solid' : 'Guide', 'constr');
179
+ }
180
+ items.push('sep');
181
+ };
182
+ // a wall's edge: named, with its wall view (with another edge, a line or a point, the + is the distance between them)
160
183
  if (s.wfaces.length) {
161
184
  // (a curved wall has no wall view: just its name)
162
185
  if (s.n === 1) {
@@ -164,24 +187,18 @@ function ctxItems(): CtxEntry[] {
164
187
  items.push({ lbl: edgeLabel(sk, id, side) });
165
188
  if (sk.lines[id]) add('elev', 'Elevation', 'elev');
166
189
  }
167
- else if (edgePair(s)) {
168
- const one = s.wfaces.length === 2 && edgeOf(s.wfaces[0])[0] === edgeOf(s.wfaces[1])[0];
169
- add(one ? 'thick' : 'dim', one ? 'Thickness' : 'Distance', 'edgeDist');
170
- }
171
190
  return items;
172
191
  }
173
192
  if (s.n === 1 && s.dims.length) {
174
193
  const c = sk.cons[s.dims[0]];
175
194
  // linked to a measurement of the job: opening it, letting go of it — no text saying what it's linked to (the user's
176
- // call, 2026-10-21; its chip is drawn linked), and no Reference (a measurement drives its line, or it isn't linked)
195
+ // call, 2026-10-21; its chip is drawn linked)
177
196
  if (c.measurementRef) {
178
197
  const t = c.measurementRef;
179
198
  if (canOpenLink(t)) add('open', 'Open', 'dimLinkOpen');
180
199
  add('unlink', 'Unlink', 'dimUnlink');
181
200
  } else if (canSaveDim(s.dims[0])) add('savegroup', 'Save to group', 'dimSave');
182
- // Reference frees what a dimension holds — a distance, say; not a cabinet's own width or height, which it keeps
183
- const cabSize = Object.values((sk as WallFace).cabs ?? {}).some(cb => cb.w === s.dims[0] || cb.h === s.dims[0]);
184
- if (c.layer !== 'print' && !cabSize && !c.measurementRef) add('ref', c.driving === false ? 'Drive' : 'Reference', 'toggleref');
201
+ // (no Reference: Figma's dimension bars have none, 2026-10-27)
185
202
  // measured on walls: which of their faces (the drawing stays as it is; the number becomes what it measures there)
186
203
  const fc = faceChoices(sk, c);
187
204
  if (fc.length && items.length) items.push('sep');
@@ -196,70 +213,55 @@ function ctxItems(): CtxEntry[] {
196
213
  // a constraint badge: nothing to offer but delete (the trash), so the bar stays empty
197
214
  if (s.n === 1 && s.cons.length) return items;
198
215
  if (s.n === 1 && s.rooms.length) { add('rename', 'Rename', 'renameRoom'); return items; }
199
- // one end of a door or window: named; with another point, end or door/window, the distance between them
216
+ // one end of a door or window: named (with another point, end or door/window, the + is the distance between them)
200
217
  if (s.oanchors.length && s.n === 1) { items.push({ lbl: anchorLabel(s.oanchors[0]) }); return items; }
201
- if (s.oanchors.length && s.n === 2 && s.oanchors.length + s.points.length + s.openings.length === 2) { add('dim', 'Distance', 'anchorDist'); return items; }
202
- // two doors/windows, or one and a point: the distance between them (locks the second one picked)
203
- if (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1))) { add('dim', 'Distance', 'opDist'); return items; }
218
+ if (s.oanchors.length || (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1)))) return items;
204
219
  if (s.n === 1 && s.openings.length) {
205
- // its sizes by name, as in the wall view (no +: it isn't one dimension); a door's swing too. (The type is fixed
206
- // once placed: Door and Window are separate tools.)
207
- const o = sk.openings[s.openings[0]];
208
- items.push({ lbl: openingLabel(o) });
220
+ // its sizes (no +: it isn't one dimension), with nothing saying them — the drawing does (the user's call, 2026-10-27);
221
+ // a door's Swing too, round its four ways (it was Flip: the user's call, 2026-10-27, "ok do swing instead"). (The
222
+ // type is fixed once placed: Door and Window are separate tools.)
209
223
  for (const it of OPENING_SIZES) add(it.ic, it.label, it.act);
210
- if (o.type === 'door') { items.push('sep'); add('flip', 'Flip', 'opFlip'); }
224
+ if (sk.openings[s.openings[0]].type === 'door') { items.push('sep'); add('flip', 'Swing', 'opFlip'); }
211
225
  return items;
212
226
  }
227
+ // a cabinet's corner: nothing of its own (pick another point or line with it to dimension or constrain)
228
+ if (P === 1 && s.n === 1 && cabOfPoint(sk, s.points[0])) return items;
229
+ add('lock', 'Lock', 'lock', { key: '⇧F' });
213
230
  if (L === 1 && s.n === 1) {
214
- const l = sk.lines[s.lines[0]];
215
- add('h', 'Horizontal', 'h', { key: '⇧H' }); add('v', 'Vertical', 'v', { key: '⇧V' }); // (its length: the yellow +)
216
- items.push('sep');
217
- add('lock', 'Lock', 'lock', { key: '⇧F' }); add('split', 'Split', 'split'); add('offset', 'Offset', 'offset'); if (l.constr) add('constr', 'Solid', 'constr'); else guide();
218
- if (!wall) {
219
- if (l.kind === 'wall') { add('thick', 'Thickness', 'thick'); add('line', 'To line', 'kind'); } else add('wall', 'To wall', 'kind');
220
- if (l.kind === 'wall' && !l.constr) { items.push('sep'); add('elev', 'Elevation', 'elev'); }
221
- }
231
+ kind();
232
+ // (its length: the yellow +)
233
+ add('h', 'Horizontal', 'h', { key: '⇧H' }); add('v', 'Vertical', 'v', { key: '⇧V' }); add('split', 'Split', 'split'); add('offset', 'Offset', 'offset');
222
234
  return items;
223
235
  }
224
236
  if (L === 2 && s.n === 2) {
225
- // (no Angle here: the yellow + is the angle between them — or, parallel, the distance)
237
+ // (no Angle: the yellow + is the angle between them — or, parallel, the distance)
238
+ kind();
226
239
  add('par', 'Parallel', 'par', { key: '⇧P' }); add('perp', 'Perpendicular', 'perp', { key: '⇧R' }); add('col', 'Collinear', 'col', { key: '⇧L' }); add('eq', 'Equal', 'eq', { key: '⇧E' });
227
240
  // two lines that meet: round off (or cut off) the corner between them
228
- if (filletCorner2()) { add('fillet', 'Fillet', 'fillet'); add('chamfer', 'Chamfer', 'chamfer'); }
229
- wallItems();
241
+ if (filletCorner2()) { items.push('sep'); add('fillet', 'Fillet', 'fillet'); add('chamfer', 'Chamfer', 'chamfer'); }
230
242
  return items;
231
243
  }
232
- // a cabinet's corner: nothing of its own (pick another point or line with it to dimension or constrain)
233
- if (P === 1 && s.n === 1 && cabOfPoint(sk, s.points[0])) return items;
244
+ // a corner where exactly two lines meet: rounding it off or cutting it off (its angle is the yellow +)
234
245
  if (P === 1 && s.n === 1) {
235
- // a corner where exactly two lines meet: rounding it off or cutting it off (its angle is the yellow +)
236
246
  if (filletable(sk, s.points[0])) { add('fillet', 'Fillet', 'fillet'); add('chamfer', 'Chamfer', 'chamfer'); }
237
- add('lock', 'Lock', 'lock', { key: '⇧F' });
238
247
  return items;
239
248
  }
240
- if (P === 2 && s.n === 2) { add('h', 'Horizontal', 'h'); add('v', 'Vertical', 'v'); add('dim', 'Distance', 'pdist'); add('merge', 'Join', 'merge'); return items; }
241
- if (P === 1 && L === 1 && s.n === 2) { add('mid', 'Midpoint', 'mid', { key: '⇧M' }); add('online', 'On line', 'online', { key: '⇧O' }); add('dim', 'Distance', 'pldist'); return items; }
249
+ // points: joined into the first picked, or level or plumb with it
250
+ if (P >= 2 && s.n === P) { add('merge', 'Join', 'merge'); add('h', 'Horizontal', 'h'); add('v', 'Vertical', 'v'); return items; }
251
+ if (P === 1 && L === 1 && s.n === 2) { add('online', 'On line', 'online', { key: '⇧O' }); add('mid', 'Midpoint', 'mid', { key: '⇧M' }); return items; }
242
252
  if (P === 1 && C === 1 && s.n === 2) { add('oncircle', 'On circle', 'oncircle'); return items; }
243
- if (L === 1 && C === 1 && s.n === 2) { add('tan', 'Tangent', 'tan', { key: '⇧T' }); wallItems(); return items; }
253
+ if (L === 1 && C === 1 && s.n === 2) { kind(); add('tan', 'Tangent', 'tan', { key: '⇧T' }); return items; }
244
254
  // a circle or arc: the yellow + sets its diameter
245
- if (C === 1 && s.n === 1) {
246
- const ci = curveInfo(sk, s.curves[0]);
247
- guide();
248
- if (wall) return items;
249
- if (ci && ci.o.kind === 'wall') { add('thick', 'Thickness', 'thick'); add('arc', 'To line', 'kind'); } else add('wall', 'To wall', 'kind');
250
- return items;
251
- }
252
- if (C === 2 && s.n === 2) { add('eq', 'Equal', 'eqr'); add('conc', 'Concentric', 'conc'); wallItems(); return items; }
255
+ if (C === 1 && s.n === 1) { kind(); return items; }
256
+ if (C === 2 && s.n === 2) { kind(); add('conc', 'Concentric', 'conc'); add('eq', 'Equal', 'eqr'); return items; }
253
257
  // 3+ lines (only lines): the constraints that make sense across many, each tied to the first line picked
254
258
  if (L >= 3 && s.n === L) {
259
+ kind();
255
260
  add('h', 'Horizontal', 'h', { key: '⇧H' }); add('v', 'Vertical', 'v', { key: '⇧V' });
256
261
  add('par', 'Parallel', 'par', { key: '⇧P' }); add('col', 'Collinear', 'col', { key: '⇧L' }); add('eq', 'Equal', 'eq', { key: '⇧E' });
257
- items.push('sep');
258
- add('lock', 'Lock', 'lock'); guide(); wallItems();
259
262
  return items;
260
263
  }
261
- if (L >= 2) { add('eq', 'Equal', 'eqall'); add('lock', 'Lock', 'lock'); guide(); wallItems(); return items; }
262
- add('lock', 'Lock', 'lock');
264
+ if (L >= 2) { kind(); add('eq', 'Equal', 'eqall'); }
263
265
  return items;
264
266
  }
265
267
 
@@ -296,7 +298,7 @@ function filletCorner2(): string | null {
296
298
  }
297
299
 
298
300
  /** Buttons for the bottom bar: they adapt to the tool and the selection (Lock first, when offered). */
299
- export const barItems = (): CtxEntry[] => arrangeLock(ctxItems());
301
+ export const barItems = (): CtxEntry[] => arrangeLead(ctxItems());
300
302
 
301
303
  /** The yellow + only appears for something it can measure: not for constraint badges or spaces alone. */
302
304
  const DRAW_TOOLS = new Set(['line', 'freehand', 'rect', 'circle', 'arc']);
@@ -324,13 +326,13 @@ export function plusAction(): void {
324
326
  if (ed.mode === 'annotate') return;
325
327
  if (wallPlus()) return;
326
328
  const s = selInfo(), sk = SK();
327
- if (s.dims.length === 1 && s.n === 1) { if (sk.cons[s.dims[0]]?.layer === 'print') toast('Print dimensions are labels only — drag to move them.', { warn: true }); else editDim(s.dims[0]); }
329
+ if (s.dims.length === 1 && s.n === 1) editDim(s.dims[0]);
328
330
  else if (s.odims.length === 1 && s.n === 1) { const [op, part] = s.odims[0].split('|'); editOpeningDim(op, part as OpeningDimPart); }
329
331
  // a wall's edge: its length on that face; with another edge, a line or a point, the distance from it
330
332
  // (a curved wall's: its diameter across that face)
331
- else if (s.wfaces.length === 1 && s.n === 1) { const [id, side] = edgeOf(s.wfaces[0]); if (sk.lines[id]) dimLine(id, false, side); else dimCurve(id, side); }
333
+ else if (s.wfaces.length === 1 && s.n === 1) { const [id, side] = edgeOf(s.wfaces[0]); if (sk.lines[id]) dimLine(id, side); else dimCurve(id, side); }
332
334
  else if (edgePair(s)) applyCtxAction('edgeDist');
333
- else if (s.lines.length === 1 && s.n === 1) dimLine(s.lines[0], false);
335
+ else if (s.lines.length === 1 && s.n === 1) dimLine(s.lines[0]);
334
336
  else if (s.curves.length === 1 && s.n === 1) dimCurve(s.curves[0]);
335
337
  else if (s.openings.length === 1 && s.n === 1) editOpeningDim(s.openings[0], 'w');
336
338
  // a corner where two lines meet: its angle
@@ -339,7 +341,7 @@ export function plusAction(): void {
339
341
  else if (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1))) applyCtxAction('opDist');
340
342
  // two things selected: the dimension between them
341
343
  else if (s.n === 2 && s.lines.length === 2) dimTwoLines(s.lines[0], s.lines[1]);
342
- else if (s.n === 2 && s.points.length === 2) dimPoints(s.points[0], s.points[1], false);
344
+ else if (s.n === 2 && s.points.length === 2) dimPoints(s.points[0], s.points[1]);
343
345
  else if (s.n === 2 && s.points.length === 1 && s.lines.length === 1) dimPointLine(s.points[0], s.lines[0]);
344
346
  else setTool('dim');
345
347
  }
@@ -395,9 +397,13 @@ const NEEDS: Partial<Record<ConsType, string>> = {
395
397
  online: 'a point and a line', oncircle: 'a point and a circle', tan: 'a line and a circle or arc', eqr: 'two circles', conc: 'two circles',
396
398
  };
397
399
 
398
- /** H/V on each selected line, or parallel/collinear/equal from the first selected line to each of the others. */
400
+ /**
401
+ * H/V on each selected line, or parallel/collinear/equal from the first selected line to each of the others; three
402
+ * or more points, each level or plumb with the first picked.
403
+ */
399
404
  function multiLineCons(type: ConsType): Constraint[] | null {
400
405
  const sk = SK(), s = selInfo();
406
+ if ((type === 'h' || type === 'v') && s.n === s.points.length && s.points.length >= 3) return s.points.slice(1).map(id => ({ type, a: s.points[0], b: id }));
401
407
  if (s.n !== s.lines.length || s.lines.length < (type === 'h' || type === 'v' ? 2 : 3)) return null;
402
408
  if (type === 'h' || type === 'v') return s.lines.map(id => ({ type, a: sk.lines[id].a, b: sk.lines[id].b }));
403
409
  if (type === 'par' || type === 'col' || type === 'eq') return s.lines.slice(1).map(id => ({ type, l1: s.lines[0], l2: id }));
@@ -406,8 +412,11 @@ function multiLineCons(type: ConsType): Constraint[] | null {
406
412
 
407
413
  const ADJ: Partial<Record<ConsType, string>> = { h: 'horizontal', v: 'vertical', par: 'parallel', col: 'collinear', eq: 'equal' };
408
414
 
409
- /** Apply one constraint type across several lines as one undo step. A conflict opens the conflict sheet for the first one that fails. */
410
- function applyMulti(type: ConsType, list: Constraint[]): void {
415
+ /**
416
+ * Apply one constraint type across several lines (or `points`, each tied to the first) as one undo step. A conflict
417
+ * opens the conflict sheet for the first one that fails.
418
+ */
419
+ function applyMulti(type: ConsType, list: Constraint[], points = false): void {
411
420
  pushHistory();
412
421
  let added = 0;
413
422
  const failed: Constraint[] = [];
@@ -418,9 +427,9 @@ function applyMulti(type: ConsType, list: Constraint[]): void {
418
427
  }
419
428
  if (!added && !failed.length) { dropHistory(); toast('They’re already ' + ADJ[type] + '.', { warn: true }); afterEdit(); return; }
420
429
  if (!added) dropHistory();
421
- // every selected line that now satisfies it (some may already have, through other constraints)
422
- const lines = (type === 'h' || type === 'v' ? list.length : list.length + 1) - failed.length;
423
- const nLines = lines + ' line' + (lines === 1 ? '' : 's');
430
+ // every selected line (or point) that now satisfies it (some may already have, through other constraints)
431
+ const lines = ((type === 'h' || type === 'v') && !points ? list.length : list.length + 1) - failed.length;
432
+ const nLines = lines + (points ? ' point' : ' line') + (lines === 1 ? '' : 's');
424
433
  if (failed.length) {
425
434
  // explain the first conflict; the sheet's Remove re-applies it
426
435
  tryAddConstraint(failed[0], { onResolved: () => afterEdit() });
@@ -430,8 +439,8 @@ function applyMulti(type: ConsType, list: Constraint[]): void {
430
439
  }
431
440
 
432
441
  function applyCons(type: ConsType): void {
433
- const multi = multiLineCons(type);
434
- if (multi && (multi.length > 1 || selInfo().lines.length > 2)) { applyMulti(type, multi); return; }
442
+ const multi = multiLineCons(type), s = selInfo(), points = s.points.length > 2 && s.n === s.points.length;
443
+ if (multi && (multi.length > 1 || s.lines.length > 2 || points)) { applyMulti(type, multi, points); return; }
435
444
  const c = consFromSel(type);
436
445
  if (!c) { toast('Select ' + (NEEDS[type] || 'geometry') + ' first.', { warn: true }); return; }
437
446
  pushHistory();
@@ -468,15 +477,13 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
468
477
  // (the Line tool's, or the templator's: both close and finish a run of points)
469
478
  case 'closeShape': (tools[ed.tool] as LineTool | SurveyTool).closeShape(); updateCtx(); return;
470
479
  case 'finish': (tools[ed.tool] as LineTool | SurveyTool).finish(); updateCtx(); return;
471
- case 'dimDrive': ed.dimLayer = 'drive'; break;
472
- case 'dimPrint': ed.dimLayer = 'print'; break;
473
480
  case 'h': case 'v': case 'par': case 'perp': case 'col': case 'eq': case 'mid': case 'online': case 'oncircle': case 'tan': case 'eqr': case 'conc':
474
481
  applyCons(act); return;
475
482
  case 'len':
476
- if (s.lines.length === 1) dimLine(s.lines[0], false);
477
- else if (s.wfaces.length === 1 && sk.lines[edgeOf(s.wfaces[0])[0]]) { const [lid, side] = edgeOf(s.wfaces[0]); dimLine(lid, false, side); }
483
+ if (s.lines.length === 1) dimLine(s.lines[0]);
484
+ else if (s.wfaces.length === 1 && sk.lines[edgeOf(s.wfaces[0])[0]]) { const [lid, side] = edgeOf(s.wfaces[0]); dimLine(lid, side); }
478
485
  return;
479
- case 'pdist': if (s.points.length === 2) dimPoints(s.points[0], s.points[1], false); return;
486
+ case 'pdist': if (s.points.length === 2) dimPoints(s.points[0], s.points[1]); return;
480
487
  case 'pldist': if (s.points.length === 1 && s.lines.length === 1) dimPointLine(s.points[0], s.lines[0]); return;
481
488
  case 'diam': if (s.curves.length === 1) dimCurve(s.curves[0]); return;
482
489
  case 'fillet': case 'chamfer': {
@@ -486,9 +493,13 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
486
493
  }
487
494
  case 'cornerAngle': cornerAngle(s.points[0]); return;
488
495
  case 'merge': {
496
+ // (every one into the first picked)
497
+ if (s.points.length < 2) return;
489
498
  pushHistory();
490
499
  const snap = JSON.stringify(sk);
491
- if (joinPoints(sk, s.points[0], s.points[1])) {
500
+ if (!s.points.slice(1).every(id => joinPoints(sk, s.points[0], id))) {
501
+ replaceSK(JSON.parse(snap) as Sketch); dropHistory(); toast('Two locked points can’t be joined. Unlock one first.', { warn: true });
502
+ } else {
492
503
  const r = solveSketch();
493
504
  if (!r.ok) { replaceSK(JSON.parse(snap) as Sketch); dropHistory(); toast('Can’t join those points — it conflicts with other constraints.', { warn: true }); }
494
505
  else { ed.sel = [{ type: 'point', id: s.points[0] }]; toast('Points joined'); }
@@ -564,7 +575,8 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
564
575
  case 'toggleref': toggleRef(s.dims[0]); return;
565
576
  case 'odimRef': { const [op, part] = s.odims[0].split('|'); toggleOpeningDim(op, part.slice(2)); return; }
566
577
  case 'renameRoom': editRoomName(s.rooms[0]); return;
567
- case 'opFlip': { pushHistory(); const o = sk.openings[s.openings[0]]; o.flip = !o.flip; afterEdit(); return; }
578
+ // (the room is on the left of the wall's start → end when there's none either side of it)
579
+ case 'opFlip': { pushHistory(); const o = sk.openings[s.openings[0]]; flipDoor(o, roomSideOf(sk, o.line) === -1 ? -1 : 1); afterEdit(); return; }
568
580
  case 'opDist': {
569
581
  const ops = ed.sel.filter(x => x.type === 'opening').map(x => x.id);
570
582
  if (ops.length === 2) {
@@ -601,8 +613,8 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
601
613
  return;
602
614
  }
603
615
  case 'palette': openPalette(anchor); return;
604
- case 'convert': convertNote(s.notes[0]); return;
605
616
  case 'linkOpen': openSelectedLink(); return;
617
+ case 'linkExpand': toggleExpand(); return;
606
618
  case 'linkSize': toggleTileSize(anchor); return;
607
619
  case 'dimLinkOpen': if (s.dims.length === 1) openDimLink(s.dims[0]); return;
608
620
  case 'dimUnlink': if (s.dims.length === 1) unlinkDim(s.dims[0]); return;
@@ -619,6 +631,8 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
619
631
  const lock = !ims.every(u => u.locked), many = ims.length > 1;
620
632
  pushHistory();
621
633
  ims.forEach(u => { u.locked = lock; });
634
+ // (locked, its bar is Lock and Open alone: an opacity or turning slider open over it goes with the rest)
635
+ if (lock) dismissPopovers();
622
636
  afterEdit();
623
637
  toast(lock ? (many ? 'Images locked. They won’t move while you trace over them.' : 'Image locked. It won’t move while you trace over it.')
624
638
  : many ? 'Images unlocked.' : 'Image unlocked. Drag it, or drag its handles to size it.');