@reekon-tools/boldr-cad 1.7.0 → 1.8.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.
@@ -24,6 +24,7 @@ import { elevOpen, fitState, OPENING_SIZES, openElevation, overflow, restoreFit,
24
24
  import { pushedOut } from '../model/facesolve';
25
25
  import { dismissPopovers, editRoomName, openKeypad, openPalette, toast, toggleDimSize, toggleImageOpacity, toggleImageRotation, toggleTileSize, updateCtx, updateTopBar } from './ports';
26
26
  import { dimSizeVisible } from './dimSize';
27
+ import { makeReference, makeRefVisible } from './refDim';
27
28
  import { cornerLines, edgePair, selInfo } from './selection';
28
29
  import { restackImage } from './project';
29
30
  import { imageById } from '../model/images';
@@ -323,7 +324,18 @@ function filletCorner2(): string | null {
323
324
  }
324
325
 
325
326
  /** Buttons for the bottom bar: they adapt to the tool and the selection (Lock first, when offered). */
326
- export const barItems = (): CtxEntry[] => arrangeLead(ctxItems());
327
+ export const barItems = (): CtxEntry[] => arrangeLead(withMakeRef(ctxItems()));
328
+
329
+ /**
330
+ * Make reference (editor/refDim.ts), wherever the selection shows a grey number to keep: after Lock and the bar's name
331
+ * for what's picked, ahead of the rest — one place for it, whatever bar it's in.
332
+ */
333
+ function withMakeRef(items: CtxEntry[]): CtxEntry[] {
334
+ if (!makeRefVisible()) return items;
335
+ const at = items.findIndex(e => !(typeof e === 'object' && 'lbl' in e));
336
+ const item: CtxItem = { ic: 'ref', label: 'Make reference', act: 'makeRef' };
337
+ return at < 0 ? [...items, item] : [...items.slice(0, at), item, ...items.slice(at)];
338
+ }
327
339
 
328
340
  /** The yellow + only appears for something it can measure: not for constraint badges or spaces alone. */
329
341
  const DRAW_TOOLS = new Set(['line', 'freehand', 'rect', 'circle', 'arc']);
@@ -654,6 +666,7 @@ export function applyCtxAction(act: string, anchor?: unknown): void {
654
666
  }
655
667
  case 'editdim': editDim(s.dims[0]); return;
656
668
  case 'toggleref': toggleRef(s.dims[0]); return;
669
+ case 'makeRef': makeReference(); return;
657
670
  case 'odimRef': { const [op, part] = s.odims[0].split('|'); toggleOpeningDim(op, part.slice(2)); return; }
658
671
  case 'renameRoom': editRoomName(s.rooms[0]); return;
659
672
  // (the room is on the left of the wall's start → end when there's none either side of it)
@@ -5,12 +5,12 @@ import { pushHistory } from '../model/history';
5
5
  import { uid } from '../model/ids';
6
6
  import { isLink, LINK_COLOR, LINK_HARD_MAX_IN, LINK_HARD_MIN_IN, LINK_MAX_IN, LINK_MIN_IN, LINK_SIDE_IN, LINK_TILE_PX, linkInfoOf, linkName, linkSide, linkSnapshot } from '../model/links';
7
7
  import { dimMeasurement } from '../model/measurement';
8
- import { imageLinkAt, linkAt } from '../render/hit';
8
+ import { hitAny, imageLinkAt, linkAt } from '../render/hit';
9
9
  import { askConfirm } from './confirm';
10
10
  import { imageById } from '../model/images';
11
11
  import { settings } from '../model/settings';
12
12
  import { page, SK, store } from '../model/store';
13
- import type { LinkTarget, Note, Sketch, Underlay } from '../model/types';
13
+ import type { LinkTarget, Note, SelItem, Sketch, Underlay } from '../model/types';
14
14
  import { tileTypeOf } from '../render/linkTile';
15
15
  import { cvs, linkBox, linkThumb, mainT, requestDraw, s2w, underlayMid } from '../render/view';
16
16
  import { setTool } from '../tools/registry';
@@ -18,6 +18,7 @@ import { elevOpen } from './elevation';
18
18
  import { is3d } from './view3dState';
19
19
  import { besideOpen, canLink, canOpenBeside, linksHost, openLink, openLinkBeside, pickLinks } from './links';
20
20
  import { toast } from './ports';
21
+ import { refTarget } from './refDim';
21
22
 
22
23
  /*
23
24
  Linking. In Annotate the + puts the job's files the person picks on the plan as tiles (selected, to move them where
@@ -270,11 +271,16 @@ export function toggleExpand(): void {
270
271
  else if (u) collapseLink(u.id);
271
272
  }
272
273
 
273
- /** 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. */
274
+ /**
275
+ * 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
276
+ * opens. Anywhere else, what's under it is picked to read its size — a line, a wall or its edge, a corner, a curve:
277
+ * its grey number shows (editor/refDim.ts; the user's call, 2026-11-05) — and a tap on nothing of the kind lets go.
278
+ */
274
279
  export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
275
- if (!ed.readOnly || !canLink()) return;
276
- const t = linkAt(sx, sy, touch)?.link ?? imageLinkAt(sx, sy, touch)?.link;
277
- if (!t || !canOpenLink(t)) return;
280
+ if (!ed.readOnly) return;
281
+ const t = canLink() ? linkAt(sx, sy, touch)?.link ?? imageLinkAt(sx, sy, touch)?.link : undefined;
282
+ if (!t) { pickToRead(sx, sy, touch); return; }
283
+ if (!canOpenLink(t)) return;
278
284
  const where = linkInfoOf(t)?.groupName ?? t.groupName;
279
285
  askConfirm({
280
286
  title: 'Open ' + linkName(t) + '?',
@@ -287,6 +293,19 @@ export function readOnlyTap(sx: number, sy: number, touch: boolean): void {
287
293
  });
288
294
  }
289
295
 
296
+ /** What a view-only tap picks to read the size of: one thing the + measures alone (the selection is otherwise empty). */
297
+ const READABLE = new Set(['line', 'wface', 'point', 'circle', 'arc']);
298
+
299
+ function pickToRead(sx: number, sy: number, touch: boolean): void {
300
+ const h = hitAny(sx, sy, { touch, faces: true });
301
+ // (a wall picked whole: the side it was tapped on, which side of it its length is read on between two spaces)
302
+ if (h?.side && (h.type === 'line' || h.type === 'circle' || h.type === 'arc')) ed.wallTap = { id: h.id, side: h.side };
303
+ ed.sel = h && !h.weak && READABLE.has(h.type) ? [{ type: h.type as SelItem['type'], id: h.id }] : [];
304
+ // (a point that isn't a corner has no size to read)
305
+ if (ed.sel.length && !refTarget()) ed.sel = [];
306
+ requestDraw();
307
+ }
308
+
290
309
  /** Let go of a dimension's link: its value stays, the drawing's alone now. */
291
310
  export function unlinkDim(id: string): void {
292
311
  const c = SK().cons[id];
@@ -0,0 +1,61 @@
1
+ import { afterEdit } from '../app/edit';
2
+ import { ed } from '../app/state';
3
+ import { measure } from '../model/constraints';
4
+ import { pushHistory } from '../model/history';
5
+ import { settings } from '../model/settings';
6
+ import { addCons } from '../model/sketch';
7
+ import { SK } from '../model/store';
8
+ import type { Constraint } from '../model/types';
9
+ import { selectionDim } from '../tools/dim';
10
+ import { elevOpen } from './elevation';
11
+ import { keypadOpen } from './keypad';
12
+ import { noScaleYet } from './scaleGate';
13
+
14
+ /*
15
+ Reference measurements by hand (the user's call, 2026-11-05: "upon selection of a line, two points, angle etc there
16
+ should be a grey measurement ... bottom bar should have a make reference option, when pressed the shown grey
17
+ measurement now stays on screen"). With something picked that the yellow + measures, the dimension it would make is
18
+ drawn as it measures now: a faded grey number with a dashed outline (the "ghost", render/draw.ts). It's a readout,
19
+ not a tap target: it sits on what it measures (a line's middle, a wall's cabinets), and a press there is for that —
20
+ the bar's + sets it. The bar's Make reference keeps it, as a reference dimension (never drives the drawing) that
21
+ stays on screen (`Constraint.kept`), solid grey in brackets. Only new ones: a dimension that drives stays driving. A reference the
22
+ drawing already set (one that would over-define it) shows only while selected, so it's drawn as a ghost too, and
23
+ Make reference keeps it. Everywhere (BOLDR's app and web), and in a drawing opened view-only, where a tap picks one
24
+ thing to read its size (readOnlyTap) and nothing can be kept.
25
+ */
26
+
27
+ /**
28
+ * What the grey number is for: the dimension the + would make for the selection (`id` unset), or the reference already
29
+ * there for it that isn't kept. Null with nothing to show: a dimension already measured or kept there, nothing the +
30
+ * measures this way, the wall view, Annotate, another tool, the keypad up (it draws what it's setting), dimensions
31
+ * hidden, or a page whose sizes are still placeholders (rule 15).
32
+ */
33
+ export function refTarget(): { c: Constraint; id?: string } | null {
34
+ if (ed.mode === 'annotate' || ed.tool !== 'select' || elevOpen() || keypadOpen() || !settings.layers.dims || noScaleYet()) return null;
35
+ const d = selectionDim();
36
+ if (!d) return null;
37
+ const sk = SK();
38
+ if (!d.existing) return { c: { ...d.c, value: measure(sk, d.c) } };
39
+ const e = sk.cons[d.existing];
40
+ return e.driving === false && !e.kept ? { c: e, id: d.existing } : null;
41
+ }
42
+
43
+ /** The dimension the grey number shows, when it isn't one of the drawing's yet. */
44
+ export function ghostDim(): Constraint | null {
45
+ const t = refTarget();
46
+ return t && !t.id ? t.c : null;
47
+ }
48
+
49
+ /** Whether the bar offers Make reference: something to keep, in a drawing that can be edited. */
50
+ export const makeRefVisible = (): boolean => !ed.readOnly && !!refTarget();
51
+
52
+ /** Make reference: the grey number kept, as a reference dimension that stays on screen. One undo step. */
53
+ export function makeReference(): void {
54
+ const t = refTarget();
55
+ if (!t || ed.readOnly) return;
56
+ pushHistory();
57
+ const sk = SK();
58
+ if (t.id) sk.cons[t.id].kept = true;
59
+ else addCons(sk, { ...t.c, driving: false, kept: true });
60
+ afterEdit();
61
+ }
@@ -58,6 +58,11 @@ export interface Constraint {
58
58
  value?: number;
59
59
  /** false = reference dimension (shown in parentheses, never drives geometry) */
60
60
  driving?: boolean;
61
+ /**
62
+ * a reference made by hand (the bar's Make reference: editor/refDim.ts): it stays on screen, as an entered dimension
63
+ * does. Any other reference — one the drawing already set — shows only while it or what it measures is selected.
64
+ */
65
+ kept?: boolean;
61
66
  /**
62
67
  * label offset from the measured span, across it (a distance between parallel lines or walls: along them); a
63
68
  * diameter's, how far past the circle the label sits; an angle's, its arc's radius once pulled off its corner
@@ -11,6 +11,7 @@ import { settings } from '../model/settings';
11
11
  import { curveInfo, frameMemo, lineBetween, roomValid, withLineIndex } from '../model/sketch';
12
12
  import { page, planSK, SK } from '../model/store';
13
13
  import { elev, elevOpen } from '../editor/elevation';
14
+ import { ghostDim } from '../editor/refDim';
14
15
  import { cabPoints } from '../model/faces';
15
16
  import { drawKeyPlan, drawWallBackdrop, drawWallChips, drawWallSelection, type ElevAct } from './elevation';
16
17
  import type { Constraint, Page, Sketch, WallFace, XY } from '../model/types';
@@ -41,8 +42,8 @@ export function rr(c: C2D, x: number, y: number, w: number, h: number, r: number
41
42
  }
42
43
 
43
44
  export interface ChipOpts {
44
- /** `r`: its corners' radius (5) */
45
- bg: string; fg: string; border?: [string, number?] | null; font?: string; pad?: number; h?: number; minW?: number; r?: number;
45
+ /** `r`: its corners' radius (5); `dash`: its border dashed so (a grey number nothing keeps yet: editor/refDim.ts) */
46
+ bg: string; fg: string; border?: [string, number?] | null; font?: string; pad?: number; h?: number; minW?: number; r?: number; dash?: number[];
46
47
  kind?: HitChip['kind']; id?: string; rot?: number;
47
48
  /**
48
49
  * 'link': a measurement of the job sets it. The chip is then Figma's linked measurement (2795:77149): a white badge
@@ -98,7 +99,7 @@ export function chip(c: C2D, text: string, cx: number, cy: number, opts: ChipOpt
98
99
  if (rot) { c.translate(cx, cy); c.rotate(rot); c.translate(-cx, -cy); }
99
100
  rr(c, x, y, w, h, opts.r ?? 5);
100
101
  c.fillStyle = bg; c.fill();
101
- if (border) { c.lineWidth = border[1] || 1; c.strokeStyle = border[0]; c.stroke(); }
102
+ if (border) { c.lineWidth = border[1] || 1; c.strokeStyle = border[0]; if (opts.dash) c.setLineDash(opts.dash); c.stroke(); c.setLineDash([]); }
102
103
  c.fillStyle = fg; c.textAlign = 'center'; c.textBaseline = 'middle';
103
104
  if (mark) {
104
105
  const k = h / LINKED.h, inset = LINKED.pad * k, bw = LINKED.badge * k, bx = x + inset, tx = bx + bw + LINKED.gap * k;
@@ -592,76 +593,93 @@ function dimLive(sk: Sketch, id: string, d: Constraint): boolean {
592
593
 
593
594
  /**
594
595
  * Driving/reference dimension chips. (Lengths are added from the bottom bar's + / Length, not on-canvas prompts.) What's
595
- * been entered (blue, or red when it can't be met) always shows; a reference only while it or what it measures is
596
- * selected or hovered.
596
+ * been entered (blue, or red when it can't be met) always shows, and so does a reference kept by hand (Make reference:
597
+ * editor/refDim.ts); a reference the drawing set only while it or what it measures is selected or hovered. Then what the
598
+ * + would make for the selection, as it measures now: the ghost.
597
599
  */
598
600
  function drawDims(c: C2D, T: Xform, sk: Sketch): void {
599
601
  if (!settings.layers.dims) return;
600
602
  for (const id in sk.cons) {
601
603
  const d = sk.cons[id];
602
604
  if (!isDim(d) || d.elev) continue; // the wall view's own dimensions are its chips
603
- const A = dimAnchor(sk, d);
604
- if (!A) continue;
605
- const ref = d.driving === false, val = ref ? measure(sk, d) : d.value!;
606
- const selected = isSel('dim', id) || isSel('cons', id);
607
605
  const bad = ed.highlight.has(id) || ed.ana.bad.has(id);
608
- if (ref && !bad && !dimLive(sk, id, d)) continue;
609
- // a measurement of the job sets it: the link badge before its value (chip()), on the chip's own colour — red once
610
- // the job's measurement is gone
611
- const linked = !!d.measurementRef && !ref && !bad, gone = linked && linkInfoOf(d.measurementRef!)?.exists === false;
612
- let bg = ref ? TH.ref : TH.blue, fg = ref ? TH.reftext : '#fff';
613
- if (bad || gone) { bg = TH.red; fg = '#fff'; }
614
- const border: [string, number] | null = selected ? [TH.yellow, 2.5] : null;
615
- const mark = linked ? 'link' as const : undefined;
616
- const wrap = (s: string) => (ref ? '(' : '') + s + (ref ? ')' : '');
617
- if (d.type === 'angle' && 'vtx' in A) {
618
- const [vx, vy] = S(T, A.vtx.x, A.vtx.y);
619
- // pulled off its corner: its arc out where the label is, the label riding on it; else a little arc at the
620
- // corner, the label out along the bisector
621
- const moved = angleLabel(sk, d), R = moved ? moved.r * T.s : 22;
622
- if (A.u1 && A.u2) {
623
- // arc between the two lines, so the angle reads as belonging to that corner
624
- const t1 = Math.atan2(-A.u1[1], A.u1[0]), t2 = Math.atan2(-A.u2[1], A.u2[0]);
625
- let sweep = t2 - t1;
626
- while (sweep > Math.PI) sweep -= 2 * Math.PI;
627
- while (sweep < -Math.PI) sweep += 2 * Math.PI;
628
- // (blue, as every other dimension is — the user's call, 2026-10-29; purple is the inference guides')
629
- c.save(); c.strokeStyle = bad ? TH.red : ref ? TH.chipline : TH.blue; c.lineWidth = 1.2;
630
- if (ref) c.setLineDash([3, 3]);
631
- c.beginPath(); c.arc(vx, vy, R, t1, t1 + sweep, sweep < 0); c.stroke();
632
- // (pulled out past where a line ends: an extension line along it, out to the arc)
633
- c.lineWidth = 1;
634
- for (const [u, len] of [[A.u1, A.len1], [A.u2, A.len2]] as const) {
635
- const from = (len ?? 0) * T.s;
636
- 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(); }
637
- }
638
- c.restore();
606
+ if (d.driving === false && !d.kept && !bad && !dimLive(sk, id, d)) continue;
607
+ drawDim(c, T, sk, d, id, bad);
608
+ }
609
+ const g = ghostDim();
610
+ if (g) drawDim(c, T, sk, g, null, false);
611
+ }
612
+
613
+ /**
614
+ * One dimension: driving blue, a kept reference solid grey in brackets, red when it can't be met. Faded — the canvas's
615
+ * own colour inside a dashed grey outline, grey text, no brackets — when it shows only while it's selected: the ghost
616
+ * (`id` null) and a reference the drawing set. Its lines are dashed when it holds nothing.
617
+ */
618
+ function drawDim(c: C2D, T: Xform, sk: Sketch, d: Constraint, id: string | null, bad: boolean): void {
619
+ const A = dimAnchor(sk, d);
620
+ if (!A) return;
621
+ const ref = d.driving === false, val = ref ? measure(sk, d) : d.value!;
622
+ const grey = ref || id === null, faded = !bad && (id === null || (ref && !d.kept));
623
+ const selected = id !== null && (isSel('dim', id) || isSel('cons', id));
624
+ // a measurement of the job sets it: the link badge before its value (chip()), on the chip's own colour — red once
625
+ // the job's measurement is gone
626
+ const linked = !!d.measurementRef && !ref && !bad, gone = linked && linkInfoOf(d.measurementRef!)?.exists === false;
627
+ let bg = faded ? TH.bg : ref ? TH.ref : TH.blue, fg = faded ? TH.ink2 : ref ? TH.reftext : '#fff';
628
+ if (bad || gone) { bg = TH.red; fg = '#fff'; }
629
+ const border: [string, number] | null = selected ? [TH.yellow, 2.5] : faded ? [TH.chipline, 1.2] : null;
630
+ // (the ghost is a readout, never a tap target: it sits on what it measures, which a press there is for)
631
+ const opts: ChipOpts = {
632
+ bg, fg, border, ...(id === null ? {} : { kind: 'dim' as const, id }),
633
+ mark: linked ? 'link' : undefined, dash: faded && !selected ? [3, 2] : undefined, ...dimChip(),
634
+ };
635
+ const wrap = (s: string) => (ref && !faded ? '(' + s + ')' : s);
636
+ const lineColor = bad ? TH.red : grey ? TH.chipline : TH.blue;
637
+ if (d.type === 'angle' && 'vtx' in A) {
638
+ const [vx, vy] = S(T, A.vtx.x, A.vtx.y);
639
+ // pulled off its corner: its arc out where the label is, the label riding on it; else a little arc at the
640
+ // corner, the label out along the bisector
641
+ const moved = angleLabel(sk, d), R = moved ? moved.r * T.s : 22;
642
+ if (A.u1 && A.u2) {
643
+ // arc between the two lines, so the angle reads as belonging to that corner
644
+ const t1 = Math.atan2(-A.u1[1], A.u1[0]), t2 = Math.atan2(-A.u2[1], A.u2[0]);
645
+ let sweep = t2 - t1;
646
+ while (sweep > Math.PI) sweep -= 2 * Math.PI;
647
+ while (sweep < -Math.PI) sweep += 2 * Math.PI;
648
+ // (blue, as every other dimension is — the user's call, 2026-10-29; purple is the inference guides')
649
+ c.save(); c.strokeStyle = lineColor; c.lineWidth = 1.2;
650
+ if (grey) c.setLineDash([3, 3]);
651
+ c.beginPath(); c.arc(vx, vy, R, t1, t1 + sweep, sweep < 0); c.stroke();
652
+ // (pulled out past where a line ends: an extension line along it, out to the arc)
653
+ c.lineWidth = 1;
654
+ for (const [u, len] of [[A.u1, A.len1], [A.u2, A.len2]] as const) {
655
+ const from = (len ?? 0) * T.s;
656
+ 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(); }
639
657
  }
640
- const text = wrap(fmtAng(val)), opts: ChipOpts = { bg, fg, border, kind: 'dim', id, mark, ...dimChip() };
641
- // (left where it was drawn: 50 px out along the bisector, or as far as its corners stay clear of the corner — a
642
- // linked chip is wider)
643
- const sz = chipSize(c, text, opts), out = Math.max(50, Math.hypot(sz.w, sz.h) / 2 + 28);
644
- 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];
645
- chip(c, text, lx, ly, opts);
646
- continue;
647
- }
648
- if (d.type === 'diam' && 'o' in A) {
649
- drawDiameter(c, T, A.o, A.r, d, d.rad ? 'R ' + wrap(fmtLen(val / 2)) : 'Ø ' + wrap(fmtLen(val)), bad ? TH.red : ref ? TH.chipline : TH.blue, { bg, fg, border, kind: 'dim', id, mark, ...dimChip() }, ref);
650
- continue;
651
- }
652
- if (!('a' in A)) continue;
653
- const [mx, my] = S(T, A.m.x, A.m.y);
654
- const lineColor = bad ? TH.red : ref ? TH.chipline : TH.blue;
655
- if ((d.type === 'dist' || d.type === 'pldist') && A.nx != null && Math.abs((d.off || 0) * T.s) > 10) {
656
- drawDimLeaders(c, T, A.a, A.b, A.nx, A.ny!, d.off!, lineColor, ref);
657
- } else if (d.type === 'pldist' || !d.line) {
658
- // (dashed only when it's a reference: a dashed line means the number holds nothing, wherever it's drawn)
659
- const [ax, ay] = S(T, A.a.x, A.a.y), [bx, by] = S(T, A.b.x, A.b.y);
660
- c.save(); if (ref) c.setLineDash([3, 3]); c.strokeStyle = bad ? TH.red : ref ? TH.chipline : TH.blue; c.lineWidth = 1.2;
661
- c.beginPath(); c.moveTo(ax, ay); c.lineTo(bx, by); c.stroke(); c.restore();
658
+ c.restore();
662
659
  }
663
- chip(c, wrap(fmtLen(val)), mx, my, { bg, fg, border, kind: 'dim', id, mark, ...dimChip() });
660
+ const text = wrap(fmtAng(val));
661
+ // (left where it was drawn: 50 px out along the bisector, or as far as its corners stay clear of the corner — a
662
+ // linked chip is wider)
663
+ const sz = chipSize(c, text, opts), out = Math.max(50, Math.hypot(sz.w, sz.h) / 2 + 28);
664
+ 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];
665
+ chip(c, text, lx, ly, opts);
666
+ return;
667
+ }
668
+ if (d.type === 'diam' && 'o' in A) {
669
+ drawDiameter(c, T, A.o, A.r, d, d.rad ? 'R ' + wrap(fmtLen(val / 2)) : 'Ø ' + wrap(fmtLen(val)), lineColor, opts, grey);
670
+ return;
671
+ }
672
+ if (!('a' in A)) return;
673
+ const [mx, my] = S(T, A.m.x, A.m.y);
674
+ if ((d.type === 'dist' || d.type === 'pldist') && A.nx != null && Math.abs((d.off || 0) * T.s) > 10) {
675
+ drawDimLeaders(c, T, A.a, A.b, A.nx, A.ny!, d.off!, lineColor, grey);
676
+ } else if (d.type === 'pldist' || !d.line) {
677
+ // (dashed only when it holds nothing: a dashed line means the number holds nothing, wherever it's drawn)
678
+ const [ax, ay] = S(T, A.a.x, A.a.y), [bx, by] = S(T, A.b.x, A.b.y);
679
+ c.save(); if (grey) c.setLineDash([3, 3]); c.strokeStyle = lineColor; c.lineWidth = 1.2;
680
+ c.beginPath(); c.moveTo(ax, ay); c.lineTo(bx, by); c.stroke(); c.restore();
664
681
  }
682
+ chip(c, wrap(fmtLen(val)), mx, my, opts);
665
683
  }
666
684
 
667
685
  /**
@@ -945,9 +963,10 @@ function badgeSpots(T: Xform, sk: Sketch, c: Constraint): [number, number][] {
945
963
  const lid = lineBetween(sk, c.a!, c.b!);
946
964
  if (lid) onLine(lid, 0.22, -1);
947
965
  else {
966
+ // two points no line joins: on the dashed line drawn between them (pointPairLink), halfway
948
967
  const a = P(c.a!), b = P(c.b!);
949
968
  const [x1, y1] = S(T, a.x, a.y), [x2, y2] = S(T, b.x, b.y);
950
- out.push([(x1 + x2) / 2 + 10, (y1 + y2) / 2 - 10]);
969
+ out.push([(x1 + x2) / 2, (y1 + y2) / 2]);
951
970
  }
952
971
  } else if (['par', 'perp', 'col', 'eq', 'angle'].includes(c.type)) {
953
972
  onLine(c.l1, 0.75, -1); onLine(c.l2, 0.75, -1);
@@ -976,16 +995,46 @@ function badgeSpots(T: Xform, sk: Sketch, c: Constraint): [number, number][] {
976
995
  return out;
977
996
  }
978
997
 
998
+ /**
999
+ * Horizontal or Vertical between two points no line joins: the two points it ties, for drawing a dashed line between
1000
+ * them that its badge sits on (the user's call, 2026-11-05: the badge "is floating so u have no idea what it is related
1001
+ * to"). Null for any other constraint.
1002
+ */
1003
+ function pointPairLink(sk: Sketch, k: Constraint): [XY, XY] | null {
1004
+ if ((k.type !== 'h' && k.type !== 'v') || lineBetween(sk, k.a!, k.b!)) return null;
1005
+ const a = sk.points[k.a!], b = sk.points[k.b!];
1006
+ return a && b ? [a, b] : null;
1007
+ }
1008
+
979
1009
  function drawBadges(c: C2D, T: Xform, sk: Sketch): void {
980
1010
  const { W, H } = cvs;
981
1011
  const selIds = new Set(ed.sel.map(s => s.id)), selPts = selPoints(sk);
1012
+ const shown: string[] = [];
982
1013
  for (const id in sk.cons) {
983
1014
  const k = sk.cons[id];
984
- if (isDim(k) || k.elev) continue;
985
- const g = GLYPH[k.type];
986
- if (!g) continue;
1015
+ if (isDim(k) || k.elev || !GLYPH[k.type]) continue;
987
1016
  const related = selIds.has(id) || [k.l, k.l1, k.l2, k.c, k.c1, k.c2].some(x => x && selIds.has(x)) || [k.a, k.b, k.p].some(x => x && selPts.has(x));
988
1017
  if (!settings.showCons && !related && !ed.highlight.has(id)) continue;
1018
+ shown.push(id);
1019
+ }
1020
+ // first the lines between the points a two-point Horizontal or Vertical ties: faint and dashed, yellow with a ring
1021
+ // on each point while it's selected, red when it can't be met — under every badge
1022
+ for (const id of shown) {
1023
+ const ends = pointPairLink(sk, sk.cons[id]);
1024
+ if (!ends) continue;
1025
+ const [x1, y1] = S(T, ends[0].x, ends[0].y), [x2, y2] = S(T, ends[1].x, ends[1].y);
1026
+ if (hyp(x2 - x1, y2 - y1) < 24) continue;
1027
+ const selected = isSel('cons', id), bad = ed.highlight.has(id) || ed.ana.bad.has(id);
1028
+ c.save();
1029
+ c.strokeStyle = bad ? TH.red : selected ? TH.yellow : TH.badgeline; c.lineWidth = selected || bad ? 1.5 : 1;
1030
+ c.setLineDash([4, 4]);
1031
+ c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
1032
+ c.setLineDash([]);
1033
+ if (selected) for (const [x, y] of [[x1, y1], [x2, y2]]) { c.beginPath(); c.arc(x, y, 8, 0, Math.PI * 2); c.lineWidth = 2.5; c.stroke(); }
1034
+ c.restore();
1035
+ }
1036
+ for (const id of shown) {
1037
+ const k = sk.cons[id], g = GLYPH[k.type]!;
989
1038
  for (const [x, y] of badgeSpots(T, sk, k)) {
990
1039
  if (x < -20 || y < -20 || x > W + 20 || y > H + 20) continue;
991
1040
  const selected = isSel('cons', id), bad = ed.highlight.has(id) || ed.ana.bad.has(id);