@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
@@ -32,10 +32,18 @@ function setState(s: CadSaveState): void {
32
32
  notify();
33
33
  }
34
34
 
35
- /** Start manual saving for the drawing open in `sync`; `listener` hears each change of state. */
35
+ /**
36
+ * Start manual saving for the drawing open in `sync`; `listener` hears each change of state, and the first, 'saved',
37
+ * once the editor has mounted (announceSaveState). This runs as the editor first renders, and a host keeps the state in
38
+ * its own: React won't have one component set another's state while it renders.
39
+ */
36
40
  export function startManualSave(sync: CadSync, listener: ((s: CadSaveState) => void) | null): void {
37
41
  Object.assign(sv, { manual: true, state: 'saved', cleanSeq: ed.editSeq, sync, listener });
38
- listener?.('saved');
42
+ }
43
+
44
+ /** Tell the host the state as it is now (the editor, once it has mounted). */
45
+ export function announceSaveState(): void {
46
+ if (sv.manual) sv.listener?.(sv.state);
39
47
  }
40
48
 
41
49
  export function stopManualSave(): void {
@@ -55,8 +55,11 @@ export const ui = sessionState(() => ({
55
55
  palette: false,
56
56
  /** what the tool just picked from the rail does (a note under the tabs that goes by itself); seq tells one pick from the next */
57
57
  toolTip: null as { text: string; seq: number } | null,
58
- /** the slider open above the bar: the selected image's opacity, or how far it's turned; or the link tiles' size */
59
- imgPop: null as null | 'opacity' | 'rotate' | 'tilesize',
58
+ /**
59
+ * what's open above the bar: the selected image's opacity or turn, or the link tiles' size (sliders), or Open's
60
+ * question (here, or side by side)
61
+ */
62
+ imgPop: null as null | 'opacity' | 'rotate' | 'tilesize' | 'open',
60
63
  /** the tool each shared place on the rail draws with (the last picked; editor/mode.ts TOOL_GROUPS), and the menu of
61
64
  a group's tools, open beside its place at x, y */
62
65
  picked: { shapes: 'line', marks: 'arrow' } as Record<string, string>,
@@ -1,8 +1,8 @@
1
1
  import { ed, isSel, type HitChip } from '../app/state';
2
2
  import { consPoints, curveDefined, isDim, lineDefined, measure } from '../model/constraints';
3
3
  import { hyp, norm2, normAng, projT } from '../model/geom';
4
- import { drivingDim, edgePoint, measureOpeningDim, refPoint, refS, widthEntered } from '../model/openings';
5
- import { arcAngles, curveFrame, dimEnds, dimRadius, edgeOf, gridSteps, OPENING_TICK_EXT, openingFrame, outsideSide, wallThick } from '../model/planGeom';
4
+ import { drivingDim, edgePoint, hingeEdge, measureOpeningDim, refPoint, refS, widthEntered } from '../model/openings';
5
+ import { angleCorner, angleLabel, arcAngles, curveFrame, dimEnds, dimRadius, edgeOf, gridSteps, OPENING_TICK_EXT, openingFrame, outsideSide, wallThick } from '../model/planGeom';
6
6
  import { faceCorner } from '../model/wallfaces';
7
7
  import { roomGeom } from '../model/rooms';
8
8
  import { wallPieces } from '../model/wallshape';
@@ -16,13 +16,15 @@ import { drawKeyPlan, drawWallBackdrop, drawWallChips, drawWallSelection, type E
16
16
  import type { Constraint, Page, Sketch, WallFace, XY } from '../model/types';
17
17
  import { fmtAng, fmtArea, fmtLen } from '../units';
18
18
  import { FONT, TH } from './theme';
19
- import { cvs, imgCache, keyPlanSize, mainT, nextThumbFrame, noteFont, noteHandles, noteTextBox, S, sketchBounds, underlayH, underlayHandles, underlayMid, type Xform } from './view';
19
+ import { cvs, imageLinkBadge, imgCache, keyPlanSize, mainT, nextThumbFrame, noteFont, noteHandles, noteTextBox, S, sketchBounds, underlayH, underlayHandles, underlayMid, underlaySrc, type Xform } from './view';
20
20
  import type { DrawCtx } from './ctx';
21
21
  import { dashFor, isSegment, NOTE_TEXT_SIZE, noteLook, shapeOutline } from '../model/markup';
22
22
  import { strokeMarkup, strokeOutline } from './markup';
23
23
  import { drawLink } from './linkTile';
24
24
  import { isLink, linkInfoOf } from '../model/links';
25
25
  import { smoothRectPath } from './squircle';
26
+ import { LINK_SVG } from './figmaIcons';
27
+ import { drawSvgIcon, svgIcon } from './svgPath';
26
28
 
27
29
  type C2D = DrawCtx;
28
30
 
@@ -41,7 +43,10 @@ export function rr(c: C2D, x: number, y: number, w: number, h: number, r: number
41
43
  export interface ChipOpts {
42
44
  bg: string; fg: string; border?: [string, number?] | null; font?: string; pad?: number; h?: number; minW?: number;
43
45
  kind?: HitChip['kind']; id?: string; rot?: number;
44
- /** a link mark before the text (a value set elsewhere: another view, or a measurement of the job) */
46
+ /**
47
+ * 'link': a measurement of the job sets it. The chip is then Figma's linked measurement (2795:77149): a white badge
48
+ * with the link icon before the value (LINKED), at the chip's own height and font, so it's the size of a plain chip.
49
+ */
45
50
  mark?: 'link';
46
51
  }
47
52
 
@@ -52,30 +57,46 @@ export function linkBlue(): string {
52
57
  return dark ? TH.lblue : TH.blue;
53
58
  }
54
59
 
55
- /** A small chain-link mark centred on (x, y). */
56
- export function linkMark(c: C2D, x: number, y: number, color: string): void {
57
- c.save();
58
- c.translate(x, y); c.rotate(-Math.PI / 4);
59
- c.strokeStyle = color; c.lineWidth = 1.4;
60
- c.beginPath(); c.roundRect(-6.5, -2.25, 7.5, 4.5, 2.25); c.roundRect(-1, -2.25, 7.5, 4.5, 2.25); c.stroke();
61
- c.restore();
60
+ /** Figma's link icon (render/figmaIcons.ts), read once. */
61
+ const LINK_ICON = svgIcon(LINK_SVG);
62
+
63
+ /** The link icon, `size` across, centred on (x, y): in its own dark grey, or `color`. */
64
+ export function linkIcon(c: C2D, x: number, y: number, size: number, color?: string): void {
65
+ drawSvgIcon(c, LINK_ICON, 'link', x - size / 2, y - size / 2, size, color);
62
66
  }
63
67
 
64
- /** What a link mark takes before a chip's text. */
65
- const MARK_W = 14;
68
+ /**
69
+ * A linked measurement's badge, as the frame draws it on its 30 px chip (Figma 2795:77149): 5 in from the chip's edges,
70
+ * 20 across with the 12 px link icon in its middle, 10 before the value, and the value 5 from the chip's end. A linked
71
+ * chip is the size of a plain one (the user's call, 2026-10-25: "the same size as the regular measurement tiles"), so
72
+ * the badge is drawn at the chip's own height: all of it scaled by h / 30.
73
+ */
74
+ const LINKED = { h: 30, pad: 5, badge: 20, badgeR: 3.826, icon: 12, gap: 10 } as const;
66
75
 
67
- /** Rounded label on the canvas; registers a hit region when `kind` is given. */
68
- export function chip(c: C2D, text: string, cx: number, cy: number, { bg, fg, border, font = '600 12px', pad = 7, h = 22, minW = 0, kind, id, rot = 0, mark }: ChipOpts): number {
76
+ /** The width and height chip() draws `text` at (and its font, set on `c`). */
77
+ export function chipSize(c: C2D, text: string, { font = '600 12px', pad = 7, h = 22, minW = 0, mark }: ChipOpts): { w: number; h: number } {
69
78
  c.font = font + ' ' + FONT;
70
- const mw = mark ? MARK_W : 0, w = Math.max(minW, c.measureText(text).width + pad * 2 + mw);
71
- const x = cx - w / 2, y = cy - h / 2;
79
+ const room = mark ? (LINKED.pad * 2 + LINKED.badge + LINKED.gap) * (h / LINKED.h) : pad * 2;
80
+ return { w: Math.max(minW, c.measureText(text).width + room), h };
81
+ }
82
+
83
+ /** Rounded label on the canvas; registers a hit region when `kind` is given. */
84
+ export function chip(c: C2D, text: string, cx: number, cy: number, opts: ChipOpts): number {
85
+ const { bg, fg, border, kind, id, rot = 0, mark } = opts;
86
+ const { w, h } = chipSize(c, text, opts), x = cx - w / 2, y = cy - h / 2;
72
87
  c.save();
73
88
  if (rot) { c.translate(cx, cy); c.rotate(rot); c.translate(-cx, -cy); }
74
89
  rr(c, x, y, w, h, 5);
75
90
  c.fillStyle = bg; c.fill();
76
91
  if (border) { c.lineWidth = border[1] || 1; c.strokeStyle = border[0]; c.stroke(); }
77
- c.fillStyle = fg; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText(text, cx + mw / 2, cy + 0.5);
78
- if (mark) linkMark(c, x + pad + 4.5, cy, fg);
92
+ c.fillStyle = fg; c.textAlign = 'center'; c.textBaseline = 'middle';
93
+ if (mark) {
94
+ const k = h / LINKED.h, inset = LINKED.pad * k, bw = LINKED.badge * k, bx = x + inset, tx = bx + bw + LINKED.gap * k;
95
+ c.fillText(text, (tx + x + w - inset) / 2, cy + 0.5);
96
+ rr(c, bx, cy - bw / 2, bw, bw, LINKED.badgeR * k);
97
+ c.fillStyle = '#FFFFFF'; c.fill();
98
+ linkIcon(c, bx + bw / 2, cy, LINKED.icon * k);
99
+ } else c.fillText(text, cx, cy + 0.5);
79
100
  c.restore();
80
101
  if (kind) {
81
102
  const ext = rot ? Math.max(w, h) / 2 : 0;
@@ -105,14 +126,52 @@ function drawGrid(c: C2D, T: Xform, m = 0): void {
105
126
  function drawUnderlay(c: C2D, T: Xform): void {
106
127
  if (!settings.layers.underlay) return;
107
128
  for (const u of page().underlays) {
108
- const im = imgCache(u.src);
109
- if (!im || !im.complete || !im.naturalWidth) continue;
129
+ const im = imgCache(underlaySrc(u)), ready = !!im && im.complete && !!im.naturalWidth;
130
+ // (an expanded photo whose picture hasn't come yet, or is gone: where it is, as its tile's card)
131
+ if (!ready && !u.link) continue;
110
132
  // drawn about its middle, turned as it's set (clockwise as seen: the canvas turns that way)
111
133
  const h = underlayH(u), m = underlayMid(u), [x, y] = S(T, m.x, m.y), w = u.w * T.s, hh = h * T.s;
112
134
  c.save(); c.globalAlpha = u.opacity ?? 0.6;
113
135
  c.translate(x, y);
114
136
  if (u.rot) c.rotate((u.rot * Math.PI) / 180);
115
- c.drawImage(im, -w / 2, -hh / 2, w, hh);
137
+ if (ready) {
138
+ // cropped: the picture whole, past the part that shows, clipped to it
139
+ const k = u.crop;
140
+ if (k) { c.beginPath(); c.rect(-w / 2, -hh / 2, w, hh); c.clip(); }
141
+ const fw = k ? w / (1 - k.l - k.r) : w, fh = k ? hh / (1 - k.t - k.b) : hh;
142
+ c.drawImage(im!, -w / 2 - (k ? k.l * fw : 0), -hh / 2 - (k ? k.t * fh : 0), fw, fh);
143
+ } else {
144
+ c.fillStyle = '#2B2C2E'; c.fillRect(-w / 2, -hh / 2, w, hh);
145
+ if (linkInfoOf(u.link!)?.exists === false) {
146
+ c.font = '500 13px ' + FONT; c.fillStyle = '#9FA0A2'; c.textAlign = 'center'; c.textBaseline = 'middle';
147
+ c.fillText('Removed', 0, 0);
148
+ }
149
+ }
150
+ c.restore();
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Each expanded photo's badge: its link icon in a white disc in its top-right corner (imageLinkBadge), over the drawing
156
+ * — always, as Skia's, selected or not, locked or not: it says the photo is the job's, and a tap on it opens it.
157
+ * `selected`: the selected photos' badges, drawn with their outlines and handles (paintOverlay), or the rest, with the
158
+ * drawing. A device keeps the drawing as a picture while a photo is moved, sized, cropped or turned, and draws only
159
+ * the photos and the moving parts again: a badge kept with the drawing stayed where the photo had been.
160
+ */
161
+ function drawImageLinks(c: C2D, T: Xform, selected: boolean): void {
162
+ if (!settings.layers.underlay) return;
163
+ const { W, H } = cvs;
164
+ for (const u of page().underlays) {
165
+ if (isSel('underlay', u.id) !== selected) continue;
166
+ const b = imageLinkBadge(T, u);
167
+ if (!b || b.x < -b.r || b.y < -b.r || b.x > W + b.r || b.y > H + b.r) continue;
168
+ const r = b.r, disc = (cc: C2D, X: number, Y: number) => { cc.beginPath(); cc.arc(X + r, Y + r, r, 0, Math.PI * 2); };
169
+ c.save();
170
+ if (ed.mode === 'draft') c.globalAlpha = 0.75;
171
+ if (c.shape) c.shape('image link ' + r, b.x - r, b.y - r, disc); else disc(c, b.x - r, b.y - r);
172
+ c.fillStyle = '#FFFFFF'; c.fill();
173
+ c.lineWidth = 1; c.strokeStyle = 'rgba(24,26,28,.2)'; c.stroke();
174
+ linkIcon(c, b.x, b.y, r * 1.2, '#181A1C');
116
175
  c.restore();
117
176
  }
118
177
  }
@@ -131,18 +190,40 @@ function drawRooms(c: C2D, T: Xform, sk: Sketch, thumb?: boolean): void {
131
190
  c.fill();
132
191
  if (thumb) continue;
133
192
  const [cx, cy] = S(T, g.c.x, g.c.y);
134
- c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillStyle = TH.label;
193
+ // (its area and perimeter once the drawing has its scale: before, only placeholders — just its name)
194
+ const nm = r.name, info = sk.unscaled ? '' : fmtArea(g.area) + ' · ' + fmtLen(g.per) + ' perim.';
135
195
  c.font = '600 14px ' + FONT;
136
- const nm = r.name;
137
- c.fillText(nm, cx, cy - 9);
138
- c.font = '500 12px ' + FONT; c.fillStyle = TH.ink2;
139
- c.fillText(fmtArea(g.area) + ' · ' + fmtLen(g.per) + ' perim.', cx, cy + 9);
196
+ const nw = c.measureText(nm).width;
197
+ c.font = '500 12px ' + FONT;
198
+ const iw = info ? c.measureText(info).width : 0;
199
+ const show = spaceLabel(T, g.inner, nw, iw);
200
+ if (!show.name) continue;
201
+ c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillStyle = TH.label;
140
202
  c.font = '600 14px ' + FONT;
141
- const w = Math.max(c.measureText(nm).width, 140);
203
+ c.fillText(nm, cx, show.info ? cy - 9 : cy);
204
+ if (show.info) { c.font = '500 12px ' + FONT; c.fillStyle = TH.ink2; c.fillText(info, cx, cy + 9); }
205
+ const w = Math.max(nw, 140);
142
206
  addHit({ kind: 'room', id, x: cx - w / 2, y: cy - 20, w, h: 40 });
143
207
  }
144
208
  }
145
209
 
210
+ /** Room left round a space's label on the screen, each side, points. */
211
+ const SPACE_LABEL_PAD = 8;
212
+
213
+ /**
214
+ * Which of a space's label shows: only what fits inside the space on the screen, so zoomed out it goes rather than
215
+ * cluttering the drawing (the user's call, 2026-10-29, as a tile's name goes: LINK_FULL_PX) — its area and perimeter
216
+ * first, then its name. `nameW`, `infoW`: their widths (no area line: 0).
217
+ */
218
+ export function spaceLabel(T: Xform, inner: XY[], nameW: number, infoW: number): { name: boolean; info: boolean } {
219
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
220
+ for (const p of inner) { x0 = Math.min(x0, p.x); x1 = Math.max(x1, p.x); y0 = Math.min(y0, p.y); y1 = Math.max(y1, p.y); }
221
+ const w = (x1 - x0) * T.s, h = (y1 - y0) * T.s, pad = 2 * SPACE_LABEL_PAD;
222
+ // (one line of 14 pt; two, 18 apart, of 14 and 12)
223
+ const name = w >= nameW + pad && h >= 18 + pad;
224
+ return { name, info: name && infoW > 0 && w >= infoW + pad && h >= 36 + pad };
225
+ }
226
+
146
227
  interface CurveWall { id: string; kind: 'circle' | 'arc'; thick: number; constr?: boolean }
147
228
 
148
229
  function curveWalls(sk: Sketch): CurveWall[] {
@@ -333,11 +414,13 @@ function drawOpenings(c: C2D, T: Xform, sk: Sketch): void {
333
414
  for (const k of [t / 6, -t / 6]) { const a = P(s0, k), b = P(s1, k); c.moveTo(a[0], a[1]); c.lineTo(b[0], b[1]); }
334
415
  c.stroke();
335
416
  } else {
336
- const hinge = P(s0, (f * t) / 2), leaf = P(s0, f * (t / 2 + w));
417
+ // the leaf, open square to the wall from the jamb it hangs on, and its swing back to the other
418
+ const [hs, ls] = hingeEdge(o) === 's1' ? [s1, s0] : [s0, s1];
419
+ const hinge = P(hs, (f * t) / 2), leaf = P(hs, f * (t / 2 + w));
337
420
  c.beginPath(); c.moveTo(hinge[0], hinge[1]); c.lineTo(leaf[0], leaf[1]); c.stroke();
338
421
  const [hx, hy] = hinge, r = w * T.s;
339
422
  const a0 = Math.atan2(leaf[1] - hy, leaf[0] - hx);
340
- const end = P(s1, (f * t) / 2);
423
+ const end = P(ls, (f * t) / 2);
341
424
  const a1 = Math.atan2(end[1] - hy, end[0] - hx);
342
425
  c.beginPath(); c.setLineDash([4, 3]); c.lineWidth = 1;
343
426
  const ccw = normAng(a1 - a0) > Math.PI;
@@ -423,12 +506,25 @@ function drawLock(c: C2D, x: number, y: number): void {
423
506
  c.restore();
424
507
  }
425
508
 
509
+ /** The selected points and the selected lines' ends: what a constraint's icon still shows for with the icons off. */
510
+ function selPoints(sk: Sketch): Set<string> {
511
+ const out = new Set<string>();
512
+ for (const s of ed.sel) {
513
+ if (s.type === 'line') { const l = sk.lines[s.id]; if (l) { out.add(l.a); out.add(l.b); } }
514
+ if (s.type === 'point') out.add(s.id);
515
+ }
516
+ return out;
517
+ }
518
+
426
519
  /** Points: filled = fully defined, hollow = still free (shape cue paired with color). */
427
520
  function drawPoints(c: C2D, T: Xform, sk: Sketch): void {
428
521
  const { W, H } = cvs;
429
522
  const touch = ed.lastPointerTouch;
430
523
  // a cabinet's corners aren't grab points: it moves as one, like a door or window
431
524
  const cabCorners = new Set(Object.values((sk as WallFace).cabs ?? {}).flatMap(cabPoints));
525
+ // a locked point's padlock is a constraint icon: with the icons off, it shows only as the badges do, on what's
526
+ // selected (the user's call, 2026-10-26)
527
+ const locks = settings.showCons ? null : selPoints(sk);
432
528
  for (const id in sk.points) {
433
529
  const p = sk.points[id];
434
530
  const [x, y] = S(T, p.x, p.y);
@@ -447,47 +543,31 @@ function drawPoints(c: C2D, T: Xform, sk: Sketch): void {
447
543
  if (selected) { c.fillStyle = TH.yellow; c.fill(); c.lineWidth = 1.5; c.strokeStyle = TH.ink; c.stroke(); }
448
544
  else if (det) { c.fillStyle = ed.highlight.has(id) ? TH.red : p.origin ? TH.ink : inkForPoint(sk, id); c.fill(); }
449
545
  else { c.fillStyle = TH.bg; c.fill(); c.lineWidth = 1.8; c.strokeStyle = TH.ink; c.stroke(); }
450
- if (p.fixed && !p.origin && !p.anchor) drawLock(c, x + 9, y - 14);
546
+ if (p.fixed && !p.origin && !p.anchor && (!locks || locks.has(id) || ed.highlight.has(id))) drawLock(c, x + 9, y - 14);
451
547
  }
452
548
  }
453
549
 
454
550
  type DimAnchor =
455
551
  | { m: XY; a: XY; b: XY; nx?: number; ny?: number; L?: number }
456
552
  | { m: XY; o: XY; r: number }
457
- | { vtx: XY; bis: [number, number]; u1?: [number, number]; u2?: [number, number] };
553
+ | { vtx: XY; bis: [number, number]; u1?: [number, number]; u2?: [number, number]; len1?: number; len2?: number };
458
554
 
459
555
  function dimAnchor(sk: Sketch, c: Constraint): DimAnchor | null {
460
- const P = (id: string) => sk.points[id];
461
- if (c.type === 'dist') {
462
- // (measured on a wall's face: from its corner to corner, the label off the face on that side)
556
+ if (c.type === 'dist' || c.type === 'pldist') {
557
+ // (a length measured on a wall's face: from its corner to corner, the label off the face on that side. Between
558
+ // parallel lines or walls: from face to face, slid along them.)
463
559
  const e = dimEnds(sk, c);
464
560
  if (!e) return null;
465
561
  const { a, b } = e;
466
562
  const dx = b.x - a.x, dy = b.y - a.y, L = hyp(dx, dy) || 1e-9, nx = -dy / L, ny = dx / L, off = c.off || 0;
467
563
  return { m: { x: (a.x + b.x) / 2 + nx * off, y: (a.y + b.y) / 2 + ny * off }, a, b, nx, ny, L };
468
564
  }
469
- if (c.type === 'pldist') {
470
- // (between walls' faces: from face to face)
471
- const e = dimEnds(sk, c);
472
- if (!e) return null;
473
- return { m: { x: (e.a.x + e.b.x) / 2, y: (e.a.y + e.b.y) / 2 }, a: e.a, b: e.b };
474
- }
475
565
  if (c.type === 'diam') {
476
566
  // (across a curved wall's face: its arrows on that face)
477
567
  const g = dimRadius(sk, c);
478
568
  return g ? { m: { x: g.o.x, y: g.o.y }, o: g.o, r: g.r } : null;
479
569
  }
480
- if (c.type === 'angle') {
481
- const l1 = sk.lines[c.l1!], l2 = sk.lines[c.l2!];
482
- const sh = [l1.a, l1.b].find(p => p === l2.a || p === l2.b);
483
- if (sh) {
484
- const s = P(sh), o1 = P(l1.a === sh ? l1.b : l1.a), o2 = P(l2.a === sh ? l2.b : l2.a);
485
- const u1 = norm2(o1.x - s.x, o1.y - s.y), u2 = norm2(o2.x - s.x, o2.y - s.y);
486
- return { vtx: s, bis: norm2(u1[0] + u2[0], u1[1] + u2[1]), u1, u2 };
487
- }
488
- const a = P(l1.a), b = P(l1.b);
489
- return { vtx: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, bis: [0, 1] };
490
- }
570
+ if (c.type === 'angle') return angleCorner(sk, c);
491
571
  return null;
492
572
  }
493
573
 
@@ -509,35 +589,50 @@ function drawDims(c: C2D, T: Xform, sk: Sketch): void {
509
589
  if (!settings.layers.dims) return;
510
590
  for (const id in sk.cons) {
511
591
  const d = sk.cons[id];
512
- if (!isDim(d) || d.layer === 'print' || d.elev) continue; // the wall view's own dimensions are its chips
592
+ if (!isDim(d) || d.elev) continue; // the wall view's own dimensions are its chips
513
593
  const A = dimAnchor(sk, d);
514
594
  if (!A) continue;
515
595
  const ref = d.driving === false, val = ref ? measure(sk, d) : d.value!;
516
596
  const selected = isSel('dim', id) || isSel('cons', id);
517
597
  const bad = ed.highlight.has(id) || ed.ana.bad.has(id);
518
598
  if (ref && !bad && !dimLive(sk, id, d)) continue;
519
- // a measurement of the job sets it: outlined, with a link mark — red once the job's measurement is gone
599
+ // a measurement of the job sets it: the link badge before its value (chip()), on the chip's own colour — red once
600
+ // the job's measurement is gone
520
601
  const linked = !!d.measurementRef && !ref && !bad, gone = linked && linkInfoOf(d.measurementRef!)?.exists === false;
521
602
  let bg = ref ? TH.ref : TH.blue, fg = ref ? TH.reftext : '#fff';
522
- if (bad) { bg = TH.red; fg = '#fff'; }
523
- if (linked) { bg = TH.bg; fg = gone ? TH.red : d.type === 'angle' ? TH.purple : linkBlue(); }
524
- const border: [string, number] | null = selected ? [TH.yellow, 2.5] : linked ? [fg, 1.5] : null;
603
+ if (bad || gone) { bg = TH.red; fg = '#fff'; }
604
+ const border: [string, number] | null = selected ? [TH.yellow, 2.5] : null;
525
605
  const mark = linked ? 'link' as const : undefined;
526
606
  const wrap = (s: string) => (ref ? '(' : '') + s + (ref ? ')' : '');
527
607
  if (d.type === 'angle' && 'vtx' in A) {
528
608
  const [vx, vy] = S(T, A.vtx.x, A.vtx.y);
609
+ // pulled off its corner: its arc out where the label is, the label riding on it; else a little arc at the
610
+ // corner, the label out along the bisector
611
+ const moved = angleLabel(sk, d), R = moved ? moved.r * T.s : 22;
529
612
  if (A.u1 && A.u2) {
530
613
  // arc between the two lines, so the angle reads as belonging to that corner
531
614
  const t1 = Math.atan2(-A.u1[1], A.u1[0]), t2 = Math.atan2(-A.u2[1], A.u2[0]);
532
615
  let sweep = t2 - t1;
533
616
  while (sweep > Math.PI) sweep -= 2 * Math.PI;
534
617
  while (sweep < -Math.PI) sweep += 2 * Math.PI;
535
- c.save(); c.strokeStyle = bad ? TH.red : ref ? TH.chipline : TH.purple; c.lineWidth = 1.2;
618
+ // (blue, as every other dimension is — the user's call, 2026-10-29; purple is the inference guides')
619
+ c.save(); c.strokeStyle = bad ? TH.red : ref ? TH.chipline : TH.blue; c.lineWidth = 1.2;
536
620
  if (ref) c.setLineDash([3, 3]);
537
- c.beginPath(); c.arc(vx, vy, 22, t1, t1 + sweep, sweep < 0); c.stroke(); c.restore();
621
+ c.beginPath(); c.arc(vx, vy, R, t1, t1 + sweep, sweep < 0); c.stroke();
622
+ // (pulled out past where a line ends: an extension line along it, out to the arc)
623
+ c.lineWidth = 1;
624
+ for (const [u, len] of [[A.u1, A.len1], [A.u2, A.len2]] as const) {
625
+ const from = (len ?? 0) * T.s;
626
+ if (moved && R > from + 2) { c.beginPath(); c.moveTo(vx + u[0] * from, vy - u[1] * from); c.lineTo(vx + u[0] * (R + 4), vy - u[1] * (R + 4)); c.stroke(); }
627
+ }
628
+ c.restore();
538
629
  }
539
- chip(c, wrap(fmtAng(val)), vx + A.bis[0] * 50, vy - A.bis[1] * 50, linked ? { bg, fg, border, kind: 'dim', id, mark }
540
- : { bg: bad ? TH.red : ref ? TH.ref : TH.purple, fg: ref ? TH.reftext : '#fff', border, kind: 'dim', id });
630
+ const text = wrap(fmtAng(val)), opts: ChipOpts = { bg, fg, border, kind: 'dim', id, mark };
631
+ // (left where it was drawn: 50 px out along the bisector, or as far as its corners stay clear of the corner — a
632
+ // linked chip is wider)
633
+ const sz = chipSize(c, text, opts), out = Math.max(50, Math.hypot(sz.w, sz.h) / 2 + 28);
634
+ const [lx, ly] = moved ? [vx + Math.cos(moved.ang) * R, vy - Math.sin(moved.ang) * R] : [vx + A.bis[0] * out, vy - A.bis[1] * out];
635
+ chip(c, text, lx, ly, opts);
541
636
  continue;
542
637
  }
543
638
  if (d.type === 'diam' && 'o' in A) {
@@ -547,7 +642,7 @@ function drawDims(c: C2D, T: Xform, sk: Sketch): void {
547
642
  if (!('a' in A)) continue;
548
643
  const [mx, my] = S(T, A.m.x, A.m.y);
549
644
  const lineColor = bad ? TH.red : ref ? TH.chipline : TH.blue;
550
- if (d.type === 'dist' && A.nx != null && Math.abs((d.off || 0) * T.s) > 10) {
645
+ if ((d.type === 'dist' || d.type === 'pldist') && A.nx != null && Math.abs((d.off || 0) * T.s) > 10) {
551
646
  drawDimLeaders(c, T, A.a, A.b, A.nx, A.ny!, d.off!, lineColor, ref);
552
647
  } else if (d.type === 'pldist' || !d.line) {
553
648
  // (dashed only when it's a reference: a dashed line means the number holds nothing, wherever it's drawn)
@@ -720,7 +815,8 @@ export function drawRubberBand(c: C2D, T: Xform, from: XY, to: XY, snapped: bool
720
815
  if (snapped) endRing(c, x2, y2, TH.blue);
721
816
  c.restore();
722
817
  const L = hyp(to.x - from.x, to.y - from.y);
723
- if (L * T.s > 24) chip(c, fmtLen(L), (x1 + x2) / 2, (y1 + y2) / 2 - 16, { bg: '#fff', fg: TH.blue, border: [TH.blue, 1.5] });
818
+ // (no distance on a drawing without its scale yet: it's only a placeholder)
819
+ if (L * T.s > 24 && !planSK().unscaled) chip(c, fmtLen(L), (x1 + x2) / 2, (y1 + y2) / 2 - 16, { bg: '#fff', fg: TH.blue, border: [TH.blue, 1.5] });
724
820
  }
725
821
 
726
822
  /**
@@ -751,7 +847,8 @@ export function drawPendingDim(c: C2D, T: Xform, p: { from: XY; to: XY; a: XY; b
751
847
  for (const q of [p.from, p.to]) { const [x, y] = S(T, q.x, q.y); endRing(c, x, y, TH.yellow); }
752
848
  c.restore();
753
849
  const [mx, my] = S(T, (p.a.x + p.b.x) / 2 + p.nx * p.off, (p.a.y + p.b.y) / 2 + p.ny * p.off);
754
- chip(c, p.label, mx, my - (Math.abs(p.off * T.s) > 1 ? 0 : 16), { bg: TH.blue, fg: '#fff', border: [TH.yellow, 2] });
850
+ // (no label until one's typed, on a page whose sizes are still placeholders)
851
+ if (p.label) chip(c, p.label, mx, my - (Math.abs(p.off * T.s) > 1 ? 0 : 16), { bg: TH.blue, fg: '#fff', border: [TH.yellow, 2] });
755
852
  }
756
853
 
757
854
  /**
@@ -800,54 +897,10 @@ function drawDiameter(c: C2D, T: Xform, o: XY, r: number, d: Constraint, text: s
800
897
  arrowHead(c, p1, sx, sy);
801
898
  if (!d.rad) arrowHead(c, p2, -sx, -sy);
802
899
  c.restore();
803
- c.font = (chipOpts.font || '600 12px') + ' ' + FONT;
804
- const w = c.measureText(text).width + 14 + (chipOpts.mark ? MARK_W : 0);
900
+ const { w } = chipSize(c, text, chipOpts);
805
901
  chip(c, text, end[0] + side * (w / 2 + 2), end[1], chipOpts);
806
902
  }
807
903
 
808
- /** Architectural dimension strings on the Print dimensions layer (reference-only). */
809
- function drawPrintDims(c: C2D, T: Xform, sk: Sketch): void {
810
- if (!settings.layers.print) return;
811
- for (const id in sk.cons) {
812
- const d = sk.cons[id];
813
- if (d.layer !== 'print' || d.type !== 'dist') continue;
814
- const a = sk.points[d.a!], b = sk.points[d.b!];
815
- if (!a || !b) continue;
816
- const dx = b.x - a.x, dy = b.y - a.y, L = hyp(dx, dy);
817
- if (L < 1e-6) continue;
818
- const nx = -dy / L, ny = dx / L, off = d.off || 24;
819
- const pa = { x: a.x + nx * off, y: a.y + ny * off }, pb = { x: b.x + nx * off, y: b.y + ny * off };
820
- const [ax, ay] = S(T, a.x, a.y), [bx, by] = S(T, b.x, b.y), [qax, qay] = S(T, pa.x, pa.y), [qbx, qby] = S(T, pb.x, pb.y);
821
- const sgn = Math.sign(off) || 1, ex = nx * sgn * 6, ey = -ny * sgn * 6;
822
- const selected = isSel('dim', id);
823
- c.strokeStyle = selected ? TH.yellow : TH.ink; c.lineWidth = 1;
824
- c.beginPath();
825
- const gap = ax - qax || ay - qay ? 4 : 0, ga = norm2(qax - ax, qay - ay);
826
- c.moveTo(ax + ga[0] * gap, ay + ga[1] * gap); c.lineTo(qax + ex, qay + ey);
827
- c.moveTo(bx + ga[0] * gap, by + ga[1] * gap); c.lineTo(qbx + ex, qby + ey);
828
- c.moveTo(qax, qay); c.lineTo(qbx, qby);
829
- c.stroke();
830
- const u = norm2(qbx - qax, qby - qay);
831
- c.lineWidth = 1.8; c.beginPath();
832
- for (const [x, y] of [[qax, qay], [qbx, qby]]) {
833
- const tx = ((u[0] - u[1]) * 5) / Math.SQRT2, ty = ((u[1] + u[0]) * 5) / Math.SQRT2;
834
- c.moveTo(x - tx, y - ty); c.lineTo(x + tx, y + ty);
835
- }
836
- c.stroke();
837
- let ang = Math.atan2(qby - qay, qbx - qax);
838
- if (ang > Math.PI / 2) ang -= Math.PI;
839
- if (ang < -Math.PI / 2) ang += Math.PI;
840
- const mx = (qax + qbx) / 2, my = (qay + qby) / 2, txt = fmtLen(L);
841
- c.save(); c.translate(mx, my); c.rotate(ang);
842
- c.font = '600 12px ' + FONT;
843
- const w = c.measureText(txt).width + 8;
844
- c.fillStyle = TH.bg; c.fillRect(-w / 2, -17, w, 14);
845
- c.fillStyle = selected ? TH.yellow : TH.ink; c.textAlign = 'center'; c.textBaseline = 'alphabetic'; c.fillText(txt, 0, -6);
846
- c.restore();
847
- addHit({ kind: 'pdim', id, x: mx - w / 2 - 4, y: my - 18, w: w + 8, h: 24 });
848
- }
849
- }
850
-
851
904
  /**
852
905
  * A badge's symbol drawn as lines rather than a character: ∥ ⊥ ∘ ◎ aren't in every typeface — on a device the
853
906
  * app's own (Inter) lacks them and Skia has nothing to fall back on, so they came out as the font's "missing glyph"
@@ -914,11 +967,7 @@ function badgeSpots(T: Xform, sk: Sketch, c: Constraint): [number, number][] {
914
967
 
915
968
  function drawBadges(c: C2D, T: Xform, sk: Sketch): void {
916
969
  const { W, H } = cvs;
917
- const selIds = new Set(ed.sel.map(s => s.id)), selPts = new Set<string>();
918
- for (const s of ed.sel) {
919
- if (s.type === 'line') { const l = sk.lines[s.id]; if (l) { selPts.add(l.a); selPts.add(l.b); } }
920
- if (s.type === 'point') selPts.add(s.id);
921
- }
970
+ const selIds = new Set(ed.sel.map(s => s.id)), selPts = selPoints(sk);
922
971
  for (const id in sk.cons) {
923
972
  const k = sk.cons[id];
924
973
  if (isDim(k) || k.elev) continue;
@@ -1068,7 +1117,7 @@ function drawSnap(c: C2D, T: Xform): void {
1068
1117
  const [x, y] = S(T, s.x, s.y);
1069
1118
  if (s.kind !== 'free' && s.kind !== 'grid') {
1070
1119
  c.lineWidth = 2; c.beginPath();
1071
- if (s.kind === 'point') c.arc(x, y, 8, 0, Math.PI * 2);
1120
+ if (s.kind === 'point' || s.kind === 'facecorner') c.arc(x, y, 8, 0, Math.PI * 2);
1072
1121
  else if (s.kind === 'mid') { c.moveTo(x, y - 8); c.lineTo(x + 7, y + 5); c.lineTo(x - 7, y + 5); c.closePath(); }
1073
1122
  else if (s.kind === 'inter') { c.moveTo(x - 6, y - 6); c.lineTo(x + 6, y + 6); c.moveTo(x + 6, y - 6); c.lineTo(x - 6, y + 6); }
1074
1123
  else c.rect(x - 5, y - 5, 10, 10);
@@ -1162,7 +1211,6 @@ function paintPlanView(ctx: C2D, sk: Sketch, m: number, under = true): void {
1162
1211
  if (settings.layers.rooms) drawRooms(ctx, T, sk);
1163
1212
  if (settings.layers.cabinets) drawCabinetFootprints(ctx, T, sk);
1164
1213
  drawWalls(ctx, T, sk); drawOpenings(ctx, T, sk); drawLines(ctx, T, sk); drawCurves(ctx, T, sk);
1165
- drawPrintDims(ctx, T, sk);
1166
1214
  if (ed.mode === 'draft') drawBadges(ctx, T, sk);
1167
1215
  // (the drawing's points only in Draft: Annotate can't move them, and shouldn't look as if it could — the user's
1168
1216
  // call, 2026-10-21)
@@ -1170,6 +1218,8 @@ function paintPlanView(ctx: C2D, sk: Sketch, m: number, under = true): void {
1170
1218
  drawOpeningAnchors(ctx, T, sk);
1171
1219
  drawDims(ctx, T, sk);
1172
1220
  drawOpeningDims(ctx, T, sk);
1221
+ // (an expanded photo's badge over the drawing, under the markup: as it's tapped. A selected one's: paintOverlay)
1222
+ drawImageLinks(ctx, T, false);
1173
1223
  drawNotes(ctx, T, sk);
1174
1224
  drawNoteSelection(ctx, T);
1175
1225
  }
@@ -1190,7 +1240,6 @@ function paintWall(ctx: C2D, W: number, fixed: C2D): void {
1190
1240
  drawLines(ctx, T, sk); drawCurves(ctx, T, sk);
1191
1241
  // (over the edges — its own and its neighbours' — so a selected cabinet's outline shows all the way round)
1192
1242
  drawWallSelection(ctx, T, plan, f, sk);
1193
- drawPrintDims(ctx, T, sk);
1194
1243
  drawBadges(ctx, T, sk);
1195
1244
  if (ed.mode === 'draft') drawPoints(ctx, T, sk);
1196
1245
  drawDims(ctx, T, sk);
@@ -1218,7 +1267,9 @@ function drawSurveyCursor(ctx: C2D, T: Xform): void {
1218
1267
 
1219
1268
  function paintOverlay(ctx: C2D, W: number, H: number, fixed: C2D = ctx): void {
1220
1269
  const T = mainT();
1221
- // a selected image's outline and handles: with the moving parts, as it's turned, moved or sized (over the drawing)
1270
+ // a selected image's badge, outline and handles: with the moving parts, as it's turned, moved or sized (over the
1271
+ // drawing; the badge under the handles, as it was under them kept with the drawing). The wall view has no photos.
1272
+ if (!(elevOpen() && elev.frame)) drawImageLinks(ctx, T, true);
1222
1273
  drawUnderlaySelection(ctx, T);
1223
1274
  if (ed.preview) ed.preview(ctx, T);
1224
1275
  // (over the tool's preview — the templator's would-be edge runs into it — whatever tool is in hand)
@@ -5,7 +5,7 @@ import { cabPoints, cornerAnchor, emptyFace, openingAnchor } from '../model/face
5
5
  import { edgeS, openingSpan, pickBetween, refS, sameRef, widthEntered } from '../model/openings';
6
6
  import type { Constraint, OpeningDim, OpeningEdge, OpeningRef, Sketch, WallFace } from '../model/types';
7
7
  import { fmtLen } from '../units';
8
- import { chip, linkBlue, linkMark } from './draw';
8
+ import { chip, linkBlue, linkIcon } from './draw';
9
9
  import { FONT, TH } from './theme';
10
10
  import { S, type Xform } from './view';
11
11
  import type { DrawCtx } from './ctx';
@@ -35,7 +35,8 @@ export interface ElevHit { x: number; y: number; w: number; h: number; act: Elev
35
35
 
36
36
  /**
37
37
  * Chip look: locked blue, reference grey in parentheses, unlocked (just measured) neutral grey, red when it can't be met,
38
- * and 'linked': locked, but set in another view (the floor plan) — an outlined blue chip with a link mark, read-only here.
38
+ * and 'linked': locked, but set in another view (the floor plan) — an outlined blue chip with the link icon, read-only
39
+ * here. (Not a linked measurement's chip: that's one of the job's, set from outside the drawing — chip()'s `mark`.)
39
40
  */
40
41
  type Look = 'drive' | 'ref' | 'free' | 'bad' | 'linked';
41
42
  const LOOK = (look: Look) => look === 'drive' ? { bg: TH.blue, fg: '#fff', line: TH.blue } : look === 'bad' ? { bg: TH.red, fg: '#fff', line: TH.red }
@@ -75,7 +76,7 @@ function dim(c: C2D, p: [number, number], q: [number, number], nx: number, ny: n
75
76
  // a number that holds nothing (dashed) reads in parentheses, as in the plan
76
77
  const linked = look === 'linked', text = dashed ? '(' + label + ')' : linked ? '  ' + label : label; // (figure spaces: room for the link mark)
77
78
  const w = chip(c, text, cx, cy, { bg: st.bg, fg: st.fg, font: '600 11.5px', h, pad: 6, border: selected ? [TH.yellow, 2] : linked ? [st.line, 1.5] : null });
78
- if (linked) linkMark(c, cx - w / 2 + 11, cy, st.fg);
79
+ if (linked) linkIcon(c, cx - w / 2 + 11, cy, 10, st.fg);
79
80
  return { x: cx - w / 2 - 4, y: cy - h / 2 - 4, w: w + 8, h: h + 8 };
80
81
  }
81
82
 
@@ -433,7 +434,7 @@ export function drawWallChips(c: C2D, T: Xform, sk: Sketch, f: ElevFrame, face:
433
434
  }
434
435
  next += lostRows.length;
435
436
  // the wall's length comes from the floor plan: linked (outlined blue) when a dimension locks it there, grey when free
436
- const planLocked = Object.values(sk.cons).some(cn => cn.type === 'dist' && cn.line === f.lid && cn.driving !== false && cn.layer !== 'print');
437
+ const planLocked = Object.values(sk.cons).some(cn => cn.type === 'dist' && cn.line === f.lid && cn.driving !== false);
437
438
  const anyRow = rows.some(r => r.length) || next > 1;
438
439
  tag(S(T, f.face[0], 0), S(T, f.face[1], 0), 0, anyRow ? ROW0 + ROW * next : ROW0, fmtLen(f.face[1] - f.face[0]), planLocked ? 'linked' : 'free', { k: 'len' });
439
440
  // the wall's height: blue once entered, grey while it's the default
@@ -1,6 +1,7 @@
1
1
  /*
2
2
  Icons drawn in a Figma frame, kept exactly as Figma exports them (their own size, viewBox and colours) rather than
3
- redrawn on the rail's 24 grid: the Pages panel's Delete and + Page (Figma 2548:101989).
3
+ redrawn on the rail's 24 grid: the Pages panel's Delete and + Page (Figma 2548:101989), and the link icon on a
4
+ linked measurement's chip (Figma 2795:77149), which the canvas draws from its paths (render/svgPath.ts).
4
5
  */
5
6
 
6
7
  /** Delete page: a trash can, white, for the red pill. */
@@ -20,3 +21,18 @@ export const PLUS_SVG = `<svg preserveAspectRatio="none" overflow="visible" styl
20
21
  <path id="Vector" d="M9.25 0.125C9.58152 0.125 9.89937 0.256791 10.1338 0.491211C10.3682 0.725631 10.5 1.04348 10.5 1.375V8H17.125C17.4565 8 17.7744 8.13179 18.0088 8.36621C18.2432 8.60063 18.375 8.91848 18.375 9.25C18.375 9.58152 18.2432 9.89937 18.0088 10.1338C17.7744 10.3682 17.4565 10.5 17.125 10.5H10.5V17.125C10.5 17.4565 10.3682 17.7744 10.1338 18.0088C9.89937 18.2432 9.58152 18.375 9.25 18.375C8.91848 18.375 8.60063 18.2432 8.36621 18.0088C8.13179 17.7744 8 17.4565 8 17.125V10.5H1.375C1.04348 10.5 0.725631 10.3682 0.491211 10.1338C0.256791 9.89937 0.125 9.58152 0.125 9.25C0.125 8.91848 0.256791 8.60063 0.491211 8.36621C0.725631 8.13179 1.04348 8 1.375 8H8V1.375C8 1.04348 8.13179 0.725631 8.36621 0.491211C8.60063 0.256791 8.91848 0.125 9.25 0.125Z" fill="#181A1C" stroke="#181A1C" stroke-width="0.25"/>
21
22
  </svg>`;
22
23
  export const PLUS_SIZE = { width: 18.5, height: 18.5 };
24
+
25
+ /** Link: a chain link, dark grey, for the white badge on a linked measurement's chip. */
26
+ export const LINK_SVG = `<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
27
+ <g id="link-svgrepo-com (2) 1" clip-path="url(#clip0_0_4)">
28
+ <path id="Vector" d="M5.28769 1.15165C6.02508 0.414261 7.02519 0 8.06805 0C10.2396 0 12 1.76041 12 3.93198C12 4.97481 11.5858 5.97492 10.8484 6.71231L9.31065 8.25L8.25 7.18934L9.78772 5.65165C10.2438 5.19557 10.5 4.57698 10.5 3.93198C10.5 2.58884 9.41115 1.5 8.06805 1.5C7.42302 1.5 6.80444 1.75622 6.34835 2.21231L4.81066 3.75L3.75 2.68934L5.28769 1.15165Z" fill="#181A1C"/>
29
+ <path id="Vector_2" d="M5.65165 9.78772L7.18934 8.25L8.25 9.31065L6.71231 10.8484C5.97492 11.5858 4.97481 12 3.93198 12C1.76041 12 0 10.2396 0 8.06805C0 7.0252 0.41426 6.02508 1.15165 5.28769L2.68934 3.75L3.75 4.81066L2.21231 6.34835C1.75622 6.80444 1.5 7.42302 1.5 8.06805C1.5 9.41115 2.58884 10.5 3.93198 10.5C4.57698 10.5 5.19557 10.2438 5.65165 9.78772Z" fill="#181A1C"/>
30
+ <path id="Vector_3" d="M4.28039 8.78136L8.78038 4.28137L7.71973 3.2207L3.21973 7.72071L4.28039 8.78136Z" fill="#181A1C"/>
31
+ </g>
32
+ <defs>
33
+ <clipPath id="clip0_0_4">
34
+ <rect width="12" height="12" fill="white"/>
35
+ </clipPath>
36
+ </defs>
37
+ </svg>`;
38
+ export const LINK_SIZE = { width: 12, height: 12 };