@reekon-tools/boldr-cad 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. package/README.md +13 -3
  2. package/dist/app/edit.d.ts +3 -3
  3. package/dist/app/edit.js +26 -1
  4. package/dist/app/edit.js.map +1 -1
  5. package/dist/app/state.d.ts +6 -8
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +255 -181
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +4 -3
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/laser.d.ts +4 -2
  23. package/dist/editor/laser.js +4 -2
  24. package/dist/editor/laser.js.map +1 -1
  25. package/dist/editor/linkActions.d.ts +49 -12
  26. package/dist/editor/linkActions.js +164 -35
  27. package/dist/editor/linkActions.js.map +1 -1
  28. package/dist/editor/links.d.ts +13 -0
  29. package/dist/editor/links.js +7 -1
  30. package/dist/editor/links.js.map +1 -1
  31. package/dist/editor/mode.d.ts +2 -1
  32. package/dist/editor/mode.js +8 -5
  33. package/dist/editor/mode.js.map +1 -1
  34. package/dist/editor/project.js +1 -2
  35. package/dist/editor/project.js.map +1 -1
  36. package/dist/editor/scaleGate.d.ts +13 -0
  37. package/dist/editor/scaleGate.js +27 -0
  38. package/dist/editor/scaleGate.js.map +1 -0
  39. package/dist/editor/selection.js +13 -9
  40. package/dist/editor/selection.js.map +1 -1
  41. package/dist/editor/tooltips.js +10 -0
  42. package/dist/editor/tooltips.js.map +1 -1
  43. package/dist/editor/view3d.d.ts +4 -1
  44. package/dist/editor/view3d.js +6 -2
  45. package/dist/editor/view3d.js.map +1 -1
  46. package/dist/export/pdf.d.ts +5 -2
  47. package/dist/export/pdf.js +12 -8
  48. package/dist/export/pdf.js.map +1 -1
  49. package/dist/export/plan.d.ts +1 -1
  50. package/dist/export/plan.js +14 -9
  51. package/dist/export/plan.js.map +1 -1
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.js +1 -0
  54. package/dist/index.js.map +1 -1
  55. package/dist/model/constraints.js +2 -0
  56. package/dist/model/constraints.js.map +1 -1
  57. package/dist/model/demo.d.ts +1 -0
  58. package/dist/model/demo.js +2 -1
  59. package/dist/model/demo.js.map +1 -1
  60. package/dist/model/elevation.d.ts +1 -1
  61. package/dist/model/elevation.js +3 -3
  62. package/dist/model/elevation.js.map +1 -1
  63. package/dist/model/geom.d.ts +2 -0
  64. package/dist/model/geom.js +11 -0
  65. package/dist/model/geom.js.map +1 -1
  66. package/dist/model/history.js +4 -1
  67. package/dist/model/history.js.map +1 -1
  68. package/dist/model/links.d.ts +10 -2
  69. package/dist/model/links.js +15 -3
  70. package/dist/model/links.js.map +1 -1
  71. package/dist/model/measurement.d.ts +2 -2
  72. package/dist/model/measurement.js +3 -3
  73. package/dist/model/measurement.js.map +1 -1
  74. package/dist/model/openings.d.ts +10 -0
  75. package/dist/model/openings.js +18 -0
  76. package/dist/model/openings.js.map +1 -1
  77. package/dist/model/planGeom.d.ts +37 -2
  78. package/dist/model/planGeom.js +72 -9
  79. package/dist/model/planGeom.js.map +1 -1
  80. package/dist/model/project.js +5 -2
  81. package/dist/model/project.js.map +1 -1
  82. package/dist/model/rooms.d.ts +18 -5
  83. package/dist/model/rooms.js +178 -73
  84. package/dist/model/rooms.js.map +1 -1
  85. package/dist/model/scale.d.ts +30 -0
  86. package/dist/model/scale.js +123 -0
  87. package/dist/model/scale.js.map +1 -0
  88. package/dist/model/settings.d.ts +0 -1
  89. package/dist/model/settings.js +1 -1
  90. package/dist/model/settings.js.map +1 -1
  91. package/dist/model/sketch.d.ts +26 -2
  92. package/dist/model/sketch.js +77 -22
  93. package/dist/model/sketch.js.map +1 -1
  94. package/dist/model/stroke.js +2 -2
  95. package/dist/model/trim.d.ts +54 -0
  96. package/dist/model/trim.js +219 -0
  97. package/dist/model/trim.js.map +1 -0
  98. package/dist/model/types.d.ts +62 -9
  99. package/dist/react/BottomBar.d.ts +2 -1
  100. package/dist/react/BottomBar.js +144 -46
  101. package/dist/react/BottomBar.js.map +1 -1
  102. package/dist/react/CadEditor.js +18 -9
  103. package/dist/react/CadEditor.js.map +1 -1
  104. package/dist/react/Drawer.js +6 -4
  105. package/dist/react/Drawer.js.map +1 -1
  106. package/dist/react/Duck.js.map +1 -1
  107. package/dist/react/KeypadSheet.js.map +1 -1
  108. package/dist/react/MarkupPanel.js +1 -1
  109. package/dist/react/PlanCanvas.native.js +54 -7
  110. package/dist/react/PlanCanvas.native.js.map +1 -1
  111. package/dist/react/PlanCanvas.web.js +17 -5
  112. package/dist/react/PlanCanvas.web.js.map +1 -1
  113. package/dist/react/Popovers.js +14 -3
  114. package/dist/react/Popovers.js.map +1 -1
  115. package/dist/react/Rail.js +1 -1
  116. package/dist/react/ShapeMenu.js +3 -2
  117. package/dist/react/ShapeMenu.js.map +1 -1
  118. package/dist/react/Sheet.d.ts +2 -2
  119. package/dist/react/Sheet.js.map +1 -1
  120. package/dist/react/ToolTip.js +3 -2
  121. package/dist/react/ToolTip.js.map +1 -1
  122. package/dist/react/TopBar.js +4 -3
  123. package/dist/react/TopBar.js.map +1 -1
  124. package/dist/react/barScroll.d.ts +23 -0
  125. package/dist/react/barScroll.js +47 -0
  126. package/dist/react/barScroll.js.map +1 -0
  127. package/dist/react/corners.d.ts +6 -2
  128. package/dist/react/corners.js.map +1 -1
  129. package/dist/react/editorBox.d.ts +8 -0
  130. package/dist/react/editorBox.js +10 -0
  131. package/dist/react/editorBox.js.map +1 -0
  132. package/dist/react/host.d.ts +1 -1
  133. package/dist/react/host.js +1 -1
  134. package/dist/react/host.js.map +1 -1
  135. package/dist/react/motion.d.ts +1 -1
  136. package/dist/react/motion.js +1 -1
  137. package/dist/react/motion.js.map +1 -1
  138. package/dist/react/pageLock.native.d.ts +2 -0
  139. package/dist/react/pageLock.native.js +2 -0
  140. package/dist/react/pageLock.native.js.map +1 -1
  141. package/dist/react/pageLock.web.d.ts +6 -0
  142. package/dist/react/pageLock.web.js +12 -3
  143. package/dist/react/pageLock.web.js.map +1 -1
  144. package/dist/react/ports.js +8 -3
  145. package/dist/react/ports.js.map +1 -1
  146. package/dist/react/saving.d.ts +7 -1
  147. package/dist/react/saving.js +10 -2
  148. package/dist/react/saving.js.map +1 -1
  149. package/dist/react/store.d.ts +5 -2
  150. package/dist/react/store.js +4 -1
  151. package/dist/react/store.js.map +1 -1
  152. package/dist/render/draw.d.ts +21 -4
  153. package/dist/render/draw.js +203 -151
  154. package/dist/render/draw.js.map +1 -1
  155. package/dist/render/elevation.js +3 -3
  156. package/dist/render/elevation.js.map +1 -1
  157. package/dist/render/figmaIcons.d.ts +6 -0
  158. package/dist/render/figmaIcons.js +16 -1
  159. package/dist/render/figmaIcons.js.map +1 -1
  160. package/dist/render/hit.d.ts +8 -1
  161. package/dist/render/hit.js +23 -4
  162. package/dist/render/hit.js.map +1 -1
  163. package/dist/render/icons.js +27 -20
  164. package/dist/render/icons.js.map +1 -1
  165. package/dist/render/lens.d.ts +27 -0
  166. package/dist/render/lens.js +41 -0
  167. package/dist/render/lens.js.map +1 -0
  168. package/dist/render/linkTile.d.ts +20 -2
  169. package/dist/render/linkTile.js +476 -246
  170. package/dist/render/linkTile.js.map +1 -1
  171. package/dist/render/snap.d.ts +10 -1
  172. package/dist/render/snap.js +87 -15
  173. package/dist/render/snap.js.map +1 -1
  174. package/dist/render/svgPath.d.ts +29 -0
  175. package/dist/render/svgPath.js +114 -0
  176. package/dist/render/svgPath.js.map +1 -0
  177. package/dist/render/tileIcons.d.ts +18 -0
  178. package/dist/render/tileIcons.js +67 -0
  179. package/dist/render/tileIcons.js.map +1 -0
  180. package/dist/render/view.d.ts +18 -1
  181. package/dist/render/view.js +38 -6
  182. package/dist/render/view.js.map +1 -1
  183. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  184. package/dist/skia/SkiaDrawCtx.js +27 -5
  185. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  186. package/dist/solver/equations.d.ts +6 -0
  187. package/dist/solver/equations.js +53 -22
  188. package/dist/solver/equations.js.map +1 -1
  189. package/dist/solver/gauss-newton.js +6 -1
  190. package/dist/solver/gauss-newton.js.map +1 -1
  191. package/dist/tools/annotate.d.ts +0 -2
  192. package/dist/tools/annotate.js +1 -22
  193. package/dist/tools/annotate.js.map +1 -1
  194. package/dist/tools/chamfer.js +4 -0
  195. package/dist/tools/chamfer.js.map +1 -1
  196. package/dist/tools/dim.d.ts +3 -3
  197. package/dist/tools/dim.js +26 -65
  198. package/dist/tools/dim.js.map +1 -1
  199. package/dist/tools/fillet.js +4 -0
  200. package/dist/tools/fillet.js.map +1 -1
  201. package/dist/tools/freehand.js +1 -4
  202. package/dist/tools/freehand.js.map +1 -1
  203. package/dist/tools/line.js +15 -11
  204. package/dist/tools/line.js.map +1 -1
  205. package/dist/tools/modify.d.ts +32 -4
  206. package/dist/tools/modify.js +253 -98
  207. package/dist/tools/modify.js.map +1 -1
  208. package/dist/tools/registry.d.ts +7 -0
  209. package/dist/tools/registry.js +14 -1
  210. package/dist/tools/registry.js.map +1 -1
  211. package/dist/tools/select.d.ts +30 -8
  212. package/dist/tools/select.js +165 -34
  213. package/dist/tools/select.js.map +1 -1
  214. package/dist/tools/shapes.d.ts +1 -1
  215. package/dist/tools/shapes.js +12 -5
  216. package/dist/tools/shapes.js.map +1 -1
  217. package/dist/view3d/build.js +9 -8
  218. package/dist/view3d/build.js.map +1 -1
  219. package/package.json +9 -9
  220. package/src/app/edit.ts +25 -1
  221. package/src/app/state.ts +6 -8
  222. package/src/data/sync.ts +7 -2
  223. package/src/editor/actions.ts +213 -122
  224. package/src/editor/elevation.ts +13 -16
  225. package/src/editor/gestures.ts +83 -13
  226. package/src/editor/keys.ts +3 -2
  227. package/src/editor/laser.ts +7 -4
  228. package/src/editor/linkActions.ts +169 -31
  229. package/src/editor/links.ts +17 -1
  230. package/src/editor/mode.ts +8 -5
  231. package/src/editor/project.ts +1 -2
  232. package/src/editor/scaleGate.ts +32 -0
  233. package/src/editor/selection.ts +13 -7
  234. package/src/editor/tooltips.ts +9 -0
  235. package/src/editor/view3d.ts +6 -2
  236. package/src/export/pdf.ts +7 -4
  237. package/src/export/plan.ts +14 -10
  238. package/src/index.ts +1 -0
  239. package/src/model/constraints.ts +1 -0
  240. package/src/model/demo.ts +2 -1
  241. package/src/model/elevation.ts +4 -4
  242. package/src/model/geom.ts +10 -0
  243. package/src/model/history.ts +4 -1
  244. package/src/model/links.ts +14 -3
  245. package/src/model/measurement.ts +3 -3
  246. package/src/model/openings.ts +18 -0
  247. package/src/model/planGeom.ts +70 -10
  248. package/src/model/project.ts +6 -2
  249. package/src/model/rooms.ts +151 -63
  250. package/src/model/scale.ts +107 -0
  251. package/src/model/settings.ts +1 -2
  252. package/src/model/sketch.ts +62 -11
  253. package/src/model/stroke.ts +2 -2
  254. package/src/model/trim.ts +188 -0
  255. package/src/model/types.ts +57 -9
  256. package/src/react/BottomBar.tsx +177 -52
  257. package/src/react/CadEditor.tsx +19 -9
  258. package/src/react/Drawer.tsx +17 -12
  259. package/src/react/Duck.tsx +3 -3
  260. package/src/react/KeypadSheet.tsx +2 -2
  261. package/src/react/MarkupPanel.tsx +1 -1
  262. package/src/react/PlanCanvas.native.tsx +60 -6
  263. package/src/react/PlanCanvas.web.tsx +17 -5
  264. package/src/react/Popovers.tsx +24 -9
  265. package/src/react/Rail.tsx +1 -1
  266. package/src/react/ShapeMenu.tsx +3 -2
  267. package/src/react/Sheet.tsx +3 -3
  268. package/src/react/ToolTip.tsx +3 -2
  269. package/src/react/TopBar.tsx +4 -3
  270. package/src/react/barScroll.ts +57 -0
  271. package/src/react/corners.ts +6 -3
  272. package/src/react/editorBox.ts +20 -0
  273. package/src/react/host.ts +1 -1
  274. package/src/react/motion.tsx +6 -6
  275. package/src/react/pageLock.native.ts +2 -0
  276. package/src/react/pageLock.web.ts +12 -3
  277. package/src/react/ports.ts +7 -2
  278. package/src/react/saving.ts +10 -2
  279. package/src/react/store.ts +5 -2
  280. package/src/render/draw.ts +169 -118
  281. package/src/render/elevation.ts +5 -4
  282. package/src/render/figmaIcons.ts +17 -1
  283. package/src/render/hit.ts +21 -4
  284. package/src/render/icons.ts +27 -20
  285. package/src/render/lens.ts +52 -0
  286. package/src/render/linkTile.ts +436 -190
  287. package/src/render/snap.ts +87 -17
  288. package/src/render/svgPath.ts +95 -0
  289. package/src/render/tileIcons.ts +75 -0
  290. package/src/render/view.ts +43 -7
  291. package/src/skia/SkiaDrawCtx.ts +26 -4
  292. package/src/solver/equations.ts +52 -22
  293. package/src/solver/gauss-newton.ts +4 -1
  294. package/src/styles.css +3 -2
  295. package/src/tools/annotate.ts +1 -18
  296. package/src/tools/chamfer.ts +3 -0
  297. package/src/tools/dim.ts +26 -52
  298. package/src/tools/fillet.ts +3 -0
  299. package/src/tools/freehand.ts +1 -3
  300. package/src/tools/line.ts +12 -11
  301. package/src/tools/modify.ts +210 -70
  302. package/src/tools/registry.ts +18 -1
  303. package/src/tools/select.ts +144 -36
  304. package/src/tools/shapes.ts +9 -4
  305. package/src/ui/ctx.ts +4 -3
  306. package/src/ui/drawer.ts +8 -2
  307. package/src/view3d/build.ts +11 -10
  308. package/dist/react/LaserTray.d.ts +0 -4
  309. package/dist/react/LaserTray.js +0 -26
  310. package/dist/react/LaserTray.js.map +0 -1
  311. package/src/react/LaserTray.tsx +0 -42
