@reekon-tools/boldr-cad 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/README.md +21 -4
  2. package/dist/app/edit.d.ts +3 -3
  3. package/dist/app/edit.js +26 -1
  4. package/dist/app/edit.js.map +1 -1
  5. package/dist/app/state.d.ts +3 -5
  6. package/dist/app/state.js +0 -1
  7. package/dist/app/state.js.map +1 -1
  8. package/dist/data/sync.d.ts +1 -1
  9. package/dist/data/sync.js +7 -2
  10. package/dist/data/sync.js.map +1 -1
  11. package/dist/editor/actions.d.ts +8 -0
  12. package/dist/editor/actions.js +149 -169
  13. package/dist/editor/actions.js.map +1 -1
  14. package/dist/editor/elevation.d.ts +4 -1
  15. package/dist/editor/elevation.js +16 -17
  16. package/dist/editor/elevation.js.map +1 -1
  17. package/dist/editor/gestures.d.ts +21 -0
  18. package/dist/editor/gestures.js +92 -14
  19. package/dist/editor/gestures.js.map +1 -1
  20. package/dist/editor/keys.js +2 -2
  21. package/dist/editor/keys.js.map +1 -1
  22. package/dist/editor/linkActions.d.ts +22 -3
  23. package/dist/editor/linkActions.js +95 -13
  24. package/dist/editor/linkActions.js.map +1 -1
  25. package/dist/editor/mode.js +4 -3
  26. package/dist/editor/mode.js.map +1 -1
  27. package/dist/editor/project.js +1 -2
  28. package/dist/editor/project.js.map +1 -1
  29. package/dist/editor/scaleGate.d.ts +13 -0
  30. package/dist/editor/scaleGate.js +27 -0
  31. package/dist/editor/scaleGate.js.map +1 -0
  32. package/dist/editor/selection.js +13 -9
  33. package/dist/editor/selection.js.map +1 -1
  34. package/dist/editor/tooltips.js +9 -0
  35. package/dist/editor/tooltips.js.map +1 -1
  36. package/dist/editor/view3d.d.ts +4 -1
  37. package/dist/editor/view3d.js +6 -2
  38. package/dist/editor/view3d.js.map +1 -1
  39. package/dist/export/pdf.d.ts +5 -2
  40. package/dist/export/pdf.js +12 -8
  41. package/dist/export/pdf.js.map +1 -1
  42. package/dist/export/plan.d.ts +1 -1
  43. package/dist/export/plan.js +14 -9
  44. package/dist/export/plan.js.map +1 -1
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.js +1 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/model/demo.d.ts +1 -0
  49. package/dist/model/demo.js +2 -1
  50. package/dist/model/demo.js.map +1 -1
  51. package/dist/model/elevation.d.ts +1 -1
  52. package/dist/model/elevation.js +3 -3
  53. package/dist/model/elevation.js.map +1 -1
  54. package/dist/model/history.js +4 -1
  55. package/dist/model/history.js.map +1 -1
  56. package/dist/model/links.d.ts +4 -1
  57. package/dist/model/links.js +9 -2
  58. package/dist/model/links.js.map +1 -1
  59. package/dist/model/measurement.d.ts +2 -2
  60. package/dist/model/measurement.js +3 -3
  61. package/dist/model/measurement.js.map +1 -1
  62. package/dist/model/openings.d.ts +10 -0
  63. package/dist/model/openings.js +18 -0
  64. package/dist/model/openings.js.map +1 -1
  65. package/dist/model/planGeom.d.ts +37 -2
  66. package/dist/model/planGeom.js +72 -9
  67. package/dist/model/planGeom.js.map +1 -1
  68. package/dist/model/project.js +5 -2
  69. package/dist/model/project.js.map +1 -1
  70. package/dist/model/rooms.d.ts +18 -5
  71. package/dist/model/rooms.js +178 -73
  72. package/dist/model/rooms.js.map +1 -1
  73. package/dist/model/scale.d.ts +30 -0
  74. package/dist/model/scale.js +120 -0
  75. package/dist/model/scale.js.map +1 -0
  76. package/dist/model/settings.d.ts +0 -1
  77. package/dist/model/settings.js +1 -1
  78. package/dist/model/settings.js.map +1 -1
  79. package/dist/model/sketch.d.ts +21 -1
  80. package/dist/model/sketch.js +58 -4
  81. package/dist/model/sketch.js.map +1 -1
  82. package/dist/model/stroke.js +2 -2
  83. package/dist/model/types.d.ts +38 -7
  84. package/dist/react/BottomBar.d.ts +2 -1
  85. package/dist/react/BottomBar.js +94 -25
  86. package/dist/react/BottomBar.js.map +1 -1
  87. package/dist/react/Drawer.js +3 -2
  88. package/dist/react/Drawer.js.map +1 -1
  89. package/dist/react/Duck.js.map +1 -1
  90. package/dist/react/KeypadSheet.js.map +1 -1
  91. package/dist/react/MarkupPanel.js +1 -1
  92. package/dist/react/PlanCanvas.native.js +54 -7
  93. package/dist/react/PlanCanvas.native.js.map +1 -1
  94. package/dist/react/Popovers.js +14 -3
  95. package/dist/react/Popovers.js.map +1 -1
  96. package/dist/react/Rail.js +1 -1
  97. package/dist/react/Sheet.d.ts +2 -2
  98. package/dist/react/Sheet.js.map +1 -1
  99. package/dist/react/barScroll.d.ts +23 -0
  100. package/dist/react/barScroll.js +47 -0
  101. package/dist/react/barScroll.js.map +1 -0
  102. package/dist/react/corners.d.ts +6 -2
  103. package/dist/react/corners.js.map +1 -1
  104. package/dist/react/host.d.ts +7 -0
  105. package/dist/react/host.js +14 -1
  106. package/dist/react/host.js.map +1 -1
  107. package/dist/react/motion.d.ts +1 -1
  108. package/dist/react/motion.js +1 -1
  109. package/dist/react/motion.js.map +1 -1
  110. package/dist/render/draw.d.ts +21 -4
  111. package/dist/render/draw.js +184 -149
  112. package/dist/render/draw.js.map +1 -1
  113. package/dist/render/elevation.js +3 -3
  114. package/dist/render/elevation.js.map +1 -1
  115. package/dist/render/figmaIcons.d.ts +6 -0
  116. package/dist/render/figmaIcons.js +16 -1
  117. package/dist/render/figmaIcons.js.map +1 -1
  118. package/dist/render/hit.d.ts +8 -1
  119. package/dist/render/hit.js +23 -4
  120. package/dist/render/hit.js.map +1 -1
  121. package/dist/render/icons.js +27 -20
  122. package/dist/render/icons.js.map +1 -1
  123. package/dist/render/lens.d.ts +27 -0
  124. package/dist/render/lens.js +41 -0
  125. package/dist/render/lens.js.map +1 -0
  126. package/dist/render/linkTile.d.ts +20 -2
  127. package/dist/render/linkTile.js +476 -246
  128. package/dist/render/linkTile.js.map +1 -1
  129. package/dist/render/snap.d.ts +5 -1
  130. package/dist/render/snap.js +3 -1
  131. package/dist/render/snap.js.map +1 -1
  132. package/dist/render/svgPath.d.ts +29 -0
  133. package/dist/render/svgPath.js +114 -0
  134. package/dist/render/svgPath.js.map +1 -0
  135. package/dist/render/tileIcons.d.ts +18 -0
  136. package/dist/render/tileIcons.js +67 -0
  137. package/dist/render/tileIcons.js.map +1 -0
  138. package/dist/render/view.d.ts +18 -1
  139. package/dist/render/view.js +31 -4
  140. package/dist/render/view.js.map +1 -1
  141. package/dist/skia/SkiaDrawCtx.d.ts +8 -0
  142. package/dist/skia/SkiaDrawCtx.js +27 -5
  143. package/dist/skia/SkiaDrawCtx.js.map +1 -1
  144. package/dist/solver/equations.d.ts +6 -0
  145. package/dist/solver/equations.js +23 -6
  146. package/dist/solver/equations.js.map +1 -1
  147. package/dist/solver/gauss-newton.js +6 -1
  148. package/dist/solver/gauss-newton.js.map +1 -1
  149. package/dist/tools/annotate.d.ts +0 -2
  150. package/dist/tools/annotate.js +1 -22
  151. package/dist/tools/annotate.js.map +1 -1
  152. package/dist/tools/chamfer.js +4 -0
  153. package/dist/tools/chamfer.js.map +1 -1
  154. package/dist/tools/dim.d.ts +3 -3
  155. package/dist/tools/dim.js +26 -65
  156. package/dist/tools/dim.js.map +1 -1
  157. package/dist/tools/fillet.js +4 -0
  158. package/dist/tools/fillet.js.map +1 -1
  159. package/dist/tools/freehand.js +1 -4
  160. package/dist/tools/freehand.js.map +1 -1
  161. package/dist/tools/line.js +15 -11
  162. package/dist/tools/line.js.map +1 -1
  163. package/dist/tools/modify.js +6 -1
  164. package/dist/tools/modify.js.map +1 -1
  165. package/dist/tools/registry.d.ts +5 -0
  166. package/dist/tools/registry.js +6 -1
  167. package/dist/tools/registry.js.map +1 -1
  168. package/dist/tools/select.d.ts +19 -0
  169. package/dist/tools/select.js +116 -17
  170. package/dist/tools/select.js.map +1 -1
  171. package/dist/tools/shapes.d.ts +1 -1
  172. package/dist/tools/shapes.js +12 -5
  173. package/dist/tools/shapes.js.map +1 -1
  174. package/dist/tools/survey.d.ts +6 -0
  175. package/dist/tools/survey.js +28 -0
  176. package/dist/tools/survey.js.map +1 -1
  177. package/dist/view3d/build.js +9 -8
  178. package/dist/view3d/build.js.map +1 -1
  179. package/package.json +9 -9
  180. package/src/app/edit.ts +25 -1
  181. package/src/app/state.ts +3 -5
  182. package/src/data/sync.ts +7 -2
  183. package/src/editor/actions.ts +126 -112
  184. package/src/editor/elevation.ts +13 -16
  185. package/src/editor/gestures.ts +83 -13
  186. package/src/editor/keys.ts +2 -2
  187. package/src/editor/linkActions.ts +90 -13
  188. package/src/editor/mode.ts +4 -3
  189. package/src/editor/project.ts +1 -2
  190. package/src/editor/scaleGate.ts +32 -0
  191. package/src/editor/selection.ts +13 -7
  192. package/src/editor/tooltips.ts +8 -0
  193. package/src/editor/view3d.ts +6 -2
  194. package/src/export/pdf.ts +7 -4
  195. package/src/export/plan.ts +14 -10
  196. package/src/index.ts +1 -1
  197. package/src/model/demo.ts +2 -1
  198. package/src/model/elevation.ts +4 -4
  199. package/src/model/history.ts +4 -1
  200. package/src/model/links.ts +8 -2
  201. package/src/model/measurement.ts +3 -3
  202. package/src/model/openings.ts +18 -0
  203. package/src/model/planGeom.ts +70 -10
  204. package/src/model/project.ts +6 -2
  205. package/src/model/rooms.ts +151 -63
  206. package/src/model/scale.ts +105 -0
  207. package/src/model/settings.ts +1 -2
  208. package/src/model/sketch.ts +48 -4
  209. package/src/model/stroke.ts +2 -2
  210. package/src/model/types.ts +38 -7
  211. package/src/react/BottomBar.tsx +114 -29
  212. package/src/react/Drawer.tsx +14 -10
  213. package/src/react/Duck.tsx +3 -3
  214. package/src/react/KeypadSheet.tsx +2 -2
  215. package/src/react/MarkupPanel.tsx +1 -1
  216. package/src/react/PlanCanvas.native.tsx +60 -6
  217. package/src/react/Popovers.tsx +24 -9
  218. package/src/react/Rail.tsx +1 -1
  219. package/src/react/Sheet.tsx +3 -3
  220. package/src/react/barScroll.ts +57 -0
  221. package/src/react/corners.ts +6 -3
  222. package/src/react/host.ts +14 -1
  223. package/src/react/motion.tsx +6 -6
  224. package/src/render/draw.ts +156 -116
  225. package/src/render/elevation.ts +5 -4
  226. package/src/render/figmaIcons.ts +17 -1
  227. package/src/render/hit.ts +21 -4
  228. package/src/render/icons.ts +27 -20
  229. package/src/render/lens.ts +52 -0
  230. package/src/render/linkTile.ts +436 -190
  231. package/src/render/snap.ts +7 -2
  232. package/src/render/svgPath.ts +95 -0
  233. package/src/render/tileIcons.ts +75 -0
  234. package/src/render/view.ts +37 -5
  235. package/src/skia/SkiaDrawCtx.ts +26 -4
  236. package/src/solver/equations.ts +22 -6
  237. package/src/solver/gauss-newton.ts +4 -1
  238. package/src/styles.css +3 -2
  239. package/src/tools/annotate.ts +1 -18
  240. package/src/tools/chamfer.ts +3 -0
  241. package/src/tools/dim.ts +26 -52
  242. package/src/tools/fillet.ts +3 -0
  243. package/src/tools/freehand.ts +1 -3
  244. package/src/tools/line.ts +12 -11
  245. package/src/tools/modify.ts +5 -1
  246. package/src/tools/registry.ts +10 -1
  247. package/src/tools/select.ts +99 -18
  248. package/src/tools/shapes.ts +9 -4
  249. package/src/tools/survey.ts +21 -0
  250. package/src/ui/ctx.ts +4 -3
  251. package/src/ui/drawer.ts +8 -2
  252. package/src/view3d/build.ts +11 -10
