@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
@@ -24,6 +24,7 @@ import { fmtLen } from '../units';
24
24
  import { addCabinet, addHeightLine, CAB, cabinetSpot, deleteCabinets, dimensionFace, editCabinet, editCabinetDepth, snapObjectMove } from './face';
25
25
  import type { RailItem } from './mode';
26
26
  import { openKeypad, refreshModes, renderElevation, toast, updateCtx, updateHint, updateRail, updateTopBar } from './ports';
27
+ import { waitForScale } from './scaleGate';
27
28
 
28
29
  /*
29
30
  The wall view: one straight wall seen straight on, edited with the plan's own tools. Opening a wall makes the
@@ -50,8 +51,12 @@ export const elevOpen = (): boolean => elev.lid != null;
50
51
  const wallBox = () => (elev.frame ? { x0: elev.frame.face[0], y0: 0, x1: elev.frame.face[1], y1: elev.frame.H } : null);
51
52
 
52
53
  /** Open the wall view of a straight wall, seen from the room side. */
53
- /** Open a wall's view: from the side a room is on — or `side`, the face to the left (+1) or right (−1) of its A → B. */
54
+ /**
55
+ * Open a wall's view: from the side a room is on — or `side`, the face to the left (+1) or right (−1) of its A → B.
56
+ * Not before the page's first length: the wall's height is real, its length only a placeholder until then.
57
+ */
54
58
  export function openElevation(lid: string, side?: 1 | -1): void {
59
+ if (waitForScale()) return;
55
60
  if (!elevFrame(planSK(), lid)) { toast('Elevations work on straight walls. Turn the line into a wall first.', { warn: true }); return; }
56
61
  cancelTool();
57
62
  elev.lid = lid; elev.flip = side != null && side !== (roomSideOf(planSK(), lid) || 1);
@@ -331,7 +336,7 @@ export const elevAct = (id: string): ElevAct => JSON.parse(id) as ElevAct;
331
336
  /** A chip's name, as the bar shows it. */
332
337
  export function elevChipName(a: ElevAct): string {
333
338
  if (a.k === 'gap') return a.title;
334
- if (a.k === 'len') return 'Wall length · set in the floor plan';
339
+ if (a.k === 'len') return 'Wall length';
335
340
  if (a.k === 'wallH') return 'Wall height';
336
341
  if (a.k === 'pos') return a.title;
337
342
  if (a.k === 'fdim') return 'Dimension';
@@ -698,7 +703,8 @@ export function wallBar(): { label?: string; items: WallBarItem[]; plus: boolean
698
703
  if (pair) {
699
704
  const name = (id: string) => (planSK().openings[id]?.type === 'door' ? 'Door' : 'Window');
700
705
  const label = name(pair.op) + ' + ' + ('op' in pair.other ? name(pair.other.op).toLowerCase() : 'corner');
701
- return { label, items: [{ ic: 'dim', label: 'Distance', act: 'wallDist' }], plus: true };
706
+ // (the distance between them: the yellow +)
707
+ return { label, items: [], plus: true };
702
708
  }
703
709
  if (ed.sel.length !== 1) return null;
704
710
  const s = ed.sel[0], sk = planSK();
@@ -706,19 +712,10 @@ export function wallBar(): { label?: string; items: WallBarItem[]; plus: boolean
706
712
  const a = elevAct(s.id);
707
713
  return { label: elevChipName(a), items: a.k === 'len' ? [{ ic: 'back', label: 'Go to plan', act: 'wallGoPlan' }] : [], plus: a.k !== 'len' };
708
714
  }
709
- // a door, window or cabinet picked as a whole: its sizes to set, by name (no + — it isn't one dimension)
710
- if (s.type === 'opening') {
711
- const o = sk.openings[s.id];
712
- if (!o) return null;
713
- return { label: openingLabel(o), items: OPENING_SIZES, plus: false };
714
- }
715
- if (s.type === 'cab') {
716
- const face = SK() as WallFace, b = cabBox(face, s.id), cb = face.cabs[s.id];
717
- return {
718
- label: 'Cabinet' + (b ? ' ' + fmtLen(b.u1 - b.u0) + ' L × ' + fmtLen(b.z1 - b.z0) + ' H × ' + fmtLen(cb.depth) + ' D' : ''),
719
- items: [{ ic: 'v', label: 'Height', act: 'cabH' }, { ic: 'h', label: 'Length', act: 'cabW' }, { ic: 'thick', label: 'Depth', act: 'cabD' }], plus: false,
720
- };
721
- }
715
+ // a door, window or cabinet picked as a whole: its sizes to set (no + — it isn't one dimension), with nothing saying
716
+ // them: the wall view shows them (the user's call, 2026-10-27: "get rid of extra dimensions text")
717
+ if (s.type === 'opening') return sk.openings[s.id] ? { items: OPENING_SIZES, plus: false } : null;
718
+ if (s.type === 'cab') return { items: [{ ic: 'v', label: 'Height', act: 'cabH' }, { ic: 'h', label: 'Length', act: 'cabW' }, { ic: 'thick', label: 'Depth', act: 'cabD' }], plus: false };
722
719
  const face = SK() as WallFace;
723
720
  if (s.type === 'line' && face.lines[s.id]?.anchor) return { label: anchorName(s.id), items: [], plus: false };
724
721
  if (s.type === 'line' && face.lines[s.id]?.cab) return { label: 'Cabinet edge', items: [], plus: false };
@@ -17,7 +17,8 @@ import type { SelItem } from '../model/types';
17
17
  /*
18
18
  Pointer routing for the plan canvas, whatever delivers the pointers (DOM pointer events, React Native touches).
19
19
  Pen draws, fingers navigate: once a pen has been seen, touch only pans and zooms. Without a pen, one finger drives
20
- the tool and a second finger turns the action into a pan/pinch. Two-finger tap undoes, three-finger tap redoes.
20
+ the tool and a second finger turns the action into a pan/pinch — a finger's press waits a moment for one first
21
+ (pressTiming). Two-finger tap undoes, three-finger tap redoes.
21
22
  Mouse: middle or right button, or Space held, drags to pan; the wheel zooms.
22
23
  With a sheet up over the drawing (the keypad, a conflict, a question), the drawing only pans and zooms, by any
23
24
  pointer, and a tap on it closes the sheet. View only, a tap on a link's tile asks to open what it's for.
@@ -52,22 +53,40 @@ export function setHostNavigation(on: boolean): void { hostNavigates = on; }
52
53
  */
53
54
  const MOTION_PX = 14;
54
55
 
56
+ /**
57
+ * How long a finger's press waits before the tool hears of it (ms), unless it lifts or moves past a tap first: time
58
+ * for a second finger to land and make it a pan or pinch from the start. Then the first finger never pressed anything:
59
+ * nothing is picked, no bar or sheet shows for it, and nothing is drawn where it landed (the user's report, 2026-10-30:
60
+ * zooming, "there is a brief moment where my touch brings up a bottom sheet"). A pinch's fingers land a few tens of
61
+ * milliseconds apart; iOS holds a scroll view's touches 150 ms and Android a pressed look 100 ms, for the same reason.
62
+ * A second finger after the wait still undoes the press (cancelPrimary). A mouse and a Pencil press at once.
63
+ */
64
+ export const pressTiming = {
65
+ /** tests pin it (0: a finger presses at once) */
66
+ holdMs: 120,
67
+ };
68
+
55
69
  const ptrs = new Map<number, PointerIn>();
56
70
  let penSeen = false, gesture: Gesture | null = null, primary: PointerIn | null = null;
57
71
  /** close: a press under a sheet (see sheetUp) — a tap closes it; open: a view-only press — a tap opens a link's tile */
58
72
  let panDrag: { x: number; y: number; id: number; moved: number; close?: boolean; open?: boolean } | null = null;
59
73
  /** where the primary pointer went down, and whether a gesture on the drawing is in motion */
60
74
  let primaryAt: { x: number; y: number } | null = null, inMotion = false;
75
+ /** the primary pointer has moved past a tap since it went down (see lensPointer) */
76
+ let primaryMoved = false;
61
77
  /**
62
78
  * What a press can change, as it was before the primary pointer went down: if a second finger turns the press into a
63
79
  * pan or pinch, it's put back (see cancelPrimary). A tool's own state is kept a level deep — its presses set its fields,
64
80
  * they don't change what's in them.
65
81
  */
66
82
  let beforePress: { tool: string; st: unknown; sel: SelItem[]; preview: Preview | null; snap: Snap | null } | null = null;
83
+ /** A finger down whose press is waiting (pressTiming): the pointer, where it landed, and each move it's made since. */
84
+ let held: { p: PointerIn; at: PointerIn; moves: PointerIn[]; timer: ReturnType<typeof setTimeout> } | null = null;
67
85
  let wheelT: ReturnType<typeof setTimeout> | undefined;
68
86
  export const keys = { spaceDown: false };
69
87
  onSessionReset(() => {
70
- ptrs.clear(); penSeen = false; gesture = null; primary = null; panDrag = null; keys.spaceDown = false; primaryAt = null; beforePress = null;
88
+ ptrs.clear(); penSeen = false; gesture = null; primary = null; panDrag = null; keys.spaceDown = false; primaryAt = null; beforePress = null; primaryMoved = false;
89
+ dropHeld();
71
90
  setMotion(false);
72
91
  clearTimeout(wheelT);
73
92
  });
@@ -164,6 +183,39 @@ function cancelPrimary(): void {
164
183
  updateCtx(); updateHint(); requestDraw();
165
184
  }
166
185
 
186
+ /** The primary pointer's press: the tool hears it land where `at` says (its pointer `p` may have moved on since). */
187
+ function press(p: PointerIn, at: PointerIn): void {
188
+ primary = p; primaryMoved = false;
189
+ primaryAt = { x: at.x, y: at.y };
190
+ // (what it lands on is picked from the drawing as it is now, not as the last frame painted it)
191
+ paintPending();
192
+ const t = curTool();
193
+ beforePress = { tool: ed.tool, st: t && t.st && typeof t.st === 'object' ? { ...t.st } : t?.st, sel: [...ed.sel], preview: ed.preview, snap: ed.snapState };
194
+ if (t && t.down) t.down(evOf(at));
195
+ }
196
+
197
+ /** The primary pointer moved: past a tap, a gesture is in motion (and the lens may show); the tool follows it. */
198
+ function movePrimary(e: PointerIn): void {
199
+ if (primaryAt && hyp(e.x - primaryAt.x, e.y - primaryAt.y) >= MOTION_PX) { setMotion(true); primaryMoved = true; }
200
+ const t = curTool();
201
+ if (t && t.move) t.move(evOf(e));
202
+ }
203
+
204
+ function dropHeld(): void {
205
+ if (held) clearTimeout(held.timer);
206
+ held = null;
207
+ }
208
+
209
+ /** A finger's waiting press goes ahead: the tool hears it land where it did, then each move it's made since. */
210
+ function releaseHeld(): void {
211
+ const h = held;
212
+ dropHeld();
213
+ // (something else took the drawing meanwhile: a mouse or Pencil press, a sheet, view only)
214
+ if (!h || primary || navigationOnly()) return;
215
+ press(h.p, h.at);
216
+ for (const m of h.moves) movePrimary(m);
217
+ }
218
+
167
219
  function endPtr(e: PointerIn, cancel: boolean): void {
168
220
  const p = ptrs.get(e.id);
169
221
  ptrs.delete(e.id);
@@ -175,6 +227,8 @@ function endPtr(e: PointerIn, cancel: boolean): void {
175
227
  return;
176
228
  }
177
229
  if (gesture && p && p.kind === 'touch') { if (!touchList().length) endGesture(e.t); return; }
230
+ // (a finger let go while its press waited: a tap, pressed and let go now; lost, it did nothing)
231
+ if (held && e.id === held.at.id) { if (cancel) dropHeld(); else releaseHeld(); }
178
232
  if (primary && e.id === primary.id) {
179
233
  primary = null;
180
234
  const t = curTool();
@@ -199,6 +253,8 @@ export function pointerDown(e: PointerIn): void {
199
253
  if (e.kind === 'touch') {
200
254
  const touches = touchList();
201
255
  if (penSeen || touches.length >= 2 || navOnly) {
256
+ // (a second finger while the first one's press waited: that never pressed anything)
257
+ dropHeld();
202
258
  if (primary && primary.kind === 'touch') cancelPrimary();
203
259
  // (a finger joining one already moving the view: the pinch is measured from here, none of its first move lost)
204
260
  if (!gesture) startGesture(e.t); else { gesture.max = Math.max(gesture.max, touches.length); gesture.last = gState(touches); }
@@ -206,13 +262,13 @@ export function pointerDown(e: PointerIn): void {
206
262
  }
207
263
  }
208
264
  if (primary || navOnly) return;
209
- primary = p;
210
- primaryAt = { x: e.x, y: e.y };
211
- // (what it lands on is picked from the drawing as it is now, not as the last frame painted it)
212
- paintPending();
213
- const t = curTool();
214
- beforePress = { tool: ed.tool, st: t && t.st && typeof t.st === 'object' ? { ...t.st } : t?.st, sel: [...ed.sel], preview: ed.preview, snap: ed.snapState };
215
- if (t && t.down) t.down(evOf(e));
265
+ // a finger's press waits a moment, in case another finger lands (pressTiming)
266
+ if (e.kind === 'touch' && pressTiming.holdMs > 0) {
267
+ dropHeld();
268
+ held = { p, at: { ...e }, moves: [], timer: setTimeout(releaseHeld, pressTiming.holdMs) };
269
+ return;
270
+ }
271
+ press(p, e);
216
272
  }
217
273
 
218
274
  export function pointerMove(e: PointerIn): void {
@@ -225,15 +281,29 @@ export function pointerMove(e: PointerIn): void {
225
281
  }
226
282
  if (p) { p.x = e.x; p.y = e.y; }
227
283
  if (gesture && p && p.kind === 'touch') { updateGesture(); return; }
284
+ // (a finger whose press is waiting: its moves wait with it, and one past a tap sends them all now — a drag)
285
+ if (held && e.id === held.at.id) {
286
+ held.moves.push({ ...e });
287
+ if (hyp(e.x - held.at.x, e.y - held.at.y) >= MOTION_PX) releaseHeld();
288
+ return;
289
+ }
228
290
  const t = curTool();
229
291
  if (!t) return;
230
- if (primary && e.id === primary.id) {
231
- if (primaryAt && hyp(e.x - primaryAt.x, e.y - primaryAt.y) >= MOTION_PX) setMotion(true);
232
- if (t.move) t.move(evOf(e));
233
- }
292
+ if (primary && e.id === primary.id) movePrimary(e);
234
293
  else if (!primary && e.kind !== 'touch' && !navigationOnly()) { ed.lastPointerTouch = false; if (t.hover) t.hover(evOf(e)); }
235
294
  }
236
295
 
296
+ /**
297
+ * The finger or Apple Pencil working the drawing, where it is on the canvas, once it's moved past a tap placing or
298
+ * dragging something (Tool.magnify): what a device's magnifier is centred on (render/lens.ts). Null for a mouse, a tap,
299
+ * a pan or a pinch, and a drawing that can't be edited or has a sheet up.
300
+ */
301
+ export function lensPointer(): { x: number; y: number } | null {
302
+ if (!primary || primary.kind === 'mouse' || !primaryMoved || navigationOnly()) return null;
303
+ const t = curTool();
304
+ return t && (!t.magnify || t.magnify()) ? { x: primary.x, y: primary.y } : null;
305
+ }
306
+
237
307
  export function pointerUp(e: PointerIn): void { endPtr(e, false); settle(); }
238
308
  export function pointerCancel(e: PointerIn): void { endPtr(e, true); settle(); }
239
309
 
@@ -5,7 +5,7 @@ import { saveSoon } from '../model/store';
5
5
  import { fitView, requestDraw } from '../render/view';
6
6
  import { cancelTool, setTool, tools } from '../tools/registry';
7
7
  import { applyCtxAction, deleteSelection } from './actions';
8
- import { addLinks, linkPlusVisible, openSelectedLink, selectedLink } from './linkActions';
8
+ import { addLinks, linkPlusVisible, openSelectedLink, selectedLink, selectedLinkedImage } from './linkActions';
9
9
  import { keys } from './gestures';
10
10
  import { keypadApply, keypadDeleteLast, keypadInsert, keypadOpen } from './keypad';
11
11
  import { confirmCancel, confirmChoose, confirmOpen } from './confirm';
@@ -89,7 +89,7 @@ export function keyDown(e: KeyIn): boolean {
89
89
  }
90
90
  // Annotate: + adds files and measurements (the + by the rail), Enter opens the selected link
91
91
  if ((e.key === '+' || e.key === '=') && linkPlusVisible()) { void addLinks(); return true; }
92
- if (e.key === 'Enter' && ed.mode === 'annotate' && selectedLink()) { openSelectedLink(); return true; }
92
+ if (e.key === 'Enter' && ed.mode === 'annotate' && (selectedLink() || selectedLinkedImage())) { openSelectedLink(); return true; }
93
93
  if (e.shift) {
94
94
  const c = SHIFT_CONS[e.key.toUpperCase()];
95
95
  if (c && ed.mode === 'draft') { applyCtxAction(c); return true; }
@@ -5,11 +5,14 @@ import { pushHistory } from '../model/history';
5
5
  import { uid } from '../model/ids';
6
6
  import { isLink, LINK_COLOR, LINK_MAX_IN, LINK_MIN_IN, LINK_TILE_PX, linkInfoOf, linkName, linkSide, linkSnapshot } from '../model/links';
7
7
  import { dimMeasurement } from '../model/measurement';
8
- import { linkAt } from '../render/hit';
8
+ import { imageLinkAt, linkAt } from '../render/hit';
9
9
  import { askConfirm } from './confirm';
10
- import { SK, store } from '../model/store';
11
- import type { LinkTarget, Note } from '../model/types';
12
- import { cvs, linkBox, mainT, requestDraw, s2w } from '../render/view';
10
+ import { imageById } from '../model/images';
11
+ import { settings } from '../model/settings';
12
+ import { page, SK, store } from '../model/store';
13
+ import type { LinkTarget, Note, Underlay } from '../model/types';
14
+ import { tileTypeOf } from '../render/linkTile';
15
+ import { cvs, linkBox, linkThumb, mainT, requestDraw, s2w, underlayMid } from '../render/view';
13
16
  import { setTool } from '../tools/registry';
14
17
  import { elevOpen } from './elevation';
15
18
  import { is3d } from './view3dState';
@@ -126,20 +129,94 @@ export function toggleLeader(id: string): void {
126
129
  afterEdit();
127
130
  }
128
131
 
129
- /** Open what the selected tile is for. */
132
+ /** Open what the selected tile, or the selected expanded photo, is for. */
130
133
  export function openSelectedLink(): void {
131
- const n = selectedLink();
132
- if (!n) return;
133
- if (!canOpenLink(n.link)) { toast(linkInfoOf(n.link)?.exists === false ? 'That’s been removed from the job, so it can’t be opened.' : 'That can’t be opened from here.', { warn: true }); return; }
134
- openLink(n.link);
134
+ const t = selectedLink()?.link ?? selectedLinkedImage()?.link;
135
+ if (t) openTarget(t);
135
136
  }
136
137
 
137
- /** A view-only tap on the plan: on a link's tile, the person is asked, then what it's for opens. */
138
+ function openTarget(t: LinkTarget): void {
139
+ if (!canOpenLink(t)) { toast(linkInfoOf(t)?.exists === false ? 'That’s been removed from the job, so it can’t be opened.' : 'That can’t be opened from here.', { warn: true }); return; }
140
+ openLink(t);
141
+ }
142
+
143
+ /** An expanded photo's badge, tapped: what it's for opens. */
144
+ export function openImageLink(id: string): void {
145
+ const t = imageById(id)?.link;
146
+ if (t) openTarget(t);
147
+ }
148
+
149
+ /*
150
+ A photo's tile expands into the photo whole, and back (the user's call, 2026-10-26: "for image objects … you should
151
+ be able to expand and condense the tile … this is how skia works, it expands the image to full size + shows a link
152
+ icon in the corner"). Expanded, it's one of the page's images — moved, sized, stretched, turned, faded and locked as
153
+ any is — with the link's badge in its corner; Expand in the bar (on) folds it back into its tile where it is. An image
154
+ from the computer has no link, so none of this. Each way is one undo step, and what was selected stays selected.
155
+ */
156
+
157
+ /** The one selected image, if it's a photo expanded from its tile and all that's selected. */
158
+ export function selectedLinkedImage(): (Underlay & { link: LinkTarget }) | null {
159
+ if (ed.sel.length !== 1 || ed.sel[0].type !== 'underlay') return null;
160
+ const u = imageById(ed.sel[0].id);
161
+ return u?.link ? (u as Underlay & { link: LinkTarget }) : null;
162
+ }
163
+
164
+ /** Whether a tile can expand: a photo's (not a sketch's, a document's or a measurement's), and the photo still there. */
165
+ export function canExpand(n: Note & { link: LinkTarget }): boolean {
166
+ const info = linkInfoOf(n.link);
167
+ return tileTypeOf(n, info) === 'image' && info?.exists !== false;
168
+ }
169
+
170
+ /**
171
+ * A photo's tile, expanded: the photo whole where the tile was, as big as it fits in 60% of the screen across and 80%
172
+ * down (an image from the computer is placed 60% across), at full strength, free to move.
173
+ */
174
+ export function expandLink(id: string): void {
175
+ const sk = SK(), at = sk.notes.findIndex(x => x.id === id), n = sk.notes[at];
176
+ if (!n || !isLink(n) || !canExpand(n) || ed.readOnly) return;
177
+ const url = linkInfoOf(n.link)?.thumbUrl, im = url ? linkThumb(url) : null;
178
+ if (!im?.complete || !im.naturalWidth) { toast('The photo is still loading. Try again in a moment.', { warn: true }); return; }
179
+ const T = mainT(), aspect = (im.naturalHeight || 1) / im.naturalWidth;
180
+ const w = Math.min(cvs.W * 0.6, (cvs.H * 0.8) / aspect) / T.s, h = w * aspect;
181
+ const { x = 0, y = 0, link, ...tile } = n;
182
+ pushHistory();
183
+ sk.notes.splice(at, 1);
184
+ const u: Underlay = { id: uid('img'), src: '', x: x - w / 2, y: y + h / 2, w, h, opacity: 1, locked: false, link, tile };
185
+ page().underlays.push(u);
186
+ settings.layers.underlay = true;
187
+ ed.sel = [{ type: 'underlay', id: u.id }];
188
+ afterEdit();
189
+ }
190
+
191
+ /** An expanded photo, folded back into its tile: where the photo's middle is, the page's tiles' size, its leader back. */
192
+ export function collapseLink(id: string): void {
193
+ const u = imageById(id);
194
+ if (!u?.link || ed.readOnly) return;
195
+ if (u.locked) { toast('This photo is locked. Unlock it to fold it back into its tile.', { warn: true }); return; }
196
+ const sk = SK(), m = underlayMid(u), was = u.tile ? (JSON.parse(JSON.stringify(u.tile)) as Omit<Note, 'x' | 'y' | 'link'>) : null;
197
+ const ts = pageLinkSize(sk) ?? was?.ts ?? Math.round(clamp(LINK_TILE_PX / mainT().s, LINK_MIN_IN, LINK_MAX_IN) * 4) / 4;
198
+ const n: Note = { ...(was ?? { id: '', type: 'link', color: LINK_COLOR }), type: 'link', x: m.x, y: m.y, ts, link: u.link };
199
+ if (!n.id || sk.notes.some(x => x.id === n.id)) n.id = uid('lk');
200
+ pushHistory();
201
+ page().underlays.splice(page().underlays.indexOf(u), 1);
202
+ sk.notes.push(n);
203
+ ed.sel = [{ type: 'note', id: n.id }];
204
+ afterEdit();
205
+ }
206
+
207
+ /** Expand in the bar: the selected photo's tile expanded, or the selected expanded photo folded back. */
208
+ export function toggleExpand(): void {
209
+ const n = selectedLink(), u = selectedLinkedImage();
210
+ if (n) expandLink(n.id);
211
+ else if (u) collapseLink(u.id);
212
+ }
213
+
214
+ /** A view-only tap on the plan: on a link's tile or an expanded photo's badge, the person is asked, then what it's for opens. */
138
215
  export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
139
216
  if (!ed.readOnly || !canLink()) return;
140
- const n = linkAt(sx, sy, touch);
141
- if (!n || !canOpenLink(n.link)) return;
142
- const t = n.link, where = linkInfoOf(t)?.groupName ?? t.groupName;
217
+ const t = linkAt(sx, sy, touch)?.link ?? imageLinkAt(sx, sy, touch)?.link;
218
+ if (!t || !canOpenLink(t)) return;
219
+ const where = linkInfoOf(t)?.groupName ?? t.groupName;
143
220
  askConfirm({
144
221
  title: 'Open ' + linkName(t) + '?',
145
222
  msg: where ? 'It’s in ' + where + '.' : undefined,
@@ -58,12 +58,13 @@ export const railIcon = (id: string): string => WALL_ICON[id] ?? id;
58
58
  /** The tool rail for each mode. 'toggle' items switch something on and off; 'act' items run an action. */
59
59
  export const RAIL: Record<Mode, RailItem[]> = {
60
60
  draft: [
61
- // drawing; then the building (walls, what goes in them, spaces); then measuring and guides
61
+ // drawing; then the building (walls, what goes in them, spaces); then guides
62
62
  ['select', 'Select', 'V'], ['line', 'Line', 'L'], ['freehand', 'Draw to shape', 'S'], ['rect', 'Rectangle', 'R'], ['circle', 'Circle', 'C'], ['arc', 'Arc', 'A'], '|',
63
63
  ['door', 'Door', 'B'], ['window', 'Window', 'N'], ['room', 'Space', 'M'], '|',
64
- // Trim (T) and Offset (O) stay on the keyboard; Offset is also in the bar for a selected line.
64
+ // Trim (T), Offset (O) and Dimension (D) stay on the keyboard; Offset is also in the bar for a selected line, and
65
+ // measuring is the yellow + for what's selected (the user's call, 2026-10-24: no Dimension on the rail).
65
66
  // The toggles together: what's drawn is walls, or guides (one or the other, never both)
66
- ['dim', 'Dimension', 'D'], ['wall', 'Walls', 'W', 'toggle'], ['constr', 'Construction lines', 'X', 'toggle'], '|',
67
+ ['wall', 'Walls', 'W', 'toggle'], ['constr', 'Construction lines', 'X', 'toggle'], '|',
67
68
  ['undo', 'Undo', '⌘Z', 'act'], ['redo', 'Redo', '⇧⌘Z', 'act'],
68
69
  ],
69
70
  annotate: [
@@ -83,11 +83,10 @@ export type LayerKey = keyof Layers | 'showCons';
83
83
  export const LAYER_ROWS: [LayerKey, string, string][] = [
84
84
  ['construction', 'Construction lines', 'Dashed guides'],
85
85
  ['dims', 'Driving dimensions', 'Blue chips that set sizes'],
86
- ['print', 'Print dimensions', 'Labels for the crew; never move geometry'],
87
86
  ['rooms', 'Spaces', 'Fill, name and area'],
88
87
  ['cabinets', 'Cabinets', 'Their footprints, from each wall’s view'],
89
88
  ['annot', 'Markup', 'Pen, arrows, shapes, clouds and notes'],
90
- ['showCons', 'Constraint icons', 'H, V, ∥, ⊥ badges'],
89
+ ['showCons', 'Constraint icons', 'H, V, ∥, ⊥ and lock badges'],
91
90
  ];
92
91
 
93
92
  export const layerOn = (k: LayerKey): boolean => (k === 'showCons' ? settings.showCons : settings.layers[k]);
@@ -0,0 +1,32 @@
1
+ import { isUnscaled } from '../model/scale';
2
+ import { planSK } from '../model/store';
3
+ import { fmtLen } from '../units';
4
+ import { toast } from './ports';
5
+
6
+ /*
7
+ Before a new page's first length (model/scale.ts), its sizes are placeholders. So nothing shows one as if it were
8
+ real, and what needs real sizes waits: the 3D view, the wall view, doors, windows and cabinets (the user's call,
9
+ 2026-10-29), and the tools whose typed size has nothing drawn to be compared with (offset, fillet, chamfer).
10
+ */
11
+
12
+ /** The page being drawn has no scale yet: its sizes are placeholders. */
13
+ export const noScaleYet = (): boolean => isUnscaled(planSK());
14
+
15
+ export const NEEDS_SCALE = 'Enter a length first: select a line and tap +. It sets the size of the whole drawing.';
16
+
17
+ /** What needs real sizes, asked for before the page has its scale: says what to do first, and true (wait). */
18
+ export function waitForScale(): boolean {
19
+ if (!noScaleYet()) return false;
20
+ toast(NEEDS_SCALE, { warn: true });
21
+ return true;
22
+ }
23
+
24
+ /** Tools that place something with a real size, or take one typed: doors, windows and cabinets (the plan's and the wall view's), offsets. */
25
+ const SIZED_TOOLS = new Set(['door', 'window', 'wdoor', 'wwindow', 'cabinet', 'height', 'offset']);
26
+ export const toolWaitsForScale = (t: string): boolean => SIZED_TOOLS.has(t);
27
+
28
+ /** A length being entered, as its label shows it: what's typed, else what it measures now — nothing while that's a placeholder. */
29
+ export const pendingLabel = (typed: number | null, now: number): string => (typed != null ? fmtLen(typed) : noScaleYet() ? '' : fmtLen(now));
30
+
31
+ /** A keypad's starting value: what it measures now, or nothing while that's only a placeholder. */
32
+ export const startingValue = (v: number): { initial: string; value?: number } => (noScaleYet() ? { initial: '' } : { initial: fmtLen(v), value: v });
@@ -11,9 +11,13 @@ import { faceLength } from '../model/wallfaces';
11
11
  import { planSK, SK } from '../model/store';
12
12
  import type { Sketch } from '../model/types';
13
13
  import { fmtArea, fmtLen } from '../units';
14
+ import { noScaleYet } from './scaleGate';
14
15
 
15
16
  /* What is selected, grouped by kind, and the one-line hint describing it (bold parts in <b>). */
16
17
 
18
+ /** A size in a hint, after a space — none on a drawing without its scale yet (a placeholder isn't a size). */
19
+ const sized = (text: string): string => (noScaleYet() ? '' : ' ' + text);
20
+
17
21
  export function selInfo() {
18
22
  const g = (t: string) => ed.sel.filter(s => s.type === t).map(s => s.id);
19
23
  const circles = g('circle'), arcs = g('arc');
@@ -54,13 +58,13 @@ export function selectionHint(): string {
54
58
  if (s.n === 1 && s.lines.length && sk.lines[s.lines[0]]?.anchor) return '<b>Set by the plan</b> · pick a point or line too, to constrain or dimension to it';
55
59
  if (s.n === 1 && s.lines.length) {
56
60
  const l = sk.lines[s.lines[0]];
57
- return '<b>' + (l.kind === 'wall' ? 'Wall' : l.constr ? 'Construction line' : 'Line') + ' ' + fmtLen(lineLen(sk, l)) + '</b> · ' + (lineDefined(ed.ana, l) ? 'fully defined' : lengthDimOf(sk, s.lines[0]) ? 'pick a constraint below or drag to adjust' : 'tap <b>+</b> below ' + forLength() + ', or pick a constraint');
61
+ return '<b>' + (l.kind === 'wall' ? 'Wall' : l.constr ? 'Construction line' : 'Line') + sized(fmtLen(lineLen(sk, l))) + '</b> · ' + (lineDefined(ed.ana, l) ? 'fully defined' : lengthDimOf(sk, s.lines[0]) ? 'pick a constraint below or drag to adjust' : 'tap <b>+</b> below ' + forLength() + ', or pick a constraint');
58
62
  }
59
63
  if (s.n === 1 && s.wfaces.length) {
60
64
  const [id, side] = edgeOf(s.wfaces[0]);
61
65
  // (a curved wall's: its diameter across it)
62
- if (!sk.lines[id]) return '<b>' + edgeLabel(sk, id, side) + ' Ø ' + fmtLen(measure(sk, { type: 'diam', c: id, face: side })) + '</b> · tap <b>+</b> for its diameter';
63
- return '<b>' + edgeLabel(sk, id, side) + ' ' + fmtLen(faceLength(sk, id, side)) + '</b> · tap <b>+</b> ' + forLength() + ', or pick another edge or a point to measure from it';
66
+ if (!sk.lines[id]) return '<b>' + edgeLabel(sk, id, side) + sized('Ø ' + fmtLen(measure(sk, { type: 'diam', c: id, face: side }))) + '</b> · tap <b>+</b> for its diameter';
67
+ return '<b>' + edgeLabel(sk, id, side) + sized(fmtLen(faceLength(sk, id, side))) + '</b> · tap <b>+</b> ' + forLength() + ', or pick another edge or a point to measure from it';
64
68
  }
65
69
  if (edgePair(s)) {
66
70
  const one = s.wfaces.length === 2 && edgeOf(s.wfaces[0])[0] === edgeOf(s.wfaces[1])[0];
@@ -75,6 +79,7 @@ export function selectionHint(): string {
75
79
  if (s.n === 1 && s.cons.length) return '<b>' + consLabel(sk.cons[s.cons[0]]) + '</b> constraint · delete it to free the shape';
76
80
  if (s.n === 1 && s.rooms.length) {
77
81
  const r = sk.rooms[s.rooms[0]], g = roomGeom(sk, r);
82
+ if (noScaleYet()) return '<b>' + r.name + '</b> · its area shows once the drawing has a length';
78
83
  return '<b>' + r.name + '</b> · ' + fmtArea(g.area) + ' · ' + fmtLen(g.per) + ' perimeter';
79
84
  }
80
85
  if (s.n === 1 && s.openings.length) {
@@ -86,7 +91,6 @@ export function selectionHint(): string {
86
91
  }
87
92
  if (s.n === 1 && s.dims.length) {
88
93
  const c = sk.cons[s.dims[0]];
89
- if (c.layer === 'print') return '<b>Print dimension</b> · drag to move the label';
90
94
  if (c.measurementRef) return '<b>' + consLabel(c) + '</b> · linked to <b>' + linkName(c.measurementRef) + '</b> · tap <b>+</b> to change it';
91
95
  return '<b>' + consLabel(c) + '</b> · tap <b>+</b> to change it';
92
96
  }
@@ -96,9 +100,11 @@ export function selectionHint(): string {
96
100
  if (part === 'w') return '<b>' + (planSK().openings[s.odims[0].split('|')[0]]?.type === 'door' ? 'Door' : 'Window') + ' width</b> · tap <b>+</b> to change it';
97
101
  return part.startsWith('d:') ? '<b>Door/window dimension</b> · tap <b>+</b> to change it' : '<b>Distance to the wall end</b> · tap <b>+</b> to lock it';
98
102
  }
99
- if (s.n === 1 && s.image) return imageById(ed.sel[0].id)?.locked
100
- ? '<b>Image</b> · locked — tap Unlock to move or resize it'
101
- : '<b>Image</b> · drag to move · corners scale, side handles stretch';
103
+ if (s.n === 1 && s.image) {
104
+ // (a photo of the job's, expanded from its tile: its link opens it, and Expand folds it back)
105
+ const u = imageById(ed.sel[0].id), what = u?.link ? '<b>Photo</b>' : '<b>Image</b>', link = u?.link ? ' · its link opens it' : '';
106
+ return u?.locked ? what + ' · locked — tap Unlock to move or resize it' + link : what + ' · drag to move · corners scale, side handles stretch' + link;
107
+ }
102
108
  if (s.n === 1 && s.notes.length && SK().notes.find(n => n.id === s.notes[0])?.type === 'link') return '<b>Link</b> · drag to move · open it, or point it at the drawing with a leader, below · Size sizes every tile on the page';
103
109
  if (s.notes.length) return 'Drag to move. Turn a pen stroke into lines below.';
104
110
  return '<b>' + s.n + ' selected</b> · choose a constraint below';
@@ -1,5 +1,6 @@
1
1
  import { ed } from '../app/state';
2
2
  import { settings } from '../model/settings';
3
+ import { noScaleYet, toolWaitsForScale } from './scaleGate';
3
4
 
4
5
  /*
5
6
  What a tool does, said in a line when it's picked from the rail (a quick note under the Annotate / Draft tabs that
@@ -34,14 +35,21 @@ const TIPS: Record<string, string> = {
34
35
  text: 'Tap where it goes, then type.',
35
36
  };
36
37
 
38
+ /** The tools that draw shapes: on a page without its scale, what they draw is a sketch. */
39
+ const SKETCHING = new Set(['line', 'freehand', 'rect', 'circle', 'arc']);
40
+
37
41
  /**
38
42
  * The line for a rail item just picked (its name in bold), or null if it has none (Undo, a colour, an action) or the
39
43
  * notes are turned off (••• → Tool tips).
40
44
  */
41
45
  export function toolTip(id: string, label: string): string | null {
42
46
  if (settings.toolTips === false) return null;
47
+ // (on a page without its scale a door, a window or an offset isn't picked: a toast says to enter a length first)
48
+ if (toolWaitsForScale(id) && noScaleYet()) return null;
43
49
  if (id === 'constr') return ed.drawConstr ? '<b>Construction lines on</b> · new lines are dashed guides.' : '<b>Construction lines off</b> · new lines are solid.';
44
50
  if (id === 'wall') return ed.drawWalls ? '<b>Walls on</b> · what you draw is walls.' : '<b>Walls off</b> · what you draw is lines.';
45
51
  const t = TIPS[id];
52
+ // drawing on a page without its scale: sketched in proportion, sized by its first length (editor/scaleGate.ts)
53
+ if (t && SKETCHING.has(id) && noScaleYet()) return '<b>' + label + '</b> · ' + t + ' Your first length sets the size.';
46
54
  return t ? '<b>' + label + '</b> · ' + t : null;
47
55
  }
@@ -16,6 +16,7 @@ import type { KeyIn } from './keys';
16
16
  import { setMode } from './mode';
17
17
  import { dismissConflict, dismissPopovers, refreshModes, updateCtx, updateHint, updateRail } from './ports';
18
18
  import { command3d, pick3d, v3 } from './view3dState';
19
+ import { waitForScale } from './scaleGate';
19
20
 
20
21
  /*
21
22
  The 3D tab: opening and leaving it, its rail's views, what the bar shows for something tapped in it (its sizes, the
@@ -30,9 +31,12 @@ import { command3d, pick3d, v3 } from './view3dState';
30
31
  export const view3dKey = (): string =>
31
32
  [store.curPageId, ed.editSeq, settings.wallH, v3.cutaway, v3.cmd.seq, v3.pick ? v3.pick.kind + ':' + v3.pick.id : ''].join('|');
32
33
 
33
- /** Show the 3D tab: whatever tool was in hand is put down, and nothing is selected. */
34
+ /**
35
+ * Show the 3D tab: whatever tool was in hand is put down, and nothing is selected. Not before the page's first length:
36
+ * its walls' heights are real and their lengths only placeholders until then (editor/scaleGate.ts).
37
+ */
34
38
  export function open3d(): void {
35
- if (v3.open) return;
39
+ if (v3.open || waitForScale()) return;
36
40
  cancelTool();
37
41
  dismissPopovers();
38
42
  if (elevOpen()) closeElevation();
package/src/export/pdf.ts CHANGED
@@ -20,10 +20,13 @@ const SCALES: [k: number, label: string][] = [
20
20
  [3 / 16, '3/16"'], [1 / 8, '1/8"'], [3 / 32, '3/32"'], [1 / 16, '1/16"'], [1 / 32, '1/32"'],
21
21
  ];
22
22
 
23
- /** Pick a scale for a drawing w × h inches: points per world inch, and its label. */
24
- export function pickScale(w: number, h: number): { s: number; label: string } {
23
+ /**
24
+ * Pick a scale for a drawing w × h inches: points per world inch, and its label. A sketch without a scale yet (its
25
+ * sizes only placeholders: model/scale.ts) is fitted to the sheet, not to scale.
26
+ */
27
+ export function pickScale(w: number, h: number, sketchOnly = false): { s: number; label: string } {
25
28
  const aw = AREA.x1 - AREA.x0, ah = AREA.y1 - AREA.y0;
26
- for (const [k, label] of SCALES) {
29
+ if (!sketchOnly) for (const [k, label] of SCALES) {
27
30
  const s = (72 * k) / 12;
28
31
  if (w * s <= aw && h * s <= ah) return { s, label: label + " = 1'-0\"" };
29
32
  }
@@ -166,7 +169,7 @@ export function sketchToPdf(sk: Sketch, opts: PdfOptions = {}): string {
166
169
  const rec = new Recorder();
167
170
  emitDrawing(sk, rec, opts);
168
171
  const bb = rec.bbox();
169
- const { s, label } = bb ? pickScale(bb.x1 - bb.x0, bb.y1 - bb.y0) : { s: 1, label: 'Not to scale' };
172
+ const { s, label } = bb ? pickScale(bb.x1 - bb.x0, bb.y1 - bb.y0, !!sk.unscaled) : { s: 1, label: 'Not to scale' };
170
173
  const cx = bb ? (bb.x0 + bb.x1) / 2 : 0, cy = bb ? (bb.y0 + bb.y1) / 2 : 0;
171
174
  const ox = (AREA.x0 + AREA.x1) / 2, oy = (AREA.y0 + AREA.y1) / 2;
172
175
  const T = (p: XY): XY => ({ x: ox + (p.x - cx) * s, y: oy + (p.y - cy) * s });