@@ -1,15 +1,15 @@
1
1
  import { afterEdit, holdFaces, solveSketch, tryAddConstraint, undo } from '../app/edit';
2
2
  import { ed, type OpeningDimPart } from '../app/state';
3
- import { CONS_LABEL, measure } from '../model/constraints';
3
+ import { CONS_LABEL, consLabel, 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,32 +34,43 @@ 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[] {
41
48
  const sk = SK(), s = selInfo(), ids = new Set(s.points);
42
- s.lines.forEach(id => { ids.add(sk.lines[id].a); ids.add(sk.lines[id].b); });
49
+ // (a straight wall's edge: its wall, as if picked whole)
50
+ withEdges(sk, s).forEach(id => { ids.add(sk.lines[id].a); ids.add(sk.lines[id].b); });
43
51
  s.curves.forEach(id => { const ci = curveInfo(sk, id); if (ci) ids.add(ci.o.c); });
44
52
  return [...ids].filter(id => sk.points[id] && !sk.points[id].origin && !sk.points[id].anchor);
45
53
  }
46
54
 
47
55
  /**
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.
56
+ * The bar leads with one button, its icon alone, set off by a divider (Figma's bars, 2528:101128: the user's call,
57
+ * 2026-10-27): Open, when what's selected is a link to something of the job's; otherwise Lock — an open padlock (white)
58
+ * when free, a closed one (red, "Locked") when locked. A link that can be locked too (an expanded photo) has Lock next,
59
+ * 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
60
+ * display the icon - no text needed below it").
50
61
  */
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);
62
+ function arrangeLead(items: CtxEntry[]): CtxEntry[] {
63
+ const find = (acts: string[]) => items.find((it): it is CtxItem => typeof it === 'object' && 'act' in it && acts.includes(it.act));
64
+ const open = find(['linkOpen', 'dimLinkOpen']), lock = find(['lock', 'imgLock']);
65
+ if (lock) {
66
+ const sk = SK();
67
+ 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); })();
68
+ Object.assign(lock, { ic: locked ? 'lock' : 'unlock', label: locked ? 'Locked' : 'Lock', on: undefined, locked, bare: true });
69
+ }
70
+ const lead = open ?? lock, out: CtxEntry[] = [];
71
+ if (lead) { lead.lead = true; out.push(lead, 'sep'); if (open && lock) out.push(lock, 'sep'); }
72
+ // the rest, with no divider doubled, first or last
73
+ for (const e of items) if (e !== lead && !(open && e === lock) && !(e === 'sep' && (!out.length || out[out.length - 1] === 'sep'))) out.push(e);
63
74
  while (out[out.length - 1] === 'sep') out.pop();
