@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,14 +1,14 @@
1
1
  import { afterEdit, holdFaces, solveSketch, tryAddConstraint, undo } from '../app/edit';
2
2
  import { ed } 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
11
  import { fitView, requestDraw } from '../render/view';
11
- import { convertNote } from '../tools/annotate';
12
12
  import { cornerAngle, dimCurve, dimEdges, dimLine, dimPointLine, dimPoints, dimTwoLines, editDim, faceChoices, onChoice, setDimFace, toggleRef } from '../tools/dim';
13
13
  import { edgeLabel, edgeOf } from '../model/planGeom';
14
14
  import { offsetPrompt, splitLine } from '../tools/modify';
@@ -17,40 +17,49 @@ import { chamferPrompt } from '../tools/chamfer';
17
17
  import { addOpeningDim, editOpeningDim, toggleOpeningDim } from '../tools/openings';
18
18
  import { setTool, tools } from '../tools/registry';
19
19
  import { fmtLen } from '../units';
20
- import { elevOpen, fitState, OPENING_SIZES, openElevation, openingLabel, overflow, restoreFit, TOO_SHORT, wallAction, wallBar, wallDelete, wallPlus } from './elevation';
20
+ import { elevOpen, fitState, OPENING_SIZES, openElevation, overflow, restoreFit, TOO_SHORT, wallAction, wallBar, wallDelete, wallPlus } from './elevation';
21
21
  import { pushedOut } from '../model/facesolve';
22
- import { editRoomName, openKeypad, openPalette, toast, toggleImageOpacity, toggleImageRotation, toggleTileSize, updateCtx, updateTopBar } from './ports';
22
+ import { dismissPopovers, editRoomName, openKeypad, openPalette, toast, toggleImageOpacity, toggleImageRotation, toggleTileSize, updateCtx, updateTopBar } from './ports';
23
23
  import { cornerLines, edgePair, selInfo } from './selection';
24
24
  import { restackImage } from './project';
25
25
  import { imageById } from '../model/images';
26
- import { canOpenLink, canSaveDim, linkSizeVisible, openDimLink, openSelectedLink, saveDimToGroup, selectedLink, toggleLeader, unlinkDim } from './linkActions';
26
+ import { canExpand, canOpenLink, canSaveDim, linkSizeVisible, openDimLink, openSelectedLink, saveDimToGroup, selectedLink, toggleExpand, toggleLeader, unlinkDim } from './linkActions';
27
+ /** A bar button shown as its icon alone, no name under it (the lead, and Lock). */
28
+ export const iconAlone = (it) => !!(it.lead || it.bare);
27
29
  /** Points a Lock on the current selection would pin (the origin is always fixed). */
28
30
  function lockTargets() {
29
31
  const sk = SK(), s = selInfo(), ids = new Set(s.points);
30
- s.lines.forEach(id => { ids.add(sk.lines[id].a); ids.add(sk.lines[id].b); });
32
+ // (a straight wall's edge: its wall, as if picked whole)
33
+ withEdges(sk, s).forEach(id => { ids.add(sk.lines[id].a); ids.add(sk.lines[id].b); });
31
34
  s.curves.forEach(id => { const ci = curveInfo(sk, id); if (ci)
32
35
  ids.add(ci.o.c); });
33
36
  return [...ids].filter(id => sk.points[id] && !sk.points[id].origin && !sk.points[id].anchor);
34
37
  }
35
38
  /**
36
- * Lock is always the first action, set off by a divider, and shows its state:
37
- * open padlock "Lock" (white) when free, closed padlock "Locked" (red) when locked.
39
+ * The bar leads with one button, its icon alone, set off by a divider (Figma's bars, 2528:101128: the user's call,
40
+ * 2026-10-27): Open, when what's selected is a link to something of the job's; otherwise Lock — an open padlock (white)
41
+ * when free, a closed one (red, "Locked") when locked. A link that can be locked too (an expanded photo) has Lock next,
42
+ * 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
43
+ * display the icon - no text needed below it").
38
44
  */
39
- function arrangeLock(items) {
40
- const i = items.findIndex(it => typeof it === 'object' && 'act' in it && (it.act === 'lock' || it.act === 'imgLock'));
41
- if (i < 0)
42
- return items;
43
- const it = items[i];
44
- const sk = SK();
45
- 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); })();
46
- Object.assign(it, { ic: locked ? 'lock' : 'unlock', label: locked ? 'Locked' : 'Lock', on: undefined, locked });
47
- const rest = items.filter((_, k) => k !== i);
48
- const out = [it];
49
- if (rest.length)
50
- out.push('sep');
51
- // tidy separators: none doubled, none leading or trailing
52
- for (const e of rest)
53
- if (!(e === 'sep' && (out[out.length - 1] === 'sep')))
45
+ function arrangeLead(items) {
46
+ const find = (acts) => items.find((it) => typeof it === 'object' && 'act' in it && acts.includes(it.act));
47
+ const open = find(['linkOpen', 'dimLinkOpen']), lock = find(['lock', 'imgLock']);
48
+ if (lock) {
49
+ const sk = SK();
50
+ 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); })();
51
+ Object.assign(lock, { ic: locked ? 'lock' : 'unlock', label: locked ? 'Locked' : 'Lock', on: undefined, locked, bare: true });
52
+ }
53
+ const lead = open ?? lock, out = [];
54
+ if (lead) {
55
+ lead.lead = true;
56
+ out.push(lead, 'sep');
57
+ if (open && lock)
58
+ out.push(lock, 'sep');
59
+ }
60
+ // the rest, with no divider doubled, first or last
61
+ for (const e of items)
62
+ if (e !== lead && !(open && e === lock) && !(e === 'sep' && (!out.length || out[out.length - 1] === 'sep')))
54
63
  out.push(e);
