@reekon-tools/boldr-cad 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) 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 +3 -5
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +149 -169
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +2 -2
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/linkActions.d.ts +22 -3
  23. package/dist/editor/linkActions.js +95 -13
  24. package/dist/editor/linkActions.js.map +1 -1
  25. package/dist/editor/mode.js +4 -3
  26. package/dist/editor/mode.js.map +1 -1
  27. package/dist/editor/project.js +1 -2
  28. package/dist/editor/project.js.map +1 -1
  29. package/dist/editor/scaleGate.d.ts +13 -0
  30. package/dist/editor/scaleGate.js +27 -0
  31. package/dist/editor/scaleGate.js.map +1 -0
  32. package/dist/editor/selection.js +13 -9
  33. package/dist/editor/selection.js.map +1 -1
  34. package/dist/editor/tooltips.js +9 -0
  35. package/dist/editor/tooltips.js.map +1 -1
  36. package/dist/editor/view3d.d.ts +4 -1
  37. package/dist/editor/view3d.js +6 -2
  38. package/dist/editor/view3d.js.map +1 -1
  39. package/dist/export/pdf.d.ts +5 -2
  40. package/dist/export/pdf.js +12 -8
  41. package/dist/export/pdf.js.map +1 -1
  42. package/dist/export/plan.d.ts +1 -1
  43. package/dist/export/plan.js +14 -9
  44. package/dist/export/plan.js.map +1 -1
  45. package/dist/model/demo.d.ts +1 -0
  46. package/dist/model/demo.js +2 -1
  47. package/dist/model/demo.js.map +1 -1
  48. package/dist/model/elevation.d.ts +1 -1
  49. package/dist/model/elevation.js +3 -3
  50. package/dist/model/elevation.js.map +1 -1
  51. package/dist/model/history.js +4 -1
  52. package/dist/model/history.js.map +1 -1
  53. package/dist/model/links.d.ts +4 -1
  54. package/dist/model/links.js +9 -2
  55. package/dist/model/links.js.map +1 -1
  56. package/dist/model/measurement.d.ts +2 -2
  57. package/dist/model/measurement.js +3 -3
  58. package/dist/model/measurement.js.map +1 -1
  59. package/dist/model/openings.d.ts +10 -0
  60. package/dist/model/openings.js +18 -0
  61. package/dist/model/openings.js.map +1 -1
  62. package/dist/model/planGeom.d.ts +37 -2
  63. package/dist/model/planGeom.js +72 -9
  64. package/dist/model/planGeom.js.map +1 -1
  65. package/dist/model/project.js +5 -2
  66. package/dist/model/project.js.map +1 -1
  67. package/dist/model/rooms.d.ts +18 -5
  68. package/dist/model/rooms.js +178 -73
  69. package/dist/model/rooms.js.map +1 -1
  70. package/dist/model/scale.d.ts +30 -0
  71. package/dist/model/scale.js +120 -0
  72. package/dist/model/scale.js.map +1 -0
  73. package/dist/model/settings.d.ts +0 -1
  74. package/dist/model/settings.js +1 -1
  75. package/dist/model/settings.js.map +1 -1
  76. package/dist/model/sketch.d.ts +21 -1
  77. package/dist/model/sketch.js +58 -4
  78. package/dist/model/sketch.js.map +1 -1
  79. package/dist/model/stroke.js +2 -2
  80. package/dist/model/types.d.ts +38 -7
  81. package/dist/react/BottomBar.d.ts +2 -1
  82. package/dist/react/BottomBar.js +94 -25
  83. package/dist/react/BottomBar.js.map +1 -1
  84. package/dist/react/Drawer.js +3 -2
  85. package/dist/react/Drawer.js.map +1 -1
  86. package/dist/react/Duck.js.map +1 -1
  87. package/dist/react/KeypadSheet.js.map +1 -1
  88. package/dist/react/MarkupPanel.js +1 -1
  89. package/dist/react/PlanCanvas.native.js +54 -7
  90. package/dist/react/PlanCanvas.native.js.map +1 -1
  91. package/dist/react/Popovers.js +14 -3
  92. package/dist/react/Popovers.js.map +1 -1
  93. package/dist/react/Rail.js +1 -1
  94. package/dist/react/Sheet.d.ts +2 -2
  95. package/dist/react/Sheet.js.map +1 -1
  96. package/dist/react/barScroll.d.ts +23 -0
  97. package/dist/react/barScroll.js +47 -0
  98. package/dist/react/barScroll.js.map +1 -0
  99. package/dist/react/corners.d.ts +6 -2
  100. package/dist/react/corners.js.map +1 -1
  101. package/dist/react/motion.d.ts +1 -1
  102. package/dist/react/motion.js +1 -1
  103. package/dist/react/motion.js.map +1 -1
  104. package/dist/render/draw.d.ts +21 -4
  105. package/dist/render/draw.js +184 -149
  106. package/dist/render/draw.js.map +1 -1
  107. package/dist/render/elevation.js +3 -3
  108. package/dist/render/elevation.js.map +1 -1
  109. package/dist/render/figmaIcons.d.ts +6 -0
  110. package/dist/render/figmaIcons.js +16 -1
  111. package/dist/render/figmaIcons.js.map +1 -1
  112. package/dist/render/hit.d.ts +8 -1
  113. package/dist/render/hit.js +23 -4
  114. package/dist/render/hit.js.map +1 -1
  115. package/dist/render/icons.js +27 -20
  116. package/dist/render/icons.js.map +1 -1
  117. package/dist/render/lens.d.ts +27 -0
  118. package/dist/render/lens.js +41 -0
  119. package/dist/render/lens.js.map +1 -0
  120. package/dist/render/linkTile.d.ts +20 -2
  121. package/dist/render/linkTile.js +476 -246
  122. package/dist/render/linkTile.js.map +1 -1
  123. package/dist/render/snap.d.ts +5 -1
  124. package/dist/render/snap.js +3 -1
  125. package/dist/render/snap.js.map +1 -1
  126. package/dist/render/svgPath.d.ts +29 -0
  127. package/dist/render/svgPath.js +114 -0
  128. package/dist/render/svgPath.js.map +1 -0
  129. package/dist/render/tileIcons.d.ts +18 -0
  130. package/dist/render/tileIcons.js +67 -0
  131. package/dist/render/tileIcons.js.map +1 -0
  132. package/dist/render/view.d.ts +18 -1
  133. package/dist/render/view.js +31 -4
  134. package/dist/render/view.js.map +1 -1
  135. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  136. package/dist/skia/SkiaDrawCtx.js +27 -5
  137. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  138. package/dist/solver/equations.d.ts +6 -0
  139. package/dist/solver/equations.js +23 -6
  140. package/dist/solver/equations.js.map +1 -1
  141. package/dist/solver/gauss-newton.js +6 -1
  142. package/dist/solver/gauss-newton.js.map +1 -1
  143. package/dist/tools/annotate.d.ts +0 -2
  144. package/dist/tools/annotate.js +1 -22
  145. package/dist/tools/annotate.js.map +1 -1
  146. package/dist/tools/chamfer.js +4 -0
  147. package/dist/tools/chamfer.js.map +1 -1
  148. package/dist/tools/dim.d.ts +3 -3
  149. package/dist/tools/dim.js +26 -65
  150. package/dist/tools/dim.js.map +1 -1
  151. package/dist/tools/fillet.js +4 -0
  152. package/dist/tools/fillet.js.map +1 -1
  153. package/dist/tools/freehand.js +1 -4
  154. package/dist/tools/freehand.js.map +1 -1
  155. package/dist/tools/line.js +15 -11
  156. package/dist/tools/line.js.map +1 -1
  157. package/dist/tools/modify.js +6 -1
  158. package/dist/tools/modify.js.map +1 -1
  159. package/dist/tools/registry.d.ts +5 -0
  160. package/dist/tools/registry.js +6 -1
  161. package/dist/tools/registry.js.map +1 -1
  162. package/dist/tools/select.d.ts +19 -0
  163. package/dist/tools/select.js +116 -17
  164. package/dist/tools/select.js.map +1 -1
  165. package/dist/tools/shapes.d.ts +1 -1
  166. package/dist/tools/shapes.js +12 -5
  167. package/dist/tools/shapes.js.map +1 -1
  168. package/dist/view3d/build.js +9 -8
  169. package/dist/view3d/build.js.map +1 -1
  170. package/package.json +9 -9
  171. package/src/app/edit.ts +25 -1
  172. package/src/app/state.ts +3 -5
  173. package/src/data/sync.ts +7 -2
  174. package/src/editor/actions.ts +126 -112
  175. package/src/editor/elevation.ts +13 -16
  176. package/src/editor/gestures.ts +83 -13
  177. package/src/editor/keys.ts +2 -2
  178. package/src/editor/linkActions.ts +90 -13
  179. package/src/editor/mode.ts +4 -3
  180. package/src/editor/project.ts +1 -2
  181. package/src/editor/scaleGate.ts +32 -0
  182. package/src/editor/selection.ts +13 -7
  183. package/src/editor/tooltips.ts +8 -0
  184. package/src/editor/view3d.ts +6 -2
  185. package/src/export/pdf.ts +7 -4
  186. package/src/export/plan.ts +14 -10
  187. package/src/model/demo.ts +2 -1
  188. package/src/model/elevation.ts +4 -4
  189. package/src/model/history.ts +4 -1
  190. package/src/model/links.ts +8 -2
  191. package/src/model/measurement.ts +3 -3
  192. package/src/model/openings.ts +18 -0
  193. package/src/model/planGeom.ts +70 -10
  194. package/src/model/project.ts +6 -2
  195. package/src/model/rooms.ts +151 -63
  196. package/src/model/scale.ts +105 -0
  197. package/src/model/settings.ts +1 -2
  198. package/src/model/sketch.ts +48 -4
  199. package/src/model/stroke.ts +2 -2
  200. package/src/model/types.ts +38 -7
  201. package/src/react/BottomBar.tsx +114 -29
  202. package/src/react/Drawer.tsx +14 -10
  203. package/src/react/Duck.tsx +3 -3
  204. package/src/react/KeypadSheet.tsx +2 -2
  205. package/src/react/MarkupPanel.tsx +1 -1
  206. package/src/react/PlanCanvas.native.tsx +60 -6
  207. package/src/react/Popovers.tsx +24 -9
  208. package/src/react/Rail.tsx +1 -1
  209. package/src/react/Sheet.tsx +3 -3
  210. package/src/react/barScroll.ts +57 -0
  211. package/src/react/corners.ts +6 -3
  212. package/src/react/motion.tsx +6 -6
  213. package/src/render/draw.ts +156 -116
  214. package/src/render/elevation.ts +5 -4
  215. package/src/render/figmaIcons.ts +17 -1
  216. package/src/render/hit.ts +21 -4
  217. package/src/render/icons.ts +27 -20
  218. package/src/render/lens.ts +52 -0
  219. package/src/render/linkTile.ts +436 -190
  220. package/src/render/snap.ts +7 -2
  221. package/src/render/svgPath.ts +95 -0
  222. package/src/render/tileIcons.ts +75 -0
  223. package/src/render/view.ts +37 -5
  224. package/src/skia/SkiaDrawCtx.ts +26 -4
  225. package/src/solver/equations.ts +22 -6
  226. package/src/solver/gauss-newton.ts +4 -1
  227. package/src/styles.css +3 -2
  228. package/src/tools/annotate.ts +1 -18
  229. package/src/tools/chamfer.ts +3 -0
  230. package/src/tools/dim.ts +26 -52
  231. package/src/tools/fillet.ts +3 -0
  232. package/src/tools/freehand.ts +1 -3
  233. package/src/tools/line.ts +12 -11
  234. package/src/tools/modify.ts +5 -1
  235. package/src/tools/registry.ts +10 -1
  236. package/src/tools/select.ts +99 -18
  237. package/src/tools/shapes.ts +9 -4
  238. package/src/ui/ctx.ts +4 -3
  239. package/src/ui/drawer.ts +8 -2
  240. package/src/view3d/build.ts +11 -10