@@ -0,0 +1,57 @@
1
+ import { C } from './theme';
2
+
3
+ /*
4
+ A bottom bar too long for the screen scrolls, and has to look like it does (the user's call, 2026-10-27: "on mobile
5
+ it is hard to tell that the bottom bar is scrollable"). Three things say so, in react/BottomBar.tsx:
6
+ - a fade at each end that has more of the bar past it, there as it's scrolled;
7
+ - its end always cuts through a button, never at a gap: there, what shows looks like all there is (barCut);
8
+ - the first bar of a session that doesn't fit slides a little way along and back, unless Reduce Motion is on.
9
+ */
10
+
11
+ /** A button's place along the bar's buttons: from their start, and its width. */
12
+ export type Span = readonly [x: number, w: number];
13
+
14
+ /** How much of a button the end leaves showing: what it's cut to, and the shares that already read as cut. */
15
+ export const CUT = 0.6, CUT_MIN = 0.3, CUT_MAX = 0.8;
16
+ /** A fade's width; how far the bar scrolls as one comes or goes. */
17
+ export const FADE = 20, FADE_RAMP = 24;
18
+ /** The nudge: how far along, how long after the bar shows, and how long before it goes back (ms). */
19
+ export const NUDGE = 40, NUDGE_AFTER = 400, NUDGE_HOLD = 450;
20
+
21
+ /**
22
+ * The buttons' places along the bar, from the widths of everything in it in order (a divider's too) and the gap
23
+ * between each: null while any is still unmeasured.
24
+ */
25
+ export function barSpans(items: readonly { w: number | undefined; button: boolean }[], gap: number): Span[] | null {
26
+ const out: Span[] = [];
27
+ let x = 0;
28
+ for (const { w, button } of items) {
29
+ if (w == null) return null;
30
+ if (button) out.push([x, w]);
31
+ x += w + gap;
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * How much of a scrolling bar shows, given `room` for it: all of that, if it already ends partway through a button
38
+ * (between CUT_MIN and CUT_MAX of it showing); otherwise less, ending at CUT of the last button that leaves showing.
39
+ */
40
+ export function barCut(spans: readonly Span[], room: number): number {
41
+ let best = 0;
42
+ for (const [x, w] of spans) {
43
+ const f = (room - x) / w;
44
+ if (f >= CUT_MIN && f <= CUT_MAX) return room;
45
+ const c = x + CUT * w;
46
+ if (c <= room && c > best) best = c;
47
+ }
48
+ return best || room;
49
+ }
50
+
51
+ /** A fade, as an SVG: the bar's grey, clear at one side, whole at the other (`to`: the side that's whole). */
52
+ export const fadeSvg = (to: 'left' | 'right'): string => {
53
+ const id = 'barFade' + (to === 'left' ? 'L' : 'R'), [a, b] = to === 'right' ? [0, 1] : [1, 0];
54
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${FADE}" height="54" viewBox="0 0 1 1" preserveAspectRatio="none">` +
55
+ `<defs><linearGradient id="${id}" x1="${a}" y1="0" x2="${b}" y2="0"><stop offset="0" stop-color="${C.g2}" stop-opacity="0"/>` +
56
+ `<stop offset="1" stop-color="${C.g2}" stop-opacity="1"/></linearGradient></defs><rect width="1" height="1" fill="url(#${id})"/></svg>`;
57
+ };
@@ -1,4 +1,4 @@
1
- import { Platform, StyleSheet } from 'react-native';
1
+ import { Platform, StyleSheet, type ImageStyle, type TextStyle, type ViewStyle } from 'react-native';
2
2
  import { CORNER_SMOOTHING } from '../render/squircle';
3
3
 
4
4
  /*
@@ -44,9 +44,12 @@ export function smoothCorners<T extends Style>(st: T): T {
44
44
  return out as T;
45
45
  }
46
46
 
47
+ /** A sheet's styles by name. (React Native 0.88's generated types have no `StyleSheet.NamedStyles`.) */
48
+ type NamedStyles<T> = { [P in keyof T]: ViewStyle | TextStyle | ImageStyle };
49
+
47
50
  /** StyleSheet.create, every style's corners smoothed (smoothCorners). */
48
- export function createSheet<T extends StyleSheet.NamedStyles<T>>(styles: T & StyleSheet.NamedStyles<unknown>): T {
51
+ export function createSheet<T extends NamedStyles<T>>(styles: T): T {
49
52
  const out = {} as Record<string, unknown>;
50
53
  for (const k of Object.keys(styles)) out[k] = smoothCorners((styles as Record<string, Style>)[k]);
51
- return StyleSheet.create(out as T);
54
+ return StyleSheet.create(out as Record<string, ViewStyle | TextStyle | ImageStyle>) as unknown as T;
52
55
  }
package/src/react/host.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { applyReading, laser } from '../editor/laser';
2
2
  import { store } from '../model/store';
3
- import { setSurveyCursor, takeSurveyPoint } from '../tools/survey';
3
+ import { closeSurveyRun, setSurveyCursor, takeSurveyPoint } from '../tools/survey';
4
4
  import { notify } from './store';
5
5
 
6
6
  /*
@@ -49,6 +49,19 @@ export function surveyPoint(x: number, y: number, capture?: unknown): boolean {
49
49
  return ok;
50
50
  }
51
51
 
52
+ /**
53
+ * Close the templating device's open run, from a button that means "close" (BOLDR's T1R's D) — the bar's Close shape:
54
+ * the last edge back to the first point, the corners it makes, and a space. It takes no point; a host whose device
55
+ * also measured one on that press drops it. False when no drawing is open, there's no run of 3 points or more, or it
56
+ * can't now (a wall view or keypad open); the editor says why.
57
+ */
58
+ export function surveyClose(): boolean {
59
+ if (!editorHost.open || !store.project) return false;
60
+ const ok = closeSurveyRun();
61
+ notify();
62
+ return ok;
63
+ }
64
+
52
65
  /**
53
66
  * The templating device's live point — where its next surveyPoint would land — on the same axes as surveyPoint, or
54
67
  * null to hide it (the device stopped, its reading went stale, the host left the screen). Drawn as a ring, with the
@@ -51,7 +51,7 @@ export const reducedMotion = (): boolean => reduceMotion;
51
51
  * device, the wait before it moved). `mounted` says whether to draw it at all; the style hides it when it's away.
52
52
  */
53
53
  export function usePopup(open: boolean, from: { x?: number; y?: number }, origin: string, keep = false): {
54
- present: boolean; mounted: boolean; style: Animated.WithAnimatedObject<ViewStyle>;
54
+ present: boolean; mounted: boolean; style: Animated.WithAnimatedValue<ViewStyle>;
55
55
  } {
56
56
  const v = useRef(new Animated.Value(open ? 1 : 0)).current, [present, setPresent] = useState(open), been = useRef(open);
57
57
  if (open) been.current = true;
@@ -77,9 +77,9 @@ export function usePopup(open: boolean, from: { x?: number; y?: number }, origin
77
77
  { translateY: v.interpolate({ inputRange: [0, 1], outputRange: [fy, 0] }) },
78
78
  { scale: v.interpolate({ inputRange: [0, 1], outputRange: [0.96, 1] }) },
79
79
  ],
80
- }) as Animated.WithAnimatedObject<ViewStyle>, [v, fx, fy, origin]);
80
+ }) as Animated.WithAnimatedValue<ViewStyle>, [v, fx, fy, origin]);
81
81
  const shown = open || present;