55
64
  while (out[out.length - 1] === 'sep')
56
65
  out.pop();
@@ -67,32 +76,27 @@ const PLACING = new Set(['door', 'window', 'wdoor', 'wwindow', 'cabinet']);
67
76
  function ctxItems() {
68
77
  const items = [], sk = SK(), s = selInfo();
69
78
  const add = (ic, label, act, o = {}) => items.push(Object.assign({ ic, label, act }, o));
70
- /** To wall (all the selected lines and curves) — or To line + Thickness when they're all walls already. */
71
- const wallItems = () => {
72
- if (elevOpen())
73
- return;
74
- const kinds = [...s.lines.map(id => sk.lines[id].kind), ...s.curves.map(id => curveInfo(sk, id)?.o.kind)];
75
- if (!kinds.length)
76
- return;
77
- if (kinds.every(k => k === 'wall')) {
78
- add('thick', 'Thickness', 'thick');
79
- add('line', 'To line', 'kind');
80
- }
81
- else
82
- add('wall', 'To wall', 'kind');
83
- };
84
79
  if (ed.mode === 'annotate') {
85
80
  const ims = selImages();
86
81
  if (ims.length && ims.length === s.n) {
87
82
  // an image (or several): lock, its own scale, and which goes in front where they overlap
88
83
  // (showing and hiding them all is the Layers menu's)
89
84
  add('lock', 'Lock', 'imgLock');
90
- if (ims.length === 1 && !ims[0].locked)
85
+ // a photo of the job's, expanded from its tile: opening it, and Expand (on) to fold it back into its tile
86
+ const lk = ims.length === 1 ? ims[0].link : undefined;
87
+ if (lk && canOpenLink(lk))
88
+ add('open', 'Open', 'linkOpen');
89
+ // locked: unlocking it, and opening what it's a photo of — nothing that changes it (the user's call,
90
+ // 2026-10-30: "images in locked state: shouldn't have these options - only lock and link should be available")
91
+ if (ims.every(u => u.locked))
92
+ return items;
93
+ if (lk)
94
+ add('expand', 'Expand', 'linkExpand', { on: true });
95
+ if (ims.length === 1)
91
96
  add('calib', 'Set scale', 'calibtool');
92
97
  if (ims.length === 1)
93
98
  add('opacity', 'Opacity', 'imgOpacity');
94
- // (turning moves it: not while it's locked, as it can't be moved or sized then)
95
- if (ims.length === 1 && !ims[0].locked)
99
+ if (ims.length === 1)
96
100
  add('rotate', 'Rotate', 'imgRotate');
97
101
  if (ims.length === 1 && page().underlays.length > 1) {
98
102
  const at = page().underlays.indexOf(ims[0]), last = page().underlays.length - 1;
@@ -108,20 +112,19 @@ function ctxItems() {
108
112
  // 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)
109
113
  if (canOpenLink(lk.link))
110
114
  add('open', 'Open', 'linkOpen');
115
+ // (a photo's: the photo whole, as one of the page's images)
116
+ if (canExpand(lk))
117
+ add('expand', 'Expand', 'linkExpand', { on: false });
111
118
  add('leader', 'Leader', 'linkLeader', { on: !!lk.leader });
112
119
  // (its leader's colour, as all markup's, is the rail's colour dot's)
113
120
  // (every tile on the page: a slider above the bar)
114
121
  add('tilesize', 'Size', 'linkSize');
115
122
  return items;
116
123
  }
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)
121
- add('convert', 'To lines', 'convert');
122
- if (linkSizeVisible())
123
- add('tilesize', 'Size', 'linkSize');
124
- }
124
+ // markup: nothing of its own — a pen stroke as any other (no To lines: the user's call, 2026-10-27), and no Color
125
+ // (the rail's colour dot styles what's selected); with tiles among it, their size
126
+ if (s.notes.length && linkSizeVisible())
127
+ add('tilesize', 'Size', 'linkSize');
125
128
  // nothing selected: no bar (the markup colour is the rail's colour dot, for what's selected too; the image's own
126
129
  // options come with selecting it)
127
130
  return items;
@@ -143,11 +146,6 @@ function ctxItems() {
143
146
  add('finish', 'Finish', 'finish');
144
147
  return items;
145
148
  }
146
- if (ed.tool === 'dim') {
147
- add('dim', 'Driving', 'dimDrive', { on: ed.dimLayer === 'drive' });
148
- add('ref', 'Print', 'dimPrint', { on: ed.dimLayer === 'print' });
149
- return items;
150
- }
151
149
  // nothing selected: the bar stays empty (Fit and Icons live in the top bar; grid snap and auto-constrain in Settings).
152
150
  // Just placed a door, window or cabinet (its tool still on): its bar, for its sizes
153
151
  if (!s.n || (ed.tool !== 'select' && !PLACING.has(ed.tool)))
@@ -161,15 +159,41 @@ function ctxItems() {
161
159
  add(it.ic, it.label, it.act);
162
160
  return items;
163
161
  }
162
+ /*
163
+ Figma's bars (2528:101128, the user's call, 2026-10-27): Lock first, its icon alone (arrangeLead); then what the lines
164
+ and curves are (kind); then what ties them, or edits them. The yellow + measures: no Distance of its own here.
165
+ */
164
166
  // on a wall face, walls don't exist: no To wall / Thickness
165
167
  const wall = elevOpen();
166
168
  const P = s.points.length, L = s.lines.length, C = s.curves.length;
167
- // Guide (dashed) is for lines: a wall is always solid (one that's a guide from before can still go back to Solid)
168
- 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; });
169
- const guide = () => { if (!allWalls)
170
- add('constr', 'Guide', 'constr'); };
171
- // a wall's edge: named, with its wall view; with another edge, a line or a point, the distance between them (two
172
- // edges of one wall: its thickness)
169
+ /**
170
+ * What the selected lines and curves are, and its divider: walls — one straight wall's wall view, and their Thickness;
171
+ * otherwise To wall, and Guide (Solid, when they all are guides). A wall is always solid, so with one among them
172
+ * there's no Guide (the user's call, 2026-11-03: a wall and a line picked together offered it) — only Solid, for a
173
+ * wall that's a guide from before; on a wall face, Guide alone. `walls`: the walls of picked edges, as if picked whole.
174
+ */
175
+ const kind = (walls = []) => {
176
+ 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);
177
+ if (!objs.length)
178
+ return;
179
+ if (objs.every(o => o.kind === 'wall')) {
180
+ if (wall)
181
+ return;
182
+ if (s.n === 1 && L === 1)
183
+ add('elev', 'Elevation', 'elev');
184
+ add('thick', 'Thickness', 'thick');
185
+ if (objs.some(o => o.constr))
186
+ add('constr', 'Solid', 'constr');
187
+ }
188
+ else {
189
+ if (!wall)
190
+ add('wall', 'To wall', 'kind');
191
+ if (!objs.some(o => o.kind === 'wall'))
192
+ add('constr', objs.every(o => o.constr) ? 'Solid' : 'Guide', 'constr');
193
+ }
194
+ items.push('sep');
195
+ };
196
+ // a wall's edge: named, with its wall view (with another edge, a line or a point, the + is the distance between them)
173
197
  if (s.wfaces.length) {
174
198
  // (a curved wall has no wall view: just its name)
175
199
  if (s.n === 1) {
@@ -178,16 +202,34 @@ function ctxItems() {
178
202
  if (sk.lines[id])
179
203
  add('elev', 'Elevation', 'elev');
180
204
  }
181
- else if (edgePair(s)) {
182
- const one = s.wfaces.length === 2 && edgeOf(s.wfaces[0])[0] === edgeOf(s.wfaces[1])[0];
183
- add(one ? 'thick' : 'dim', one ? 'Thickness' : 'Distance', 'edgeDist');
205
+ else if (s.n === 2 && straightEdges(sk, s).length === s.wfaces.length) {
206
+ /*
207
+ 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:
208
+ "add equal, lock, and to wall to both bottom bars"), each acting on what's picked — the point On face; Parallel
209
+ and Perpendicular, the wall (a face runs with its centreline); Collinear, the line along the face (both its ends
210
+ on it); Equal, the face's length, corner to corner. (Two edges: no Collinear — their faces flush is another thing.)
211
+ */
212
+ // (the two edges of one wall: nothing but the + — its thickness)
213
+ const pair = new Set(withEdges(sk, s)).size === 2;
214
+ if (P === 1 || pair)
215
+ add('lock', 'Lock', 'lock', { key: '⇧F' });
216
+ if (P === 1)
217
+ add('online', 'On face', 'online', { key: '⇧O' });
218
+ else if (pair) {
219
+ kind(straightEdges(sk, s).map(([id]) => id));
220
+ add('par', 'Parallel', 'par', { key: '⇧P' });
221
+ add('perp', 'Perpendicular', 'perp', { key: '⇧R' });
222
+ if (L === 1)
223
+ add('col', 'Collinear', 'col', { key: '⇧L' });
224
+ add('eq', 'Equal', 'eq', { key: '⇧E' });
225
+ }
184
226
  }
185
227
  return items;
186
228
  }
187
229
  if (s.n === 1 && s.dims.length) {
188
230
  const c = sk.cons[s.dims[0]];
189
231
  // linked to a measurement of the job: opening it, letting go of it — no text saying what it's linked to (the user's
190
- // call, 2026-10-21; its chip is drawn linked), and no Reference (a measurement drives its line, or it isn't linked)
232
+ // call, 2026-10-21; its chip is drawn linked)
191
233
  if (c.measurementRef) {
192
234
  const t = c.measurementRef;
193
235
  if (canOpenLink(t))
@@ -196,10 +238,7 @@ function ctxItems() {
196
238
  }
197
239
  else if (canSaveDim(s.dims[0]))
198
240
  add('savegroup', 'Save to group', 'dimSave');
199
- // Reference frees what a dimension holds — a distance, say; not a cabinet's own width or height, which it keeps
200
- const cabSize = Object.values(sk.cabs ?? {}).some(cb => cb.w === s.dims[0] || cb.h === s.dims[0]);
201
- if (c.layer !== 'print' && !cabSize && !c.measurementRef)
202
- add('ref', c.driving === false ? 'Drive' : 'Reference', 'toggleref');
241
+ // (no Reference: Figma's dimension bars have none, 2026-10-27)
203
242
  // measured on walls: which of their faces (the drawing stays as it is; the number becomes what it measures there)
204
243
  const fc = faceChoices(sk, c);
205
244
  if (fc.length && items.length)
@@ -220,96 +259,71 @@ function ctxItems() {
220
259
  add('rename', 'Rename', 'renameRoom');
221
260
  return items;
222
261
  }
223
- // one end of a door or window: named; with another point, end or door/window, the distance between them
262
+ // one end of a door or window: named (with another point, end or door/window, the + is the distance between them)
224
263
  if (s.oanchors.length && s.n === 1) {
225
264
  items.push({ lbl: anchorLabel(s.oanchors[0]) });
226
265
  return items;
227
266
  }
228
- if (s.oanchors.length && s.n === 2 && s.oanchors.length + s.points.length + s.openings.length === 2) {
229
- add('dim', 'Distance', 'anchorDist');
230
- return items;
231
- }
232
- // two doors/windows, or one and a point: the distance between them (locks the second one picked)
233
- if (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1))) {
234
- add('dim', 'Distance', 'opDist');
267
+ if (s.oanchors.length || (s.n === 2 && (s.openings.length === 2 || (s.openings.length === 1 && s.points.length === 1))))
235
268
  return items;
236
- }
237
269
  if (s.n === 1 && s.openings.length) {
238
- // its sizes by name, as in the wall view (no +: it isn't one dimension); a door's swing too. (The type is fixed
239
- // once placed: Door and Window are separate tools.)
240
- const o = sk.openings[s.openings[0]];
241
- items.push({ lbl: openingLabel(o) });
270
+ // its sizes (no +: it isn't one dimension), with nothing saying them — the drawing does (the user's call, 2026-10-27);
271
+ // a door's Swing too, round its four ways (it was Flip: the user's call, 2026-10-27, "ok do swing instead"). (The
272
+ // type is fixed once placed: Door and Window are separate tools.)
242
273
  for (const it of OPENING_SIZES)
243
274
  add(it.ic, it.label, it.act);
244
- if (o.type === 'door') {
275
+ if (sk.openings[s.openings[0]].type === 'door') {
245
276
  items.push('sep');
246
- add('flip', 'Flip', 'opFlip');
277
+ add('flip', 'Swing', 'opFlip');
247
278
  }
248
279
  return items;
249
280
  }
281
+ // a cabinet's corner: nothing of its own (pick another point or line with it to dimension or constrain)
282
+ if (P === 1 && s.n === 1 && cabOfPoint(sk, s.points[0]))
283
+ return items;
284
+ add('lock', 'Lock', 'lock', { key: '⇧F' });
250
285
  if (L === 1 && s.n === 1) {
251
- const l = sk.lines[s.lines[0]];
286
+ kind();
287
+ // (its length: the yellow +)
252
288
  add('h', 'Horizontal', 'h', { key: '⇧H' });
253
- add('v', 'Vertical', 'v', { key: '⇧V' }); // (its length: the yellow +)
254
- items.push('sep');
255
- add('lock', 'Lock', 'lock', { key: '⇧F' });
289
+ add('v', 'Vertical', 'v', { key: '⇧V' });
256
290
  add('split', 'Split', 'split');
257
291
  add('offset', 'Offset', 'offset');
258
- if (l.constr)
259
- add('constr', 'Solid', 'constr');
260
- else
261
- guide();
262
- if (!wall) {
263
- if (l.kind === 'wall') {
264
- add('thick', 'Thickness', 'thick');
265
- add('line', 'To line', 'kind');
266
- }
267
- else
268
- add('wall', 'To wall', 'kind');
269
- if (l.kind === 'wall' && !l.constr) {
270
- items.push('sep');
271
- add('elev', 'Elevation', 'elev');
272
- }
273
- }
274
292
  return items;
275
293
  }
276
294
  if (L === 2 && s.n === 2) {
277
- // (no Angle here: the yellow + is the angle between them — or, parallel, the distance)
295
+ // (no Angle: the yellow + is the angle between them — or, parallel, the distance)
296
+ kind();
278
297
  add('par', 'Parallel', 'par', { key: '⇧P' });
279
298
  add('perp', 'Perpendicular', 'perp', { key: '⇧R' });
280
299
  add('col', 'Collinear', 'col', { key: '⇧L' });
281
300
  add('eq', 'Equal', 'eq', { key: '⇧E' });
282
301
  // two lines that meet: round off (or cut off) the corner between them
283
302
  if (filletCorner2()) {
303
+ items.push('sep');
284
304
  add('fillet', 'Fillet', 'fillet');
285
305
  add('chamfer', 'Chamfer', 'chamfer');
286
306
  }
287
- wallItems();
288
307
  return items;
289
308
  }
290
- // a cabinet's corner: nothing of its own (pick another point or line with it to dimension or constrain)
291
- if (P === 1 && s.n === 1 && cabOfPoint(sk, s.points[0]))
292
- return items;
309
+ // a corner where exactly two lines meet: rounding it off or cutting it off (its angle is the yellow +)
293
310
  if (P === 1 && s.n === 1) {
294
- // a corner where exactly two lines meet: rounding it off or cutting it off (its angle is the yellow +)
295
311
  if (filletable(sk, s.points[0])) {
296
312
  add('fillet', 'Fillet', 'fillet');
297
313
  add('chamfer', 'Chamfer', 'chamfer');
298
314
  }
299
- add('lock', 'Lock', 'lock', { key: '⇧F' });
300
315
  return items;
301
316
  }
302
- if (P === 2 && s.n === 2) {
317
+ // points: joined into the first picked, or level or plumb with it
318
+ if (P >= 2 && s.n === P) {
319
+ add('merge', 'Join', 'merge');
303
320
  add('h', 'Horizontal', 'h');
304
321
  add('v', 'Vertical', 'v');
305
- add('dim', 'Distance', 'pdist');
306
- add('merge', 'Join', 'merge');
307
322
  return items;
308
323
  }
309
324
  if (P === 1 && L === 1 && s.n === 2) {
310
- add('mid', 'Midpoint', 'mid', { key: '⇧M' });
311
325
  add('online', 'On line', 'online', { key: '⇧O' });
312
- add('dim', 'Distance', 'pldist');
326
+ add('mid', 'Midpoint', 'mid', { key: '⇧M' });
313
327
  return items;
314
328
  }
315
329
  if (P === 1 && C === 1 && s.n === 2) {
@@ -317,51 +331,35 @@ function ctxItems() {
317
331
  return items;
318
332
  }
319
333
  if (L === 1 && C === 1 && s.n === 2) {
334
+ kind();
320
335
  add('tan', 'Tangent', 'tan', { key: '⇧T' });
321
- wallItems();
322
336
  return items;
323
337
  }
324
338
  // a circle or arc: the yellow + sets its diameter
325
339
  if (C === 1 && s.n === 1) {
326
- const ci = curveInfo(sk, s.curves[0]);
327
- guide();
328
- if (wall)
329
- return items;
330
- if (ci && ci.o.kind === 'wall') {
331
- add('thick', 'Thickness', 'thick');
332
- add('arc', 'To line', 'kind');
333
- }
334
- else
335
- add('wall', 'To wall', 'kind');
340
+ kind();
336
341
  return items;
337
342
  }
338
343
  if (C === 2 && s.n === 2) {
339
- add('eq', 'Equal', 'eqr');
344
+ kind();
340
345
  add('conc', 'Concentric', 'conc');
341
- wallItems();
346
+ add('eq', 'Equal', 'eqr');
342
347
  return items;
343
348
  }
344
349
  // 3+ lines (only lines): the constraints that make sense across many, each tied to the first line picked
345
350
  if (L >= 3 && s.n === L) {
351
+ kind();
346
352
  add('h', 'Horizontal', 'h', { key: '⇧H' });
347
353
  add('v', 'Vertical', 'v', { key: '⇧V' });
348
354
  add('par', 'Parallel', 'par', { key: '⇧P' });
349
355
  add('col', 'Collinear', 'col', { key: '⇧L' });
350
356
  add('eq', 'Equal', 'eq', { key: '⇧E' });
351
- items.push('sep');
352
- add('lock', 'Lock', 'lock');
353
- guide();
354
- wallItems();
355
357
  return items;
356
358
  }
357
359
  if (L >= 2) {
360
+ kind();
358
361
  add('eq', 'Equal', 'eqall');
359
- add('lock', 'Lock', 'lock');
360
- guide();
361
- wallItems();
362
- return items;
363
362
  }
364
- add('lock', 'Lock', 'lock');
365
363
  return items;
366
364
  }
367
365
  /** The keypad for walls' thickness, finished face to face. */
@@ -400,7 +398,7 @@ function filletCorner2() {
400
398
  return p && filletable(sk, p) ? p : null;
401
399
  }
402
400
  /** Buttons for the bottom bar: they adapt to the tool and the selection (Lock first, when offered). */
403
- export const barItems = () => arrangeLock(ctxItems());
401
+ export const barItems = () => arrangeLead(ctxItems());
404
402
  /** The yellow + only appears for something it can measure: not for constraint badges or spaces alone. */
405
403
  const DRAW_TOOLS = new Set(['line', 'freehand', 'rect', 'circle', 'arc']);
406
404
  export function plusVisible() {
@@ -431,12 +429,8 @@ export function plusAction() {
431
429
  if (wallPlus())
432
430
  return;
433
431
  const s = selInfo(), sk = SK();
434
- if (s.dims.length === 1 && s.n === 1) {
435
- if (sk.cons[s.dims[0]]?.layer === 'print')
436
- toast('Print dimensions are labels only — drag to move them.', { warn: true });
437
- else
438
- editDim(s.dims[0]);
439
- }
432
+ if (s.dims.length === 1 && s.n === 1)
433
+ editDim(s.dims[0]);
440
434
  else if (s.odims.length === 1 && s.n === 1) {
441
435
  const [op, part] = s.odims[0].split('|');
442
436
  editOpeningDim(op, part);
@@ -446,14 +440,14 @@ export function plusAction() {
446
440
  else if (s.wfaces.length === 1 && s.n === 1) {
447
441
  const [id, side] = edgeOf(s.wfaces[0]);
448
442
  if (sk.lines[id])
449
- dimLine(id, false, side);
443
+ dimLine(id, side);
450
444
  else
451
445
  dimCurve(id, side);
452
446
  }
453
447
  else if (edgePair(s))
454
448
  applyCtxAction('edgeDist');
455
449
  else if (s.lines.length === 1 && s.n === 1)
456
- dimLine(s.lines[0], false);
450
+ dimLine(s.lines[0]);
457
451
  else if (s.curves.length === 1 && s.n === 1)
458
452
  dimCurve(s.curves[0]);
459
453
  else if (s.openings.length === 1 && s.n === 1)
@@ -469,7 +463,7 @@ export function plusAction() {
469
463
  else if (s.n === 2 && s.lines.length === 2)
470
464
  dimTwoLines(s.lines[0], s.lines[1]);
471
465
  else if (s.n === 2 && s.points.length === 2)
472
- dimPoints(s.points[0], s.points[1], false);
466
+ dimPoints(s.points[0], s.points[1]);
473
467
  else if (s.n === 2 && s.points.length === 1 && s.lines.length === 1)
474
468
  dimPointLine(s.points[0], s.lines[0]);
475
469
  else
@@ -499,6 +493,10 @@ export function deleteSelection() {
499
493
  afterEdit();
500
494
  toast(imgIds.size && !rest.length ? (imgIds.size > 1 ? 'Images removed' : 'Image removed') : 'Deleted', { action: { label: 'Undo', fn: () => undo() } });
501
495
  }
496
+ /** The picked edges of straight walls, as [wall, side] (a curved wall's edge has no line to stand for it). */
497
+ const straightEdges = (sk, s) => s.wfaces.map(edgeOf).filter(([id]) => !!sk.lines[id]);
498
+ /** The picked lines, and the walls of the picked straight edges. */
499
+ const withEdges = (sk, s) => [...s.lines, ...straightEdges(sk, s).map(([id]) => id)];
502
500
  function consFromSel(type) {
503
501
  const sk = SK(), s = selInfo(), L = s.lines.map(id => sk.lines[id]);
504
502
  switch (type) {
@@ -510,13 +508,28 @@ function consFromSel(type) {
510
508
  return { type, a: s.points[0], b: s.points[1] };
511
509
  return null;
512
510
  case 'par':
513
- case 'perp':
511
+ case 'perp': {
512
+ // (a wall's edge: its wall — the same thing, square to something)
513
+ const ls = withEdges(sk, s);
514
+ return ls.length === 2 && ls[0] !== ls[1] ? { type, l1: ls[0], l2: ls[1] } : null;
515
+ }
514
516
  case 'col':
515
- case 'eq':
516
517
  return s.lines.length === 2 ? { type, l1: s.lines[0], l2: s.lines[1] } : null;
518
+ case 'eq': {
519
+ // (a wall's edge: that face's length, corner to corner)
520
+ const ls = withEdges(sk, s), f = straightEdges(sk, s);
521
+ if (ls.length !== 2 || ls[0] === ls[1])
522
+ return null;
523
+ 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])) } : { type, l1: ls[0], l2: ls[1] };
524
+ }
517
525
  case 'mid':
518
- case 'online':
526
+ case 'online': {
527
+ // (a point and a wall's edge: on that face)
528
+ const f = straightEdges(sk, s);
529
+ if (type === 'online' && s.n === 2 && s.points.length === 1 && f.length === 1)
530
+ return { type, p: s.points[0], l: f[0][0], face: f[0][1] };
519
531
  return s.points.length === 1 && s.lines.length === 1 ? { type, p: s.points[0], l: s.lines[0] } : null;
532
+ }
520
533
  case 'oncircle':
521
534
  return s.points.length === 1 && s.curves.length === 1 ? { type, p: s.points[0], c: s.curves[0] } : null;
522
535
  case 'tan': {
@@ -540,9 +553,14 @@ const NEEDS = {
540
553
  h: 'a line or two points', v: 'a line or two points', par: 'two lines', perp: 'two lines', col: 'two lines', eq: 'two lines', mid: 'a point and a line',
541
554
  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',
542
555
  };
543
- /** H/V on each selected line, or parallel/collinear/equal from the first selected line to each of the others. */
556
+ /**
557
+ * H/V on each selected line, or parallel/collinear/equal from the first selected line to each of the others; three
558
+ * or more points, each level or plumb with the first picked.
559
+ */
544
560
  function multiLineCons(type) {
545
561
  const sk = SK(), s = selInfo();
562
+ if ((type === 'h' || type === 'v') && s.n === s.points.length && s.points.length >= 3)
563
+ return s.points.slice(1).map(id => ({ type, a: s.points[0], b: id }));
546
564
  if (s.n !== s.lines.length || s.lines.length < (type === 'h' || type === 'v' ? 2 : 3))
547
565
  return null;
548
566
  if (type === 'h' || type === 'v')
@@ -552,8 +570,11 @@ function multiLineCons(type) {
552
570
  return null;
553
571
  }
554
572
  const ADJ = { h: 'horizontal', v: 'vertical', par: 'parallel', col: 'collinear', eq: 'equal' };
555
- /** Apply one constraint type across several lines as one undo step. A conflict opens the conflict sheet for the first one that fails. */
556
- function applyMulti(type, list) {
573
+ /**
574
+ * Apply one constraint type across several lines (or `points`, each tied to the first) as one undo step. A conflict
575
+ * opens the conflict sheet for the first one that fails.
576
+ */
577
+ function applyMulti(type, list, points = false) {
557
578
  pushHistory();
558
579
  let added = 0;
559
580
  const failed = [];
@@ -572,9 +593,9 @@ function applyMulti(type, list) {
572
593
  }
573
594
  if (!added)
574
595
  dropHistory();
575
- // every selected line that now satisfies it (some may already have, through other constraints)
576
- const lines = (type === 'h' || type === 'v' ? list.length : list.length + 1) - failed.length;
577
- const nLines = lines + ' line' + (lines === 1 ? '' : 's');
596
+ // every selected line (or point) that now satisfies it (some may already have, through other constraints)
597
+ const lines = ((type === 'h' || type === 'v') && !points ? list.length : list.length + 1) - failed.length;
598
+ const nLines = lines + (points ? ' point' : ' line') + (lines === 1 ? '' : 's');
578
599
  if (failed.length) {
579
600
  // explain the first conflict; the sheet's Remove re-applies it
580
601
  tryAddConstraint(failed[0], { onResolved: () => afterEdit() });
@@ -585,10 +606,49 @@ function applyMulti(type, list) {
585
606
  toast(CONS_LABEL[type] + ' added to ' + nLines + '.');
586
607
  afterEdit();
587
608
  }
609
+ /** A line and a straight wall's edge picked: the line along that face, both its ends on it (Collinear, with a face). */
610
+ function alongFace() {
611
+ const sk = SK(), s = selInfo(), f = straightEdges(sk, s);
612
+ if (s.n !== 2 || s.lines.length !== 1 || f.length !== 1 || f[0][0] === s.lines[0])
613
+ return null;
614
+ const [l, face] = f[0], ln = sk.lines[s.lines[0]];
615
+ return [ln.a, ln.b].map(p => ({ type: 'online', p, l, face }));
616
+ }
617
+ /** 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. */
618
+ function applyAlong(list) {
619
+ pushHistory();
620
+ let added = 0;
621
+ const failed = [];
622
+ for (const c of list) {
623
+ const r = tryAddConstraint(c, { silent: true });
624
+ if (r.ok)
625
+ added++;
626
+ else if (r.conflict)
627
+ failed.push(c);
628
+ }
629
+ if (!added && !failed.length) {
630
+ dropHistory();
631
+ toast('It’s already along that face.', { warn: true });
632
+ afterEdit();
633
+ return;
634
+ }
635
+ if (!added)
636
+ dropHistory();
637
+ if (failed.length)
638
+ tryAddConstraint(failed[0], { onResolved: () => afterEdit() });
639
+ else
640
+ toast(CONS_LABEL.col + ' added');
641
+ afterEdit();
642
+ }
588
643
  function applyCons(type) {
589
- const multi = multiLineCons(type);
590
- if (multi && (multi.length > 1 || selInfo().lines.length > 2)) {
591
- applyMulti(type, multi);
644
+ const along = type === 'col' ? alongFace() : null;
645
+ if (along) {
646
+ applyAlong(along);
647
+ return;
648
+ }
649
+ const multi = multiLineCons(type), s = selInfo(), points = s.points.length > 2 && s.n === s.points.length;
650
+ if (multi && (multi.length > 1 || s.lines.length > 2 || points)) {
651
+ applyMulti(type, multi, points);
592
652
  return;
593
653
  }
594
654
  const c = consFromSel(type);
@@ -601,7 +661,7 @@ function applyCons(type) {
601
661
  if (!r.ok)
602
662
  dropHistory();
603
663
  else
604
- toast(CONS_LABEL[c.type] + ' added');
664
+ toast(consLabel(c) + ' added');
605
665
  afterEdit();
606
666
  }
607
667
  /** Run a bottom-bar action. `anchor` is the button pressed, for actions that open a popover next to it. */
@@ -654,12 +714,6 @@ export function applyCtxAction(act, anchor) {
654
714
  tools[ed.tool].finish();
655
715
  updateCtx();
656
716
  return;
657
- case 'dimDrive':
658
- ed.dimLayer = 'drive';
659
- break;
660
- case 'dimPrint':
661
- ed.dimLayer = 'print';
662
- break;
663
717
  case 'h':
664
718
  case 'v':
665
719
  case 'par':
@@ -676,15 +730,15 @@ export function applyCtxAction(act, anchor) {
676
730
  return;
677
731
  case 'len':
678
732
  if (s.lines.length === 1)
679
- dimLine(s.lines[0], false);
733
+ dimLine(s.lines[0]);
680
734
  else if (s.wfaces.length === 1 && sk.lines[edgeOf(s.wfaces[0])[0]]) {
681
735
  const [lid, side] = edgeOf(s.wfaces[0]);
682
- dimLine(lid, false, side);
736
+ dimLine(lid, side);
683
737
  }
684
738
  return;
685
739
  case 'pdist':
686
740
  if (s.points.length === 2)
687
- dimPoints(s.points[0], s.points[1], false);
741
+ dimPoints(s.points[0], s.points[1]);
688
742
  return;
689
743
  case 'pldist':
690
744
  if (s.points.length === 1 && s.lines.length === 1)
@@ -705,9 +759,17 @@ export function applyCtxAction(act, anchor) {
705
759
  cornerAngle(s.points[0]);
706
760
  return;
707
761
  case 'merge': {
762
+ // (every one into the first picked)
763
+ if (s.points.length < 2)
764
+ return;
708
765
  pushHistory();
709
766
  const snap = JSON.stringify(sk);
710
- if (joinPoints(sk, s.points[0], s.points[1])) {
767
+ if (!s.points.slice(1).every(id => joinPoints(sk, s.points[0], id))) {
768
+ replaceSK(JSON.parse(snap));
769
+ dropHistory();
770
+ toast('Two locked points can’t be joined. Unlock one first.', { warn: true });
771
+ }
772
+ else {
711
773
  const r = solveSketch();
712
774
  if (!r.ok) {
713
775
  replaceSK(JSON.parse(snap));
@@ -747,14 +809,22 @@ export function applyCtxAction(act, anchor) {
747
809
  requestDraw();
748
810
  break;
749
811
  }
750
- case 'constr':
812
+ case 'constr': {
813
+ // all guides, or all solid again if they all are — one setting for the whole selection, as To wall is (flipping
814
+ // each swapped a guide and a line picked together). A wall is always solid: never made a guide, and one that's a
815
+ // guide from before goes back to solid
816
+ const objs = [...s.lines.filter(id => !sk.lines[id].anchor).map(id => sk.lines[id]), ...s.curves.map(id => curveInfo(sk, id)?.o)]
817
+ .filter((o) => !!o);
818
+ const lines = objs.filter(o => o.kind !== 'wall'), walls = objs.filter(o => o.kind === 'wall' && o.constr);
819
+ if (!lines.length && !walls.length)
820
+ return;
821
+ const guide = !lines.every(o => o.constr);
751
822
  pushHistory();
752
- s.lines.forEach(id => { if (!sk.lines[id].anchor)
753
- sk.lines[id].constr = !sk.lines[id].constr; });
754
- s.curves.forEach(id => { const ci = curveInfo(sk, id); if (ci)
755
- ci.o.constr = !ci.o.constr; });
823
+ lines.forEach(o => { o.constr = guide; });
824
+ walls.forEach(o => { o.constr = false; });
756
825
  afterEdit();
757
826
  return;
827
+ }
758
828
  case 'kind': {
759
829
  // one setting for the whole selection: all walls, unless they all are already (then all lines)
760
830
  const targets = [...s.lines.map(id => sk.lines[id]), ...s.curves.map(id => curveInfo(sk, id)?.o).filter((o) => !!o)];
@@ -797,9 +867,9 @@ export function applyCtxAction(act, anchor) {
797
867
  return;
798
868
  }
799
869
  case 'thick':
800
- // straight and curved walls in the selection
870
+ // straight and curved walls in the selection (an edge's: its wall)
801
871
  thicknessPrompt([
802
- ...s.lines.map(id => sk.lines[id]).filter(l => l.kind === 'wall'),
872
+ ...withEdges(sk, s).map(id => sk.lines[id]).filter(l => l.kind === 'wall'),
803
873
  ...s.curves.map(id => curveInfo(sk, id)?.o).filter((o) => !!o && o.kind === 'wall'),
804
874
  ]);
805
875
  return;
@@ -824,10 +894,11 @@ export function applyCtxAction(act, anchor) {
824
894
  case 'renameRoom':
825
895
  editRoomName(s.rooms[0]);
826
896
  return;
897
+ // (the room is on the left of the wall's start → end when there's none either side of it)
827
898
  case 'opFlip': {
828
899
  pushHistory();
829
900
  const o = sk.openings[s.openings[0]];
830
- o.flip = !o.flip;
901
+ flipDoor(o, roomSideOf(sk, o.line) === -1 ? -1 : 1);
831
902
  afterEdit();
832
903
  return;
833
904
  }
@@ -876,12 +947,12 @@ export function applyCtxAction(act, anchor) {
876
947
  case 'palette':
877
948
  openPalette(anchor);
878
949
  return;
879
- case 'convert':
880
- convertNote(s.notes[0]);
881
- return;
882
950
  case 'linkOpen':
883
951
  openSelectedLink();
884
952
  return;
953
+ case 'linkExpand':
954
+ toggleExpand();
955
+ return;
885
956
  case 'linkSize':
886
957
  toggleTileSize(anchor);
887
958
  return;
@@ -916,6 +987,9 @@ export function applyCtxAction(act, anchor) {
916
987
  const lock = !ims.every(u => u.locked), many = ims.length > 1;
917
988
  pushHistory();
918
989
  ims.forEach(u => { u.locked = lock; });
990
+ // (locked, its bar is Lock and Open alone: an opacity or turning slider open over it goes with the rest)
991
+ if (lock)
992
+ dismissPopovers();
919
993
  afterEdit();
920
994
  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.')
921
995
  : many ? 'Images unlocked.' : 'Image unlocked. Drag it, or drag its handles to size it.');