@@ -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,43 @@ 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) c.drawImage(im!, -w / 2, -hh / 2, w, hh);
138
+ else {
139
+ c.fillStyle = '#2B2C2E'; c.fillRect(-w / 2, -hh / 2, w, hh);
140
+ if (linkInfoOf(u.link!)?.exists === false) {
141
+ c.font = '500 13px ' + FONT; c.fillStyle = '#9FA0A2'; c.textAlign = 'center'; c.textBaseline = 'middle';
142
+ c.fillText('Removed', 0, 0);
143
+ }
144
+ }
145
+ c.restore();
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Each expanded photo's badge: its link icon in a white disc in its top-right corner (imageLinkBadge), over the drawing
151
+ * — 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.
152
+ */
153
+ function drawImageLinks(c: C2D, T: Xform): void {
154
+ if (!settings.layers.underlay) return;
155
+ const { W, H } = cvs;
156
+ for (const u of page().underlays) {
157
+ const b = imageLinkBadge(T, u);
158
+ if (!b || b.x < -b.r || b.y < -b.r || b.x > W + b.r || b.y > H + b.r) continue;
159
+ const r = b.r, disc = (cc: C2D, X: number, Y: number) => { cc.beginPath(); cc.arc(X + r, Y + r, r, 0, Math.PI * 2); };
160
+ c.save();
161
+ if (ed.mode === 'draft') c.globalAlpha = 0.75;
162
+ if (c.shape) c.shape('image link ' + r, b.x - r, b.y - r, disc); else disc(c, b.x - r, b.y - r);
163
+ c.fillStyle = '#FFFFFF'; c.fill();
164
+ c.lineWidth = 1; c.strokeStyle = 'rgba(24,26,28,.2)'; c.stroke();
165
+ linkIcon(c, b.x, b.y, r * 1.2, '#181A1C');
116
166
  c.restore();
117
167
  }
118
168
  }