82
- const hidden = useMemo(() => [style, { display: 'none' as const }] as unknown as Animated.WithAnimatedObject<ViewStyle>, [style]);
82
+ const hidden = useMemo(() => [style, { display: 'none' as const }] as unknown as Animated.WithAnimatedValue<ViewStyle>, [style]);
83
83
  return { present: shown, mounted: shown || (keep && been.current), style: shown ? style : hidden };
84
84
  }
85
85
 
@@ -185,13 +185,13 @@ function SegOption({ label, on, onPress, onLayout, style, textStyle, onText, off
185
185
  onLayout={e => onLayout(e.nativeEvent.layout)} style={[segCenter, style, { backgroundColor: 'transparent' }]}>
186
186
  {NATIVE ? (
187
187
  <>
188
- <Animated.Text style={[textStyle, { color: offText, opacity: ink }]}>{label}</Animated.Text>
188
+ <Animated.Text numberOfLines={1} style={[textStyle, { color: offText, opacity: ink }]}>{label}</Animated.Text>
189
189
  {/* (the chosen colour's label, exactly over the other) */}
190
190
  <View pointerEvents="none" style={segOver}>
191
- <Animated.Text accessible={false} style={[textStyle, { color: onText, opacity: t }]}>{label}</Animated.Text>
191
+ <Animated.Text numberOfLines={1} accessible={false} style={[textStyle, { color: onText, opacity: t }]}>{label}</Animated.Text>
192
192
  </View>
193
193
  </>
194
- ) : <Animated.Text style={[textStyle, { color: ink }]}>{label}</Animated.Text>}
194
+ ) : <Animated.Text numberOfLines={1} style={[textStyle, { color: ink }]}>{label}</Animated.Text>}
195
195
  </Tap>
196
196
  );
197
197
  }
@@ -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);