64
75
  return out;
65
76
  }
@@ -79,24 +90,22 @@ const PLACING = new Set(['door', 'window', 'wdoor', 'wwindow', 'cabinet']);
79
90
  function ctxItems(): CtxEntry[] {
80
91
  const items: CtxEntry[] = [], sk = SK(), s = selInfo();
81
92
  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
93
  if (ed.mode === 'annotate') {
91
94
  const ims = selImages();
92
95
  if (ims.length && ims.length === s.n) {
93
96
  // an image (or several): lock, its own scale, and which goes in front where they overlap
94
97
  // (showing and hiding them all is the Layers menu's)
95
98
  add('lock', 'Lock', 'imgLock');
96
- if (ims.length === 1 && !ims[0].locked) add('calib', 'Set scale', 'calibtool');
99
+ // a photo of the job's, expanded from its tile: opening it, and Expand (on) to fold it back into its tile
100
+ const lk = ims.length === 1 ? ims[0].link : undefined;
101
+ if (lk && canOpenLink(lk)) add('open', 'Open', 'linkOpen');
102
+ // locked: unlocking it, and opening what it's a photo of — nothing that changes it (the user's call,
103
+ // 2026-10-30: "images in locked state: shouldn't have these options - only lock and link should be available")
104
+ if (ims.every(u => u.locked)) return items;
105
+ if (lk) add('expand', 'Expand', 'linkExpand', { on: true });
106
+ if (ims.length === 1) add('calib', 'Set scale', 'calibtool');
97
107
  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');
108
+ if (ims.length === 1) add('rotate', 'Rotate', 'imgRotate');
100
109
  if (ims.length === 1 && page().underlays.length > 1) {
101
110
  const at = page().underlays.indexOf(ims[0]), last = page().underlays.length - 1;
102
111
  if (at < last) add('tofront', 'To front', 'imgFront');
@@ -108,18 +117,17 @@ function ctxItems(): CtxEntry[] {
108
117
  if (lk) {
109
118
  // 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
119
  if (canOpenLink(lk.link)) add('open', 'Open', 'linkOpen');
120
+ // (a photo's: the photo whole, as one of the page's images)
121
+ if (canExpand(lk)) add('expand', 'Expand', 'linkExpand', { on: false });
111
122
  add('leader', 'Leader', 'linkLeader', { on: !!lk.leader });
112
123
  // (its leader's colour, as all markup's, is the rail's colour dot's)
113
124
  // (every tile on the page: a slider above the bar)
114
125
  add('tilesize', 'Size', 'linkSize');
115
126
  return items;
116
127
  }
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
- }
128
+ // markup: nothing of its own — a pen stroke as any other (no To lines: the user's call, 2026-10-27), and no Color
129
+ // (the rail's colour dot styles what's selected); with tiles among it, their size
130
+ if (s.notes.length && linkSizeVisible()) add('tilesize', 'Size', 'linkSize');
123
131
  // nothing selected: no bar (the markup colour is the rail's colour dot, for what's selected too; the image's own
124
132
  // options come with selecting it)
125
133
  return items;
@@ -138,7 +146,6 @@ function ctxItems(): CtxEntry[] {
138
146
  if (n >= 1) add('finish', 'Finish', 'finish');
139
147
  return items;
140
148
  }
141
- if (ed.tool === 'dim') { add('dim', 'Driving', 'dimDrive', { on: ed.dimLayer === 'drive' }); add('ref', 'Print', 'dimPrint', { on: ed.dimLayer === 'print' }); return items; }
142
149
  // nothing selected: the bar stays empty (Fit and Icons live in the top bar; grid snap and auto-constrain in Settings).
143
150
  // Just placed a door, window or cabinet (its tool still on): its bar, for its sizes
144
151
  if (!s.n || (ed.tool !== 'select' && !PLACING.has(ed.tool))) return items;
@@ -149,39 +156,70 @@ function ctxItems(): CtxEntry[] {
149
156
  for (const it of wb.items) add(it.ic, it.label, it.act);
150
157
  return items;
151
158
  }
159
+ /*
160
+ Figma's bars (2528:101128, the user's call, 2026-10-27): Lock first, its icon alone (arrangeLead); then what the lines
161
+ and curves are (kind); then what ties them, or edits them. The yellow + measures: no Distance of its own here.
162
+ */
152
163
  // on a wall face, walls don't exist: no To wall / Thickness
153
164
  const wall = elevOpen();
154
165
  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)
166
+ /**
167
+ * What the selected lines and curves are, and its divider: walls — one straight wall's wall view, and their Thickness;
168
+ * otherwise To wall, and Guide (Solid, when they all are guides). A wall is always solid, so with one among them
169
+ * there's no Guide (the user's call, 2026-11-03: a wall and a line picked together offered it) — only Solid, for a
170
+ * wall that's a guide from before; on a wall face, Guide alone. `walls`: the walls of picked edges, as if picked whole.
171
+ */
172
+ const kind = (walls: string[] = []) => {
173
+ const objs = [...s.lines.map(id => sk.lines[id]), ...walls.map(id => sk.lines[id]), ...s.curves.map(id => curveInfo(sk, id)?.o)].filter((o): o is NonNullable<typeof o> => !!o);
174
+ if (!objs.length) return;
175
+ if (objs.every(o => o.kind === 'wall')) {
176
+ if (wall) return;
177
+ if (s.n === 1 && L === 1) add('elev', 'Elevation', 'elev');
178
+ add('thick', 'Thickness', 'thick');
179
+ if (objs.some(o => o.constr)) add('constr', 'Solid', 'constr');
180
+ } else {
181
+ if (!wall) add('wall', 'To wall', 'kind');
182
+ if (!objs.some(o => o.kind === 'wall')) add('constr', objs.every(o => o.constr) ? 'Solid' : 'Guide', 'constr');
183
+ }
184
+ items.push('sep');
185
+ };
186
+ // a wall's edge: named, with its wall view (with another edge, a line or a point, the + is the distance between them)
160
187
  if (s.wfaces.length) {
161
188
  // (a curved wall has no wall view: just its name)
162
189
  if (s.n === 1) {
163
190
  const [id, side] = edgeOf(s.wfaces[0]);
164
191
  items.push({ lbl: edgeLabel(sk, id, side) });
165
192
  if (sk.lines[id]) add('elev', 'Elevation', 'elev');
166
- }
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');
193
+ } else if (s.n === 2 && straightEdges(sk, s).length === s.wfaces.length) {
194
+ /*
195
+ A straight wall's edge with a point or a line: the bar the wall picked whole has (the user's call, 2026-11-03:
196
+ "add equal, lock, and to wall to both bottom bars"), each acting on what's picked — the point On face; Parallel
197
+ and Perpendicular, the wall (a face runs with its centreline); Collinear, the line along the face (both its ends
198
+ on it); Equal, the face's length, corner to corner. (Two edges: no Collinear — their faces flush is another thing.)
199
+ */
200
+ // (the two edges of one wall: nothing but the + — its thickness)
201
+ const pair = new Set(withEdges(sk, s)).size === 2;
202
+ if (P === 1 || pair) add('lock', 'Lock', 'lock', { key: '⇧F' });
203
+ if (P === 1) add('online', 'On face', 'online', { key: '⇧O' });
204
+ else if (pair) {
205
+ kind(straightEdges(sk, s).map(([id]) => id));
206
+ add('par', 'Parallel', 'par', { key: '⇧P' }); add('perp', 'Perpendicular', 'perp', { key: '⇧R' });
207
+ if (L === 1) add('col', 'Collinear', 'col', { key: '⇧L' });
208
+ add('eq', 'Equal', 'eq', { key: '⇧E' });
209
+ }
170
210
  }
171
211
  return items;
172
212
  }
173
213
  if (s.n === 1 && s.dims.length) {
174
214
  const c = sk.cons[s.dims[0]];
175
215
  // 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)
216
+ // call, 2026-10-21; its chip is drawn linked)
177
217
  if (c.measurementRef) {
178
218
  const t = c.measurementRef;
179
219
  if (canOpenLink(t)) add('open', 'Open', 'dimLinkOpen');
180
220
  add('unlink', 'Unlink', 'dimUnlink');
181
221
  } 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');
222
+ // (no Reference: Figma's dimension bars have none, 2026-10-27)
185
223
  // measured on walls: which of their faces (the drawing stays as it is; the number becomes what it measures there)
186
224
  const fc = faceChoices(sk, c);
187
225
  if (fc.length && items.length) items.push('sep');
@@ -196,70 +234,55 @@ function ctxItems(): CtxEntry[] {
196
234
  // a constraint badge: nothing to offer but delete (the trash), so the bar stays empty
197
235
  if (s.n === 1 && s.cons.length) return items;
198
236
  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
237
+ // one end of a door or window: named (with another point, end or door/window, the + is the distance between them)
200
238
  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; }
239
+ if (s.oanchors.length || (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1)))) return items;
204
240
  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) });