@@ -131,18 +181,40 @@ function drawRooms(c: C2D, T: Xform, sk: Sketch, thumb?: boolean): void {
131
181
  c.fill();
132
182
  if (thumb) continue;
133
183
  const [cx, cy] = S(T, g.c.x, g.c.y);
134
- c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillStyle = TH.label;
184
+ // (its area and perimeter once the drawing has its scale: before, only placeholders — just its name)
185
+ const nm = r.name, info = sk.unscaled ? '' : fmtArea(g.area) + ' · ' + fmtLen(g.per) + ' perim.';
135
186
  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);
187
+ const nw = c.measureText(nm).width;
188
+ c.font = '500 12px ' + FONT;
189
+ const iw = info ? c.measureText(info).width : 0;
190
+ const show = spaceLabel(T, g.inner, nw, iw);
191
+ if (!show.name) continue;
192
+ c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillStyle = TH.label;
140
193
  c.font = '600 14px ' + FONT;
141
- const w = Math.max(c.measureText(nm).width, 140);
194
+ c.fillText(nm, cx, show.info ? cy - 9 : cy);
195
+ if (show.info) { c.font = '500 12px ' + FONT; c.fillStyle = TH.ink2; c.fillText(info, cx, cy + 9); }
196
+ const w = Math.max(nw, 140);
142
197
  addHit({ kind: 'room', id, x: cx - w / 2, y: cy - 20, w, h: 40 });