241
+ // its sizes (no +: it isn't one dimension), with nothing saying them — the drawing does (the user's call, 2026-10-27);
242
+ // a door's Swing too, round its four ways (it was Flip: the user's call, 2026-10-27, "ok do swing instead"). (The
243
+ // type is fixed once placed: Door and Window are separate tools.)
209
244
  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'); }
245
+ if (sk.openings[s.openings[0]].type === 'door') { items.push('sep'); add('flip', 'Swing', 'opFlip'); }
211
246
  return items;
212
247
  }
248
+ // a cabinet's corner: nothing of its own (pick another point or line with it to dimension or constrain)
249
+ if (P === 1 && s.n === 1 && cabOfPoint(sk, s.points[0])) return items;
250
+ add('lock', 'Lock', 'lock', { key: '⇧F' });
213
251
  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
- }
252
+ kind();
253
+ // (its length: the yellow +)
254
+ add('h', 'Horizontal', 'h', { key: '⇧H' }); add('v', 'Vertical', 'v', { key: '⇧V' }); add('split', 'Split', 'split'); add('offset', 'Offset', 'offset');
222
255
  return items;
223
256
  }
224
257
  if (L === 2 && s.n === 2) {
225
- // (no Angle here: the yellow + is the angle between them — or, parallel, the distance)
258
+ // (no Angle: the yellow + is the angle between them — or, parallel, the distance)
259
+ kind();
226
260
  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
261
  // 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();
262
+ if (filletCorner2()) { items.push('sep'); add('fillet', 'Fillet', 'fillet'); add('chamfer', 'Chamfer', 'chamfer'); }
230
263
  return items;
231
264
  }
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;
265
+ // a corner where exactly two lines meet: rounding it off or cutting it off (its angle is the yellow +)
234
266
  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