143
198
  }
144
199
  }
145
200
 
201
+ /** Room left round a space's label on the screen, each side, points. */
202
+ const SPACE_LABEL_PAD = 8;
203
+
204
+ /**
205
+ * Which of a space's label shows: only what fits inside the space on the screen, so zoomed out it goes rather than
206
+ * cluttering the drawing (the user's call, 2026-10-29, as a tile's name goes: LINK_FULL_PX) — its area and perimeter
207
+ * first, then its name. `nameW`, `infoW`: their widths (no area line: 0).
208
+ */
209
+ export function spaceLabel(T: Xform, inner: XY[], nameW: number, infoW: number): { name: boolean; info: boolean } {
210
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
211
+ 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); }
212
+ const w = (x1 - x0) * T.s, h = (y1 - y0) * T.s, pad = 2 * SPACE_LABEL_PAD;
213
+ // (one line of 14 pt; two, 18 apart, of 14 and 12)
214
+ const name = w >= nameW + pad && h >= 18 + pad;
215
+ return { name, info: name && infoW > 0 && w >= infoW + pad && h >= 36 + pad };
216
+ }
217
+
146
218
  interface CurveWall { id: string; kind: 'circle' | 'arc'; thick: number; constr?: boolean }
147
219
 
148
220
  function curveWalls(sk: Sketch): CurveWall[] {
@@ -333,11 +405,13 @@ function drawOpenings(c: C2D, T: Xform, sk: Sketch): void {
333
405
  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
406
  c.stroke();
335
407
  } else {
336
- const hinge = P(s0, (f * t) / 2), leaf = P(s0, f * (t / 2 + w));
408
+ // the leaf, open square to the wall from the jamb it hangs on, and its swing back to the other
409
+ const [hs, ls] = hingeEdge(o) === 's1' ? [s1, s0] : [s0, s1];
410
+ const hinge = P(hs, (f * t) / 2), leaf = P(hs, f * (t / 2 + w));
337
411
  c.beginPath(); c.moveTo(hinge[0], hinge[1]); c.lineTo(leaf[0], leaf[1]); c.stroke();
338
412
  const [hx, hy] = hinge, r = w * T.s;
339
413
  const a0 = Math.atan2(leaf[1] - hy, leaf[0] - hx);
340
- const end = P(s1, (f * t) / 2);
414
+ const end = P(ls, (f * t) / 2);
341
415
  const a1 = Math.atan2(end[1] - hy, end[0] - hx);
342
416
  c.beginPath(); c.setLineDash([4, 3]); c.lineWidth = 1;
343
417
  const ccw = normAng(a1 - a0) > Math.PI;
@@ -423,12 +497,25 @@ function drawLock(c: C2D, x: number, y: number): void {
423
497
  c.restore();
424
498
  }
425
499
 
500
+ /** The selected points and the selected lines' ends: what a constraint's icon still shows for with the icons off. */
501
+ function selPoints(sk: Sketch): Set<string> {
502
+ const out = new Set<string>();
503
+ for (const s of ed.sel) {
504
+ if (s.type === 'line') { const l = sk.lines[s.id]; if (l) { out.add(l.a); out.add(l.b); } }
505
+ if (s.type === 'point') out.add(s.id);
506
+ }
507
+ return out;
508
+ }
509
+
426
510
  /** Points: filled = fully defined, hollow = still free (shape cue paired with color). */
427
511
  function drawPoints(c: C2D, T: Xform, sk: Sketch): void {
428
512
  const { W, H } = cvs;
429
513
  const touch = ed.lastPointerTouch;
430
514
  // a cabinet's corners aren't grab points: it moves as one, like a door or window
431
515
  const cabCorners = new Set(Object.values((sk as WallFace).cabs ?? {}).flatMap(cabPoints));
516
+ // a locked point's padlock is a constraint icon: with the icons off, it shows only as the badges do, on what's
517
+ // selected (the user's call, 2026-10-26)
518
+ const locks = settings.showCons ? null : selPoints(sk);
432
519
  for (const id in sk.points) {
433
520
  const p = sk.points[id];
434
521
  const [x, y] = S(T, p.x, p.y);
@@ -447,47 +534,31 @@ function drawPoints(c: C2D, T: Xform, sk: Sketch): void {
447
534
  if (selected) { c.fillStyle = TH.yellow; c.fill(); c.lineWidth = 1.5; c.strokeStyle = TH.ink; c.stroke(); }
448
535
  else if (det) { c.fillStyle = ed.highlight.has(id) ? TH.red : p.origin ? TH.ink : inkForPoint(sk, id); c.fill(); }
449
536
  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);
537
+ if (p.fixed && !p.origin && !p.anchor && (!locks || locks.has(id) || ed.highlight.has(id))) drawLock(c, x + 9, y - 14);
451
538
  }
452
539
  }
453
540
 
454
541
  type DimAnchor =
455
542
  | { m: XY; a: XY; b: XY; nx?: number; ny?: number; L?: number }
456
543
  | { m: XY; o: XY; r: number }
457
- | { vtx: XY; bis: [number, number]; u1?: [number, number]; u2?: [number, number] };
544
+ | { vtx: XY; bis: [number, number]; u1?: [number, number]; u2?: [number, number]; len1?: number; len2?: number };
458
545
 
459
546
  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)
547
+ if (c.type === 'dist' || c.type === 'pldist') {
548
+ // (a length measured on a wall's face: from its corner to corner, the label off the face on that side. Between
549
+ // parallel lines or walls: from face to face, slid along them.)
463
550
  const e = dimEnds(sk, c);
464
551
  if (!e) return null;
465
552
  const { a, b } = e;
466
553
  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
554
  return { m: { x: (a.x + b.x) / 2 + nx * off, y: (a.y + b.y) / 2 + ny * off }, a, b, nx, ny, L };
468
555
  }
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
556
  if (c.type === 'diam') {
476
557
  // (across a curved wall's face: its arrows on that face)
477
558
  const g = dimRadius(sk, c);
478
559
  return g ? { m: { x: g.o.x, y: g.o.y }, o: g.o, r: g.r } : null;
479
560
  }
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
- }
561
+ if (c.type === 'angle') return angleCorner(sk, c);
491
562
  return null;