267
  if (filletable(sk, s.points[0])) { add('fillet', 'Fillet', 'fillet'); add('chamfer', 'Chamfer', 'chamfer'); }
237
- add('lock', 'Lock', 'lock', { key: '⇧F' });
238
268
  return items;
239
269
  }
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; }
270
+ // points: joined into the first picked, or level or plumb with it
271
+ if (P >= 2 && s.n === P) { add('merge', 'Join', 'merge'); add('h', 'Horizontal', 'h'); add('v', 'Vertical', 'v'); return items; }
272
+ if (P === 1 && L === 1 && s.n === 2) { add('online', 'On line', 'online', { key: '⇧O' }); add('mid', 'Midpoint', 'mid', { key: '⇧M' }); return items; }
242
273
  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; }
274
+ if (L === 1 && C === 1 && s.n === 2) { kind(); add('tan', 'Tangent', 'tan', { key: '⇧T' }); return items; }
244
275
  // 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; }
276
+ if (C === 1 && s.n === 1) { kind(); return items; }
277
+ if (C === 2 && s.n === 2) { kind(); add('conc', 'Concentric', 'conc'); add('eq', 'Equal', 'eqr'); return items; }
253
278
  // 3+ lines (only lines): the constraints that make sense across many, each tied to the first line picked