492
563
  }
493
564
 
@@ -509,35 +580,50 @@ function drawDims(c: C2D, T: Xform, sk: Sketch): void {
509
580
  if (!settings.layers.dims) return;
510
581
  for (const id in sk.cons) {
511
582
  const d = sk.cons[id];
512
- if (!isDim(d) || d.layer === 'print' || d.elev) continue; // the wall view's own dimensions are its chips
583
+ if (!isDim(d) || d.elev) continue; // the wall view's own dimensions are its chips
513
584
  const A = dimAnchor(sk, d);
514
585
  if (!A) continue;
515
586
  const ref = d.driving === false, val = ref ? measure(sk, d) : d.value!;
516
587
  const selected = isSel('dim', id) || isSel('cons', id);
517
588
  const bad = ed.highlight.has(id) || ed.ana.bad.has(id);
518
589
  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
590
+ // a measurement of the job sets it: the link badge before its value (chip()), on the chip's own colour — red once
591
+ // the job's measurement is gone
520
592
  const linked = !!d.measurementRef && !ref && !bad, gone = linked && linkInfoOf(d.measurementRef!)?.exists === false;
521
593
  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;
594
+ if (bad || gone) { bg = TH.red; fg = '#fff'; }
595
+ const border: [string, number] | null = selected ? [TH.yellow, 2.5] : null;
525
596
  const mark = linked ? 'link' as const : undefined;
526
597
  const wrap = (s: string) => (ref ? '(' : '') + s + (ref ? ')' : '');
527
598
  if (d.type === 'angle' && 'vtx' in A) {
528
599
  const [vx, vy] = S(T, A.vtx.x, A.vtx.y);
600
+ // pulled off its corner: its arc out where the label is, the label riding on it; else a little arc at the
601
+ // corner, the label out along the bisector
602
+ const moved = angleLabel(sk, d), R = moved ? moved.r * T.s : 22;
529
603
  if (A.u1 && A.u2) {
530
604
  // arc between the two lines, so the angle reads as belonging to that corner
531
605
  const t1 = Math.atan2(-A.u1[1], A.u1[0]), t2 = Math.atan2(-A.u2[1], A.u2[0]);
532
606
  let sweep = t2 - t1;
533
607
  while (sweep > Math.PI) sweep -= 2 * Math.PI;
534
608
  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;
609
+ // (blue, as every other dimension is — the user's call, 2026-10-29; purple is the inference guides')
610
+ c.save(); c.strokeStyle = bad ? TH.red : ref ? TH.chipline : TH.blue; c.lineWidth = 1.2;
536
611
  if (ref) c.setLineDash([3, 3]);
537
- c.beginPath(); c.arc(vx, vy, 22, t1, t1 + sweep, sweep < 0); c.stroke(); c.restore();
612
+ c.beginPath(); c.arc(vx, vy, R, t1, t1 + sweep, sweep < 0); c.stroke();
613
+ // (pulled out past where a line ends: an extension line along it, out to the arc)
614
+ c.lineWidth = 1;
615
+ for (const [u, len] of [[A.u1, A.len1], [A.u2, A.len2]] as const) {
616
+ const from = (len ?? 0) * T.s;
617
+ 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(); }
618
+ }
619
+ c.restore();
538
620
  }
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 });
621
+ const text = wrap(fmtAng(val)), opts: ChipOpts = { bg, fg, border, kind: 'dim', id, mark };
622
+ // (left where it was drawn: 50 px out along the bisector, or as far as its corners stay clear of the corner — a
623
+ // linked chip is wider)
624
+ const sz = chipSize(c, text, opts), out = Math.max(50, Math.hypot(sz.w, sz.h) / 2 + 28);
625
+ 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];
626
+ chip(c, text, lx, ly, opts);
541
627
  continue;
542
628
  }
543
629
  if (d.type === 'diam' && 'o' in A) {
@@ -547,7 +633,7 @@ function drawDims(c: C2D, T: Xform, sk: Sketch): void {
547
633
  if (!('a' in A)) continue;
548
634
  const [mx, my] = S(T, A.m.x, A.m.y);
549
635
  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) {
636
+ if ((d.type === 'dist' || d.type === 'pldist') && A.nx != null && Math.abs((d.off || 0) * T.s) > 10) {
551
637
  drawDimLeaders(c, T, A.a, A.b, A.nx, A.ny!, d.off!, lineColor, ref);
552
638
  } else if (d.type === 'pldist' || !d.line) {
553
639
  // (dashed only when it's a reference: a dashed line means the number holds nothing, wherever it's drawn)
@@ -720,7 +806,8 @@ export function drawRubberBand(c: C2D, T: Xform, from: XY, to: XY, snapped: bool
720
806
  if (snapped) endRing(c, x2, y2, TH.blue);
721
807
  c.restore();
722
808
  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] });
809
+ // (no distance on a drawing without its scale yet: it's only a placeholder)
810
+ 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
811
  }
725
812
 
726
813
  /**
@@ -751,7 +838,8 @@ export function drawPendingDim(c: C2D, T: Xform, p: { from: XY; to: XY; a: XY; b
751
838
  for (const q of [p.from, p.to]) { const [x, y] = S(T, q.x, q.y); endRing(c, x, y, TH.yellow); }
752
839
  c.restore();
753
840
  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] });
841
+ // (no label until one's typed, on a page whose sizes are still placeholders)
842
+ 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
843
  }
756
844
 
757
845
  /**
@@ -800,54 +888,10 @@ function drawDiameter(c: C2D, T: Xform, o: XY, r: number, d: Constraint, text: s
800
888
  arrowHead(c, p1, sx, sy);
801
889
  if (!d.rad) arrowHead(c, p2, -sx, -sy);
802
890
  c.restore();
803
- c.font = (chipOpts.font || '600 12px') + ' ' + FONT;
804
- const w = c.measureText(text).width + 14 + (chipOpts.mark ? MARK_W : 0);
891
+ const { w } = chipSize(c, text, chipOpts);
805
892
  chip(c, text, end[0] + side * (w / 2 + 2), end[1], chipOpts);
806
893
  }
807
894
 
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
895
  /**
852
896
  * A badge's symbol drawn as lines rather than a character: ∥ ⊥ ∘ ◎ aren't in every typeface — on a device the
853
897
  * 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 +958,7 @@ function badgeSpots(T: Xform, sk: Sketch, c: Constraint): [number, number][] {
914
958
 
915
959
  function drawBadges(c: C2D, T: Xform, sk: Sketch): void {
916
960
  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
- }
961
+ const selIds = new Set(ed.sel.map(s => s.id)), selPts = selPoints(sk);
922
962
  for (const id in sk.cons) {
923
963
  const k = sk.cons[id];
924
964
  if (isDim(k) || k.elev) continue;
@@ -1162,7 +1202,6 @@ function paintPlanView(ctx: C2D, sk: Sketch, m: number, under = true): void {
1162
1202
  if (settings.layers.rooms) drawRooms(ctx, T, sk);
1163
1203
  if (settings.layers.cabinets) drawCabinetFootprints(ctx, T, sk);
1164
1204
  drawWalls(ctx, T, sk); drawOpenings(ctx, T, sk); drawLines(ctx, T, sk); drawCurves(ctx, T, sk);
1165
- drawPrintDims(ctx, T, sk);
1166
1205
  if (ed.mode === 'draft') drawBadges(ctx, T, sk);
1167
1206
  // (the drawing's points only in Draft: Annotate can't move them, and shouldn't look as if it could — the user's
1168
1207
  // call, 2026-10-21)
@@ -1170,6 +1209,8 @@ function paintPlanView(ctx: C2D, sk: Sketch, m: number, under = true): void {
1170
1209
  drawOpeningAnchors(ctx, T, sk);
1171
1210
  drawDims(ctx, T, sk);
1172
1211
  drawOpeningDims(ctx, T, sk);
1212
+ // (an expanded photo's badge over the drawing, under the markup: as it's tapped)
1213
+ drawImageLinks(ctx, T);
1173
1214
  drawNotes(ctx, T, sk);
1174
1215
  drawNoteSelection(ctx, T);
1175
1216
  }
@@ -1190,7 +1231,6 @@ function paintWall(ctx: C2D, W: number, fixed: C2D): void {
1190
1231
  drawLines(ctx, T, sk); drawCurves(ctx, T, sk);
1191
1232
  // (over the edges — its own and its neighbours' — so a selected cabinet's outline shows all the way round)
1192
1233
  drawWallSelection(ctx, T, plan, f, sk);
1193
- drawPrintDims(ctx, T, sk);
1194
1234
  drawBadges(ctx, T, sk);
1195
1235
  if (ed.mode === 'draft') drawPoints(ctx, T, sk);
1196
1236
  drawDims(ctx, T, sk);
@@ -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 };
package/src/render/hit.ts CHANGED
@@ -6,12 +6,12 @@ import { roomValid } from '../model/sketch';
6
6
  import { page, SK } from '../model/store';
7
7
  import { edgePoint } from '../model/openings';
8
8
  import { elevOpen, wallBodyAt } from '../editor/elevation';
9
- import type { Note, OpeningEdge } from '../model/types';
9
+ import type { LinkTarget, Note, OpeningEdge, Underlay } from '../model/types';
10
10
  import { cabinetFootprints } from '../model/facesolve';
11
11
  import { arcAngles, curveFrame, edgeId, OPENING_TICK_EXT, openingFrame, wallThick } from '../model/planGeom';
12
12
  import { noteLook, shapeOutline } from '../model/markup';
13
13
  import { smoothPolyline } from '../model/smooth';
14
- import { leaderPath, linkBox, mainT, noteHandles, noteTextBox, S, s2w, underlayH, underlayHandles, underlayLocal, type Xform } from './view';
14
+ import { imageLinkBadge, leaderPath, linkBox, mainT, noteHandles, noteTextBox, S, s2w, underlayH, underlayHandles, underlayLocal, type Xform } from './view';
15
15
 
16
16
  /** The handle closest to (sx, sy) within `tol` px (small boxes put handles close together; take the nearest, not the first). */