254
279
  if (L >= 3 && s.n === L) {
280
+ kind();
255
281
  add('h', 'Horizontal', 'h', { key: '⇧H' }); add('v', 'Vertical', 'v', { key: '⇧V' });
256
282
  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
283
  return items;
260
284
  }
261
- if (L >= 2) { add('eq', 'Equal', 'eqall'); add('lock', 'Lock', 'lock'); guide(); wallItems(); return items; }
262
- add('lock', 'Lock', 'lock');
285
+ if (L >= 2) { kind(); add('eq', 'Equal', 'eqall'); }
263
286
  return items;
264
287
  }
265
288
 
@@ -296,7 +319,7 @@ function filletCorner2(): string | null {
296
319
  }
297
320
 
298
321
  /** Buttons for the bottom bar: they adapt to the tool and the selection (Lock first, when offered). */
299
- export const barItems = (): CtxEntry[] => arrangeLock(ctxItems());
322
+ export const barItems = (): CtxEntry[] => arrangeLead(ctxItems());
300
323
 
301
324
  /** The yellow + only appears for something it can measure: not for constraint badges or spaces alone. */
302
325
  const DRAW_TOOLS = new Set(['line', 'freehand', 'rect', 'circle', 'arc']);
@@ -324,13 +347,13 @@ export function plusAction(): void {
324
347
  if (ed.mode === 'annotate') return;
325
348
  if (wallPlus()) return;
326
349
  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]); }
350
+ if (s.dims.length === 1 && s.n === 1) editDim(s.dims[0]);
328
351
  else if (s.odims.length === 1 && s.n === 1) { const [op, part] = s.odims[0].split('|'); editOpeningDim(op, part as OpeningDimPart); }
329
352
  // a wall's edge: its length on that face; with another edge, a line or a point, the distance from it
330
353
  // (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); }
354
+ 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
355
  else if (edgePair(s)) applyCtxAction('edgeDist');
333
- else if (s.lines.length === 1 && s.n === 1) dimLine(s.lines[0], false);
356
+ else if (s.lines.length === 1 && s.n === 1) dimLine(s.lines[0]);
334
357
  else if (s.curves.length === 1 && s.n === 1) dimCurve(s.curves[0]);
335
358
  else if (s.openings.length === 1 && s.n === 1) editOpeningDim(s.openings[0], 'w');
336
359
  // a corner where two lines meet: its angle
@@ -339,7 +362,7 @@ export function plusAction(): void {
339
362
  else if (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1))) applyCtxAction('opDist');
340
363
  // two things selected: the dimension between them
341
364
  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);
365
+ else if (s.n === 2 && s.points.length === 2) dimPoints(s.points[0], s.points[1]);
343
366
  else if (s.n === 2 && s.points.length === 1 && s.lines.length === 1) dimPointLine(s.points[0], s.lines[0]);
344
367
  else setTool('dim');
345
368
  }
@@ -362,6 +385,11 @@ export function deleteSelection(): void {
362
385
  toast(imgIds.size && !rest.length ? (imgIds.size > 1 ? 'Images removed' : 'Image removed') : 'Deleted', { action: { label: 'Undo', fn: () => undo() } });
363
386
  }
364
387
 