17
17
  function nearestHandle<H extends { x: number; y: number }>(hs: H[], sx: number, sy: number, tol: number): H | null {
@@ -42,6 +42,20 @@ export function linkAt(sx: number, sy: number, touch = false): (Note & { link: N
42
42
  return null;
43
43
  }
44
44
 
45
+ /**
46
+ * The expanded photo whose badge is under a screen point (its link icon, in its corner: a tap opens what it's for —
47
+ * a view-only one too), the top one. The badges are drawn over every image, so they're picked before any.
48
+ */
49
+ export function imageLinkAt(sx: number, sy: number, touch = false): (Underlay & { link: LinkTarget }) | null {
50
+ if (!settings.layers.underlay || elevOpen()) return null;
51
+ const T = mainT(), ims = page().underlays;
52
+ for (let i = ims.length - 1; i >= 0; i--) {
53
+ const b = imageLinkBadge(T, ims[i]);
54
+ if (b && hyp(sx - b.x, sy - b.y) <= b.r + (touch ? 6 : 2)) return ims[i] as Underlay & { link: LinkTarget };
55
+ }
56
+ return null;
57
+ }
58
+
45
59
  function notePointHit(n: Note, sx: number, sy: number, T: Xform, tol: number): boolean {
46
60
  if (n.type === 'link') return !!linkHit(T, n, sx, sy, tol);
47
61
  if (n.type === 'text') {
@@ -129,6 +143,9 @@ export function hitAny(sx: number, sy: number, { touch = false, only = null as s
129
143
  if (notePointHit(n, sx, sy, T, tol)) return { type: 'note', id: n.id };
130
144
  }
131
145
  if (!only) {
146
+ // an expanded photo's badge: a tap opens what it's for, a drag moves the photo
147
+ const lu = imageLinkAt(sx, sy, touch);
148
+ if (lu) return { type: 'underlay', id: lu.id, part: 'link' };
132
149
  // the topmost image under the point
133
150
  const [wx, wy] = s2w(sx, sy);
134
151
  for (let i = ims.length - 1; i >= 0; i--) {
@@ -145,7 +162,7 @@ export function hitAny(sx: number, sy: number, { touch = false, only = null as s
145
162
  const h = chips[i];
146
163
  if (h.weak) continue;
147
164
  if (sx >= h.x && sx <= h.x + h.w && sy >= h.y && sy <= h.y + h.h) {
148
- if (h.kind === 'dim' || h.kind === 'pdim') return { type: 'dim', id: h.id, print: h.kind === 'pdim' };
165
+ if (h.kind === 'dim') return { type: 'dim', id: h.id };
149
166
  if (h.kind === 'cons') return { type: 'cons', id: h.id };
150
167
  if (h.kind === 'odim') return { type: 'odim', id: h.id, part: h.part };
151
168
  if (h.kind === 'room') return { type: 'room', id: h.id };
@@ -219,7 +236,7 @@ export function hitAny(sx: number, sy: number, { touch = false, only = null as s
219
236
  const [x, y] = S(T, g.o.x, g.o.y);
220
237
  const [wx, wy] = s2w(sx, sy);
221
238
  const th = Math.atan2(wy - g.o.y, wx - g.o.x);
222
- const sweep = normAng(g.t1 - g.t0), at = normAng(th - g.t0);
239
+ const sweep = g.full ? 2 * Math.PI : normAng(g.t1 - g.t0), at = normAng(th - g.t0);
223
240
  const tw = sk.arcs[id].kind === 'wall' ? Math.max(0, ((sk.arcs[id].thick || 4.5) * T.s) / 2 - tol / 2) : 0;
224
241
  if (at <= sweep) consider('arc', id, Math.abs(hyp(x - sx, y - sy) - g.r * T.s) - tw);
225
242
  }