388
+ /** The picked edges of straight walls, as [wall, side] (a curved wall's edge has no line to stand for it). */
389
+ const straightEdges = (sk: Sketch, s: ReturnType<typeof selInfo>): [string, 1 | -1][] => s.wfaces.map(edgeOf).filter(([id]) => !!sk.lines[id]);
390
+ /** The picked lines, and the walls of the picked straight edges. */
391
+ const withEdges = (sk: Sketch, s: ReturnType<typeof selInfo>): string[] => [...s.lines, ...straightEdges(sk, s).map(([id]) => id)];
392
+
365
393
  function consFromSel(type: ConsType): Constraint | null {
366
394
  const sk = SK(), s = selInfo(), L = s.lines.map(id => sk.lines[id]);
367
395
  switch (type) {
@@ -369,10 +397,25 @@ function consFromSel(type: ConsType): Constraint | null {
369
397
  if (s.lines.length === 1) return { type, a: L[0].a, b: L[0].b };
370
398
  if (s.points.length === 2) return { type, a: s.points[0], b: s.points[1] };
371
399
  return null;
372
- case 'par': case 'perp': case 'col': case 'eq':
400
+ case 'par': case 'perp': {
401
+ // (a wall's edge: its wall — the same thing, square to something)
402
+ const ls = withEdges(sk, s);
403
+ return ls.length === 2 && ls[0] !== ls[1] ? { type, l1: ls[0], l2: ls[1] } : null;
404
+ }
405
+ case 'col':
373
406
  return s.lines.length === 2 ? { type, l1: s.lines[0], l2: s.lines[1] } : null;
374
- case 'mid': case 'online':
407
+ case 'eq': {
408
+ // (a wall's edge: that face's length, corner to corner)
409
+ const ls = withEdges(sk, s), f = straightEdges(sk, s);
410
+ if (ls.length !== 2 || ls[0] === ls[1]) return null;
411
+ return f.length ? { type, l1: ls[0], l2: ls[1], faces: ls.map((_, i) => (i < s.lines.length ? 0 : f[i - s.lines.length][1])) as [number, number] } : { type, l1: ls[0], l2: ls[1] };
412
+ }
413
+ case 'mid': case 'online': {
414
+ // (a point and a wall's edge: on that face)
415
+ const f = straightEdges(sk, s);
416
+ if (type === 'online' && s.n === 2 && s.points.length === 1 && f.length === 1) return { type, p: s.points[0], l: f[0][0], face: f[0][1] };
375
417
  return s.points.length === 1 && s.lines.length === 1 ? { type, p: s.points[0], l: s.lines[0] } : null;
418
+ }
376
419
  case 'oncircle':
377
420
  return s.points.length === 1 && s.curves.length === 1 ? { type, p: s.points[0], c: s.curves[0] } : null;
378
421
  case 'tan': {
@@ -395,9 +438,13 @@ const NEEDS: Partial<Record<ConsType, string>> = {
395
438
  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
439
  };
397
440
 
398
- /** H/V on each selected line, or parallel/collinear/equal from the first selected line to each of the others. */
441
+ /**
442
+ * H/V on each selected line, or parallel/collinear/equal from the first selected line to each of the others; three
443
+ * or more points, each level or plumb with the first picked.
444
+ */
399
445
  function multiLineCons(type: ConsType): Constraint[] | null {
400
446
  const sk = SK(), s = selInfo();
447
+ 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
448
  if (s.n !== s.lines.length || s.lines.length < (type === 'h' || type === 'v' ? 2 : 3)) return null;
402
449
  if (type === 'h' || type === 'v') return s.lines.map(id => ({ type, a: sk.lines[id].a, b: sk.lines[id].b }));
403
450
  if (type === 'par' || type === 'col' || type === 'eq') return s.lines.slice(1).map(id => ({ type, l1: s.lines[0], l2: id }));
@@ -406,8 +453,11 @@ function multiLineCons(type: ConsType): Constraint[] | null {
406
453
 
407
454
  const ADJ: Partial<Record<ConsType, string>> = { h: 'horizontal', v: 'vertical', par: 'parallel', col: 'collinear', eq: 'equal' };
408
455
 
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 {
456
+ /**
457
+ * Apply one constraint type across several lines (or `points`, each tied to the first) as one undo step. A conflict
458
+ * opens the conflict sheet for the first one that fails.
459
+ */
460
+ function applyMulti(type: ConsType, list: Constraint[], points = false): void {
411
461
  pushHistory();
412
462
  let added = 0;
413
463
  const failed: Constraint[] = [];
@@ -418,9 +468,9 @@ function applyMulti(type: ConsType, list: Constraint[]): void {
418
468
  }
419
469
  if (!added && !failed.length) { dropHistory(); toast('They’re already ' + ADJ[type] + '.', { warn: true }); afterEdit(); return; }
420
470
  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');
471
+ // every selected line (or point) that now satisfies it (some may already have, through other constraints)
472
+ const lines = ((type === 'h' || type === 'v') && !points ? list.length : list.length + 1) - failed.length;
473
+ const nLines = lines + (points ? ' point' : ' line') + (lines === 1 ? '' : 's');
424
474
  if (failed.length) {
425
475
  // explain the first conflict; the sheet's Remove re-applies it
426
476
  tryAddConstraint(failed[0], { onResolved: () => afterEdit() });
@@ -429,15 +479,42 @@ function applyMulti(type: ConsType, list: Constraint[]): void {
429
479
  afterEdit();
430
480
  }
431
481
 
482
+ /** A line and a straight wall's edge picked: the line along that face, both its ends on it (Collinear, with a face). */
483
+ function alongFace(): Constraint[] | null {
484
+ const sk = SK(), s = selInfo(), f = straightEdges(sk, s);
485
+ if (s.n !== 2 || s.lines.length !== 1 || f.length !== 1 || f[0][0] === s.lines[0]) return null;
486
+ const [l, face] = f[0], ln = sk.lines[s.lines[0]];
487
+ return [ln.a, ln.b].map(p => ({ type: 'online' as const, p, l, face }));
488
+ }
489
+
490
+ /** A line along a face (alongFace), as one undo step: its ends on it, or the conflict sheet for the first that can't be. */
491
+ function applyAlong(list: Constraint[]): void {
492
+ pushHistory();
493
+ let added = 0;
494
+ const failed: Constraint[] = [];
495
+ for (const c of list) {
496
+ const r = tryAddConstraint(c, { silent: true });
497
+ if (r.ok) added++;
498
+ else if (r.conflict) failed.push(c);
499
+ }
500
+ if (!added && !failed.length) { dropHistory(); toast('It’s already along that face.', { warn: true }); afterEdit(); return; }
501
+ if (!added) dropHistory();
502
+ if (failed.length) tryAddConstraint(failed[0], { onResolved: () => afterEdit() });
503
+ else toast(CONS_LABEL.col + ' added');
504
+ afterEdit();
505
+ }
506
+
432
507
  function applyCons(type: ConsType): void {
433
- const multi = multiLineCons(type);
434
- if (multi && (multi.length > 1 || selInfo().lines.length > 2)) { applyMulti(type, multi); return; }
508
+ const along = type === 'col' ? alongFace() : null;
509
+ if (along) { applyAlong(along); return; }
510
+ const multi = multiLineCons(type), s = selInfo(), points = s.points.length > 2 && s.n === s.points.length;
511
+ if (multi && (multi.length > 1 || s.lines.length > 2 || points)) { applyMulti(type, multi, points); return; }
435
512
  const c = consFromSel(type);
436
513
  if (!c) { toast('Select ' + (NEEDS[type] || 'geometry') + ' first.', { warn: true }); return; }
437
514
  pushHistory();
438
515
  const r = tryAddConstraint(c, { onResolved: () => afterEdit() });
439
516
  if (!r.ok) dropHistory();
440
- else toast(CONS_LABEL[c.type] + ' added');
517
+ else toast(consLabel(c) + ' added');
441
518
  afterEdit();
442
519
  }
443
520
 
@@ -468,15 +545,13 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
468
545
  // (the Line tool's, or the templator's: both close and finish a run of points)
469
546
  case 'closeShape': (tools[ed.tool] as LineTool | SurveyTool).closeShape(); updateCtx(); return;
470
547
  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
548
  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
549
  applyCons(act); return;
475
550
  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); }
551
+ if (s.lines.length === 1) dimLine(s.lines[0]);
552
+ else if (s.wfaces.length === 1 && sk.lines[edgeOf(s.wfaces[0])[0]]) { const [lid, side] = edgeOf(s.wfaces[0]); dimLine(lid, side); }
478
553
  return;
479
- case 'pdist': if (s.points.length === 2) dimPoints(s.points[0], s.points[1], false); return;
554
+ case 'pdist': if (s.points.length === 2) dimPoints(s.points[0], s.points[1]); return;
480
555
  case 'pldist': if (s.points.length === 1 && s.lines.length === 1) dimPointLine(s.points[0], s.lines[0]); return;
481
556
  case 'diam': if (s.curves.length === 1) dimCurve(s.curves[0]); return;
482
557
  case 'fillet': case 'chamfer': {
@@ -486,9 +561,13 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
486
561
  }
487
562
  case 'cornerAngle': cornerAngle(s.points[0]); return;
488
563
  case 'merge': {
564
+ // (every one into the first picked)
565
+ if (s.points.length < 2) return;
489
566
  pushHistory();
490
567
  const snap = JSON.stringify(sk);
491
- if (joinPoints(sk, s.points[0], s.points[1])) {
568
+ if (!s.points.slice(1).every(id => joinPoints(sk, s.points[0], id))) {
569
+ replaceSK(JSON.parse(snap) as Sketch); dropHistory(); toast('Two locked points can’t be joined. Unlock one first.', { warn: true });
570
+ } else {
492
571
  const r = solveSketch();
493
572
  if (!r.ok) { replaceSK(JSON.parse(snap) as Sketch); dropHistory(); toast('Can’t join those points — it conflicts with other constraints.', { warn: true }); }
494
573
  else { ed.sel = [{ type: 'point', id: s.points[0] }]; toast('Points joined'); }
@@ -514,12 +593,21 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
514
593
  requestDraw();
515
594
  break;
516
595
  }
517
- case 'constr':
596
+ case 'constr': {
597
+ // all guides, or all solid again if they all are — one setting for the whole selection, as To wall is (flipping
598
+ // each swapped a guide and a line picked together). A wall is always solid: never made a guide, and one that's a
599
+ // guide from before goes back to solid
600
+ const objs = [...s.lines.filter(id => !sk.lines[id].anchor).map(id => sk.lines[id]), ...s.curves.map(id => curveInfo(sk, id)?.o)]
601
+ .filter((o): o is NonNullable<typeof o> => !!o);
602
+ const lines = objs.filter(o => o.kind !== 'wall'), walls = objs.filter(o => o.kind === 'wall' && o.constr);
603
+ if (!lines.length && !walls.length) return;
604
+ const guide = !lines.every(o => o.constr);
518
605
  pushHistory();
519
- s.lines.forEach(id => { if (!sk.lines[id].anchor) sk.lines[id].constr = !sk.lines[id].constr; });
520
- s.curves.forEach(id => { const ci = curveInfo(sk, id); if (ci) ci.o.constr = !ci.o.constr; });
606
+ lines.forEach(o => { o.constr = guide; });
607
+ walls.forEach(o => { o.constr = false; });
521
608
  afterEdit();
522
609
  return;
610
+ }
523
611
  case 'kind': {
524
612
  // one setting for the whole selection: all walls, unless they all are already (then all lines)
525
613
  const targets: { kind?: 'line' | 'wall'; thick?: number }[] = [...s.lines.map(id => sk.lines[id]), ...s.curves.map(id => curveInfo(sk, id)?.o).filter((o): o is NonNullable<typeof o> => !!o)];
@@ -548,9 +636,9 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
548
636
  return;
549
637
  }
550
638
  case 'thick':
551
- // straight and curved walls in the selection
639
+ // straight and curved walls in the selection (an edge's: its wall)
552
640
  thicknessPrompt([
553
- ...s.lines.map(id => sk.lines[id]).filter(l => l.kind === 'wall'),
641
+ ...withEdges(sk, s).map(id => sk.lines[id]).filter(l => l.kind === 'wall'),
554
642
  ...s.curves.map(id => curveInfo(sk, id)?.o).filter((o): o is NonNullable<typeof o> => !!o && o.kind === 'wall'),
555
643
  ]);
556
644
  return;
@@ -564,7 +652,8 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
564
652
  case 'toggleref': toggleRef(s.dims[0]); return;
565
653
  case 'odimRef': { const [op, part] = s.odims[0].split('|'); toggleOpeningDim(op, part.slice(2)); return; }
566
654
  case 'renameRoom': editRoomName(s.rooms[0]); return;
567
- case 'opFlip': { pushHistory(); const o = sk.openings[s.openings[0]]; o.flip = !o.flip; afterEdit(); return; }
655
+ // (the room is on the left of the wall's start → end when there's none either side of it)
656
+ case 'opFlip': { pushHistory(); const o = sk.openings[s.openings[0]]; flipDoor(o, roomSideOf(sk, o.line) === -1 ? -1 : 1); afterEdit(); return; }
568
657
  case 'opDist': {
569
658
  const ops = ed.sel.filter(x => x.type === 'opening').map(x => x.id);
570
659
  if (ops.length === 2) {
@@ -601,8 +690,8 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
601
690
  return;
602
691
  }
603
692
  case 'palette': openPalette(anchor); return;
604
- case 'convert': convertNote(s.notes[0]); return;
605
693
  case 'linkOpen': openSelectedLink(); return;
694
+ case 'linkExpand': toggleExpand(); return;
606
695
  case 'linkSize': toggleTileSize(anchor); return;
607
696
  case 'dimLinkOpen': if (s.dims.length === 1) openDimLink(s.dims[0]); return;
608
697
  case 'dimUnlink': if (s.dims.length === 1) unlinkDim(s.dims[0]); return;
@@ -619,6 +708,8 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
619
708
  const lock = !ims.every(u => u.locked), many = ims.length > 1;
620
709
  pushHistory();
621
710
  ims.forEach(u => { u.locked = lock; });
711
+ // (locked, its bar is Lock and Open alone: an opacity or turning slider open over it goes with the rest)
712
+ if (lock) dismissPopovers();
622
713
  afterEdit();
623
714
  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
715
  : many ? 'Images unlocked.' : 'Image unlocked. Drag it, or drag its handles to size it.');