@floless/app 0.37.0 → 0.38.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.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.37.0" : void 0,
52859
+ define: true ? "0.38.0" : void 0,
52860
52860
  pkgVersion: readPkgVersion()
52861
52861
  });
52862
52862
  }
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
52866
52866
  return "dev";
52867
52867
  }
52868
52868
  function appChannel() {
52869
- return resolveChannel({ isSea: isSea2(), define: true ? "0.37.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.38.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -53752,7 +53752,19 @@ function bakeContractIntoApp(sourcePath, contract) {
53752
53752
  if (idx < 0) idx = items.findIndex((it) => nodeOf(it)?.agent === "steel-takeoff-us");
53753
53753
  if (idx < 0) throw new Error("no contract-emitting node to bake the contract into");
53754
53754
  doc2.setIn(["nodes", idx, "config", "contract"], contract.type);
53755
- doc2.setIn(["nodes", idx, "config", "takeoff"], contract);
53755
+ const plans = contract.plans;
53756
+ const baked = Array.isArray(plans) ? {
53757
+ ...contract,
53758
+ plans: plans.map((p) => {
53759
+ if (p && typeof p === "object") {
53760
+ const clone = { ...p };
53761
+ delete clone.dims;
53762
+ return clone;
53763
+ }
53764
+ return p;
53765
+ })
53766
+ } : contract;
53767
+ doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
53756
53768
  (0, import_node_fs17.writeFileSync)(sourcePath, doc2.toString());
53757
53769
  }
53758
53770
 
@@ -67,7 +67,8 @@
67
67
  "labels": { "type": "array", "items": { "$ref": "#/$defs/label" } },
68
68
  "details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
69
69
  "tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
70
- "members": { "type": "array", "items": { "$ref": "#/$defs/member" } }
70
+ "members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
71
+ "dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." }
71
72
  }
72
73
  },
73
74
  "segment": {
@@ -81,6 +82,18 @@
81
82
  "o": { "enum": ["H", "V", "D"], "description": "Orientation: horizontal / vertical / diagonal." }
82
83
  }
83
84
  },
85
+ "dim": {
86
+ "type": "object",
87
+ "required": ["id", "a", "b", "axis", "off"],
88
+ "additionalProperties": true,
89
+ "properties": {
90
+ "id": { "type": "string" },
91
+ "a": { "$ref": "#/$defs/point2", "description": "First snapped anchor, display space." },
92
+ "b": { "$ref": "#/$defs/point2", "description": "Second snapped anchor, display space." },
93
+ "axis": { "enum": ["free", "x", "y"], "description": "free = aligned distance hypot(b-a); x = |b.x-a.x|; y = |b.y-a.y|." },
94
+ "off": { "type": "number", "description": "Dimension-line offset: for axis x the dim-line Y; for axis y the dim-line X; for free the signed perpendicular distance from the a-b baseline." }
95
+ }
96
+ },
84
97
  "label": {
85
98
  "type": "object",
86
99
  "required": ["text", "disp"],
@@ -141,12 +141,20 @@
141
141
  #lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
142
142
  line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
143
143
  circle.snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
144
+ line.dim{stroke:#22d3ee;stroke-width:2.5;vector-effect:non-scaling-stroke;pointer-events:stroke;cursor:pointer}
145
+ line.dimwit{stroke:#22d3ee;stroke-width:1;opacity:.85;vector-effect:non-scaling-stroke;pointer-events:none}
146
+ circle.dimend{fill:#22d3ee;pointer-events:none}
147
+ rect.dimchip{fill:#111827;stroke:#22d3ee;stroke-width:1;vector-effect:non-scaling-stroke;cursor:pointer}
148
+ text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
149
+ line.dim.dimsel{stroke:#f59e0b} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
150
+ .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
151
+ body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
144
152
  body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
145
153
  body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
146
154
  circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
147
155
  text.numtx{fill:#fff;font:bold 12px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
148
156
  #dupStat.has b{color:#fca5a5} #dupB.alert{background:#7f1d1d;border-color:#991b1b} #dupB.alert:hover{background:#991b1b}
149
- #dupB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
157
+ #dupB.ok,#revB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
150
158
  header select#planSel{width:auto;min-width:210px;padding:5px 8px;font-weight:600}
151
159
  /* 2D|3D view toggle — segmented, far-left; active segment filled (--panel) with a --brand underline */
152
160
  #viewToggle{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;flex:none}
@@ -188,12 +196,17 @@
188
196
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
189
197
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
190
198
  <button id=mAdd title="Toggle add-member mode">Add member</button>
199
+ <button id=dimB title="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free.">⊢ Dimension</button>
191
200
  <button id=askAiBtn>Ask AI ▸</button>
192
201
  <div id=moreWrap>
193
202
  <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
194
203
  <div id=moreMenu role=menu>
204
+ <div class=mlabel>Dimensions</div>
205
+ <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
206
+ <hr>
195
207
  <div class=mlabel>Review</div>
196
208
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
209
+ <button id=revB title="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
197
210
  <button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
198
211
  <hr>
199
212
  <div class=mlabel>Reference</div>
@@ -397,6 +410,7 @@ function showAiUpdateBanner(message) {
397
410
  function main() {
398
411
  let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
399
412
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
413
+ let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
400
414
  let selIds=new Set();
401
415
  let undo=[], redo=[];
402
416
  const byId=id=>P.members.find(m=>m.id===id);
@@ -448,9 +462,14 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
448
462
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
449
463
  const autofillTOS=syncDefaults;
450
464
  // profs is per-plan — computed in setPlan()
451
- function snapshot(){return JSON.stringify(P.members);}
465
+ function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
452
466
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
453
- function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();sync3D();}
467
+ function apply(json){const d=JSON.parse(json);
468
+ if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
469
+ else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;}
470
+ selIds=new Set([...selIds].filter(id=>byId(id)));
471
+ selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
472
+ scheduleSave();render();sync3D();}
454
473
  function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
455
474
  function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
456
475
  function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
@@ -465,7 +484,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
465
484
  else el.textContent=msg||'Auto-save on';}
466
485
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
467
486
  custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
468
- plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
487
+ plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
469
488
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
470
489
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
471
490
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
@@ -494,7 +513,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
494
513
  if(d.custom_details)C.custom_details=d.custom_details;
495
514
  if(d.profile_colors)C.profile_colors=d.profile_colors;
496
515
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
497
- C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;p.autofilled=true;}});
516
+ C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;p.autofilled=true;}});
498
517
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
499
518
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
500
519
  function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
@@ -505,6 +524,45 @@ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y
505
524
  const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
506
525
  function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
507
526
  let t=((p[0]-a[0])*vx+(p[1]-a[1])*vy)/L2;t=Math.max(0,Math.min(1,t));return {pt:[a[0]+t*vx,a[1]+t*vy],t};}
527
+ // --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
528
+ // y -> the dim-line X; free -> signed perpendicular distance from the a->b baseline. ---
529
+ function dimGeo(a,b,axis,off){
530
+ if(axis==='x'){const y=off;return {p1:[a[0],y],p2:[b[0],y],w:[[a,[a[0],y]],[b,[b[0],y]]],mid:[(a[0]+b[0])/2,y],px:Math.abs(b[0]-a[0])};}
531
+ if(axis==='y'){const x=off;return {p1:[x,a[1]],p2:[x,b[1]],w:[[a,[x,a[1]]],[b,[x,b[1]]]],mid:[x,(a[1]+b[1])/2],px:Math.abs(b[1]-a[1])};}
532
+ const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy),D=L||1,nx=-dy/D,ny=dx/D; // free: parallel-offset along the normal (D guards a zero-length divide; L stays the true measured length so a 0-length dim reads 0'-0", not 1px)
533
+ const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
534
+ return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
535
+ // measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
536
+ function dimValueText(px){return fmtFtIn(px/FT*12);}
537
+ // the offset a click at pointer p implies, given the two anchors + axis.
538
+ function dimOffFromPointer(a,b,axis,p){
539
+ if(axis==='x')return p[1];
540
+ if(axis==='y')return p[0];
541
+ const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
542
+ return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
543
+ // a sensible offset for the stage-1 preview (before the 3rd click picks one): clear of the anchors by a constant screen distance.
544
+ function dimDefaultOff(a,b,axis){
545
+ if(axis==='x')return Math.min(a[1],b[1])-28/zoom; // X dim line sits above the higher anchor
546
+ if(axis==='y')return Math.min(a[0],b[0])-28/zoom; // Y dim line sits left of the leftmost anchor
547
+ return -28/zoom;} // free: a constant perpendicular offset
548
+ // one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
549
+ // screen); the chip rect/text + end dots are sized in 1/zoom and re-synced by updateBadges on zoom.
550
+ function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off);
551
+ const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
552
+ const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
553
+ // a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
554
+ // horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
555
+ // 8° of horizontal it stays flat.
556
+ let ang=0;if(d.axis==='free'){ang=Math.atan2(g.p2[1]-g.p1[1],g.p2[0]-g.p1[0])*180/Math.PI;if(ang>90)ang-=180;else if(ang<-90)ang+=180;if(Math.abs(ang)<8)ang=0;}
557
+ let s='';for(const w of g.w)s+=`<line class="dimwit ${c}" x1="${w[0][0]}" y1="${w[0][1]}" x2="${w[1][0]}" y2="${w[1][1]}"/>`;
558
+ s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
559
+ s+=`<circle class="dimend ${c}" cx="${g.p1[0]}" cy="${g.p1[1]}" r="${3/zoom}"/><circle class="dimend ${c}" cx="${g.p2[0]}" cy="${g.p2[1]}" r="${3/zoom}"/>`;
560
+ const chip=`<rect class="dimchip ${c}"${dd} data-cx="${g.mid[0]}" data-cy="${g.mid[1]}" data-w="${cw}" data-h="${ch}" x="${g.mid[0]-cw/2/zoom}" y="${g.mid[1]-ch/2/zoom}" width="${cw/zoom}" height="${ch/zoom}" rx="${4/zoom}"/>`
561
+ +`<text class="dimtx ${c}" x="${g.mid[0]}" y="${g.mid[1]}" style="font-size:${13/zoom}px">${txt}</text>`;
562
+ s+=ang?`<g transform="rotate(${ang.toFixed(2)} ${g.mid[0]} ${g.mid[1]})">${chip}</g>`:chip; // rotate the chip group around the dim-line midpoint
563
+ return s;}
564
+ function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
565
+ return P.dims.map(d=>dimSvg(d,{id:d.id,sel:selDimIds.has(d.id)})).join('');}
508
566
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
509
567
  // nearest end → intersection: extends when the line is past the end, trims when it crosses the member. ponytail: keeps the larger piece on a deep overshoot — undo+drag if that's wrong.
510
568
  // Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
@@ -518,7 +576,7 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
518
576
  m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
519
577
  const c=JSON.parse(JSON.stringify(base));c.id='m'+Date.now();c.wp=[q.slice(),b];
520
578
  if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
521
- c.rfi=(WT[c.profile]==null);P.members.push(c);selIds=new Set([m.id,c.id]);geoMode=null;setGeo();pushUndo(pv);render();}
579
+ c.rfi=(WT[c.profile]==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
522
580
  // --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
523
581
  function dupKey(m){const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
524
582
  function dupScore(m){let s=0;if(WT[m.profile]!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
@@ -544,6 +602,15 @@ function _mAble(a,b){if((a.profile||'').toUpperCase()!==(b.profile||'').toUpperC
544
602
  const s=_mShare(a,b);if(!s)return null;return _mStraight(a,b,s)&&_mElev(a,b,s)?s:null;}
545
603
  function _mAngle(wp){const dx=Math.abs(wp[1][0]-wp[0][0]),dy=Math.abs(wp[1][1]-wp[0][1]),t=Math.tan(MRG_ANG);
546
604
  return dy<=dx*t?'H':dx<=dy*t?'V':'D';}
605
+ // A beam is drawn the canonical way when its work-line runs left→right (mostly-horizontal) or
606
+ // bottom→up (steeper/skew). Display space is y-down, so "up" = a smaller y. Returns true when
607
+ // start→end points the WRONG way — swapping its ends (P) would make it canonical. 45° split:
608
+ // |run| ≥ |rise| ⇒ judge left/right, else up/down. (A zero-length beam has no direction.)
609
+ function isReversed(m){if(!m||!m.wp||m.wp.length<2)return false;
610
+ const dx=m.wp[1][0]-m.wp[0][0],dy=m.wp[1][1]-m.wp[0][1];if(dx===0&&dy===0)return false;
611
+ return Math.abs(dx)>=Math.abs(dy)?dx<0:dy>0;}
612
+ // Ids of the active plan's beams (not columns) drawn the wrong way — the "Reversed beams" selection.
613
+ function reversedBeams(){return P.members.filter(m=>m.role!=='column'&&isReversed(m)).map(m=>m.id);}
547
614
  function mergeCollinearJS(members){
548
615
  const idx=members.map((_,i)=>i).filter(i=>_mElig(members[i]));
549
616
  const adj=new Map();for(const i of idx)adj.set(i,[]);
@@ -593,7 +660,7 @@ function fitToWindow(){applyZoom(Math.min(stage.clientWidth/EXTX,stage.clientHei
593
660
  function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot(),ns=new Set();
594
661
  let i=0;for(const s of arr){const id='m'+Date.now()+'_'+(i++);const c=JSON.parse(JSON.stringify(s));
595
662
  c.id=id;c.wp=[[s.wp[0][0]+o,s.wp[0][1]+o],[s.wp[1][0]+o,s.wp[1][1]+o]];P.members.push(c);ns.add(id);}
596
- selIds=ns;mode='sel';setMode();pushUndo(pv);render();}
663
+ selIds=ns;selDimIds.clear();mode='sel';setMode();pushUndo(pv);render();}
597
664
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
598
665
  function render(){
599
666
  if(geoMode&&(selIds.size<1||(geoMode==='split'&&selIds.size!==1))){geoMode=null;document.body.classList.remove('geo');} // Split needs exactly one member; Extend/Trim works on any selection
@@ -613,19 +680,24 @@ function render(){
613
680
  selM.forEach((m,idx)=>{const c=mid(m),k=Math.round(c[0]/8)+','+Math.round(c[1]/8);(grp[k]=grp[k]||[]).push({idx,c});});
614
681
  for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
615
682
  s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
683
+ s+=renderDims();
616
684
  svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf();
617
685
  if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
618
686
  }
619
687
  function updDup(){const n=redundantDups().length;
620
688
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
621
689
  const b=document.getElementById('dupB');if(b.dataset.flash)return; // don't clobber transient "none" feedback
622
- b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Re-check duplicates';}
690
+ b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Check duplicates';}
623
691
  // keep selection-number badges constant on screen while zooming; the end dots grow with zoom (epR) so
624
692
  // they stay readable against the thick member line — both must track zoom live as the user zooms.
625
693
  function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
626
694
  svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
627
695
  svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
628
- svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));}
696
+ svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));
697
+ const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
698
+ svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
699
+ svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
700
+ r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});}
629
701
  function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
630
702
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
631
703
  if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
@@ -640,6 +712,15 @@ const VARIES=Symbol('varies');
640
712
  function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
641
713
  function panel(){
642
714
  const p=document.getElementById('panel');
715
+ if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
716
+ <div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
717
+ <div class=sect style="margin-top:12px">Direction</div>
718
+ <div class=seg2><button id=dimAxFree class="${dimAxis==='free'?'on':''}">Free</button><button id=dimAxX class="${dimAxis==='x'?'on':''}">X</button><button id=dimAxY class="${dimAxis==='y'?'on':''}">Y</button></div>
719
+ <div class=hint style="margin-top:8px">Snaps to grid intersections, member ends, and onto member lines — same as drawing. <b>Alt</b> turns snap off. <b>Esc</b> cancels.</div>`;
720
+ document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
721
+ document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
722
+ document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
723
+ return;}
643
724
  if(mode==='add'){
644
725
  p.innerHTML=`<span class=badge>Add member</span>
645
726
  <div class=sect>Current profile</div>
@@ -704,7 +785,7 @@ function panel(){
704
785
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
705
786
  <div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim all</button></div>
706
787
  <div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — the nearest end of <b>every</b> selected member snaps to where it meets that line.':'<b>Extend/Trim</b> (E) every selected member\'s nearest end to one line. <b>Esc</b> cancels.'}</div>
707
- <div class=row><button class=ghostw id=swapEnds title="Reverse every selected member: swap each one's start and end (yellow ↔ magenta / bottom ↔ top) handles">⇄ Swap start ↔ end (all)</button></div>
788
+ <div class=row><button class=ghostw id=swapEnds title="Reverse every selected member: swap each one's start and end (yellow ↔ magenta / bottom ↔ top) handles · Shortcut: P">⇄ Swap start ↔ end (all) <span style="opacity:.55">(P)</span></button></div>
708
789
  <div class="row f"><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
709
790
  // wiring — every commit applies to the whole selection; a blank field is a no-op (leaves each member's own value).
710
791
  document.getElementById('pf').onchange=e=>{const v=e.target.value.toUpperCase().replace(/ /g,'');if(!v)return;edit(()=>{for(const m of selArr()){m.profile=v;m.rfi=(WT[v]==null);}if(!profs.includes(v)){profs.push(v);profs.sort();}});};
@@ -756,7 +837,7 @@ function panel(){
756
837
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
757
838
  <div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim</button><button id=geoSplit class="${geoMode==='split'?'on':''}">Split</button></div>
758
839
  <div class=hint style="margin-top:6px">${geoMode==='el'?'Click a <b>target line</b> — another member or a grey segment. The nearest end of this member snaps to where the two lines meet (extends if short, trims if it overshoots).':geoMode==='split'?'Click a <b>point on this member</b> to cut it into two members.':'<b>Extend/Trim</b> (E) an end to meet another line · <b>Split</b> (S) at a point. <b>Esc</b> cancels.'}</div>
759
- <div class=row><button class=ghostw id=swapEnds title="Reverse the member: swap the start (${col?'bottom':'yellow'}) and end (${col?'top':'magenta'}) handles">⇄ Swap start ↔ end</button></div>
840
+ <div class=row><button class=ghostw id=swapEnds title="Reverse the member: swap the start (${col?'bottom':'yellow'}) and end (${col?'top':'magenta'}) handles · Shortcut: P">⇄ Swap start ↔ end <span style="opacity:.55">(P)</span></button></div>
760
841
  <div class="row f"><button class=danger id=del>Delete member</button></div>`;
761
842
  document.getElementById('pf').onchange=e=>edit(()=>{const v=e.target.value.toUpperCase().replace(/ /g,'');m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});
762
843
  document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
@@ -823,12 +904,59 @@ function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
823
904
  return {x:sx,y:sy,hit:false};}
824
905
  function snapMark(x,y){let c=document.getElementById('snapMark');if(!c){c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.id='snapMark';c.setAttribute('class','snapmk');svg.appendChild(c);}c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r',6/zoom);}
825
906
  function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
907
+ // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
908
+ // buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
909
+ // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
910
+ const SVGNS='http://www.w3.org/2000/svg';
911
+ function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
912
+ if(dimMode){if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes + ensures dims are visible
913
+ dimDraft=null;dimPrevClear();}
914
+ function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
915
+ if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;return {x,y,raw:[q.x,q.y],hit:sn.hit};}
916
+ return {x,y,raw:[q.x,q.y],hit:false};}
917
+ function dimClick(e){const s=dimSnapAt(e);
918
+ if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
919
+ if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
920
+ const axis=dimDraft._ax||dimDraft.axis; // commit the axis the preview last showed: _ax is cached every move (incl. the click-2 preview), so a Shift press/release between move and click can't make the commit disagree with what was on screen
921
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw); // click 3 → offset/side from raw pointer
922
+ const a=dimDraft.a,b=dimDraft.b;
923
+ edit(()=>{P.dims.push({id:'d'+Date.now(),a,b,axis,off});}); // undoable; render() clears the preview
924
+ dimDraft=null;}
925
+ function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
926
+ function dimPrev(e){if(!dimMode||!dimDraft){dimPrevClear();return;}
927
+ let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
928
+ if(!dimDraft.b){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear(); // stage 1: live CONSTRAINED preview from a → the tentative 2nd point
929
+ const b=[s.x,s.y];dimLastPtr=b;
930
+ const ax=e.shiftKey?(Math.abs(b[0]-dimDraft.a[0])>=Math.abs(b[1]-dimDraft.a[1])?'x':'y'):dimDraft.axis; // X/Y/F + Shift force already apply while picking the 2nd point (value shown live)
931
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax)},{preview:true});return;}
932
+ const q=toSvg(e);dimLastPtr=[q.x,q.y];snapClear();
933
+ const ax=e.shiftKey?(Math.abs(dimDraft.b[0]-dimDraft.a[0])>=Math.abs(dimDraft.b[1]-dimDraft.a[1])?'x':'y'):dimDraft.axis; // Shift = momentary dominant-axis lock
934
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y]); // stage 2: full dim rides the pointer
935
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off},{preview:true});dimDraft._ax=ax;}
936
+ function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
937
+ const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
938
+ for(const [id,a] of [['dimAxFree','free'],['dimAxX','x'],['dimAxY','y']]){const b=document.getElementById(id);if(b)b.classList.toggle('on',dimAxis===a);}} // keep the Free/X/Y control in sync whether the axis changed by button OR keyboard (x/y/f)
939
+ function dimHintText(){const a={free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
940
+ return 'Dimension — '+a+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
941
+ let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
942
+ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
943
+ const g=document.getElementById('dimPrevG');if(!g)return;
944
+ if(!dimDraft.b){const b=dimLastPtr; // stage 1: redraw the forming dim with the just-changed axis (key/button), no mouse move needed
945
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis)},{preview:true});return;}
946
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr);dimDraft._ax=dimDraft.axis; // stage 2: keep the offset the pointer last set; sync _ax so the 3rd-click commit uses the axis now on screen
947
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off},{preview:true});}
826
948
  // themed transient toast (baseline tokens — never a native alert)
827
949
  function toast(msg){let t=document.getElementById('toast');
828
950
  if(!t){t=document.createElement('div');t.id='toast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s;pointer-events:none';document.body.appendChild(t);}
829
951
  t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
830
952
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
831
953
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
954
+ if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
955
+ if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
956
+ if(e.ctrlKey||e.metaKey){selDimIds.has(did)?selDimIds.delete(did):selDimIds.add(did);render();return;} // Ctrl+click toggles this dim within the dim selection
957
+ selDimIds=new Set([did]);const d=P.dims.find(x=>x.id===did); // plain click → exactly this dim (collapses any multi-selection, so Delete removes only it)
958
+ if(d){drag={type:'dim',id:d.id,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();}render();return;} // drag the dim line to slide its offset
959
+ if(selDimIds.size){selDimIds.clear();render();} // ANY other click (member / handle / empty / marquee-start, incl. Ctrl) is dim-exclusive → drop the dim selection so Delete never hits a stale dim
832
960
  if(geoMode==='split'&&selIds.size===1){const id=[...selIds][0],m=byId(id);
833
961
  const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
834
962
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
@@ -858,14 +986,16 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
858
986
  const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
859
987
  buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
860
988
  drag={type:'move',start:[p.x,p.y],items,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
861
- if((t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e);
989
+ if((t===svg||t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e); // t===svg → drag on bare canvas (no raster) still box-selects
862
990
  const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
863
991
  drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
864
992
  svg.addEventListener('pointermove',e=>{
993
+ if(dimMode){dimPrev(e);return;}
865
994
  if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
866
995
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
867
996
  if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1]);}else snapClear();}return;}
868
997
  if(!drag)return;const p=toSvg(e);
998
+ if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y]);render();}return;}
869
999
  if(drag.type==='draw'){let x=p.x,y=p.y;
870
1000
  if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
871
1001
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
@@ -894,19 +1024,28 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
894
1024
  const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(WT[addProfile]==null)}));
895
1025
  if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
896
1026
  drag=null;render();return;}
897
- if(drag.type==='marquee'){if(!drag.add)selIds.clear();
898
- const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2))for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
1027
+ if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
1028
+ const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
1029
+ if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
899
1030
  drag.rect.remove();drag=null;render();return;}
900
1031
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
901
1032
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
902
- document.getElementById('mAdd').onclick=()=>{mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();};
1033
+ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();}; // entering add/sel disarms the Dimension tool (else dimMode keeps eating canvas clicks) and drops any dim selection
1034
+ document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
1035
+ function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
1036
+ document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
903
1037
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
904
1038
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
905
1039
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
906
- if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
1040
+ if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
1041
+ document.getElementById('revB').onclick=()=>{const ids=reversedBeams(); // beams drawn against the canonical direction
1042
+ if(!ids.length){const b=document.getElementById('revB');b.dataset.flash='1';b.classList.add('ok');b.textContent='All aligned ✓';
1043
+ setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');b.textContent='Reversed beams';},1500);return;}
1044
+ if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();
1045
+ toast(ids.length+' reversed beam'+(ids.length>1?'s':'')+' selected — press P to swap direction');}; // select reversed → review → P swaps them
907
1046
  document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members); // conservative; result is undoable (Ctrl+Z)
908
1047
  if(!r.mergedAway){toast('Nothing to merge');return;}
909
- edit(()=>{P.members=r.members;selIds=new Set();});
1048
+ edit(()=>{P.members=r.members;selIds=new Set();selDimIds.clear();});
910
1049
  toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
911
1050
  document.getElementById('undoB').onclick=doUndo;
912
1051
  document.getElementById('redoB').onclick=doRedo;
@@ -921,16 +1060,23 @@ addEventListener('keydown',e=>{
921
1060
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
922
1061
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
923
1062
  if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
1063
+ if(e.key==='Escape'&&dimMode){if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // 1st Esc drops the in-progress dim, 2nd exits the tool
924
1064
  if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
925
- if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});return;}
1065
+ if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
1066
+ if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
1067
+ if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
1068
+ if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
1069
+ if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1070
+ if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}}
926
1071
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
927
1072
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
1073
+ if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
928
1074
  if(kk==='s'&&selIds.size===1){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // Split — single only
929
1075
  if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
930
1076
  if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
931
1077
  else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
932
1078
  else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
933
- else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
1079
+ else if(k==='a'&&!inForm){e.preventDefault();selDimIds.clear();selIds=new Set(P.members.map(m=>m.id));render();}
934
1080
  else if(k==='s'){e.preventDefault();persist();(window.flushContract?window.flushContract():Promise.resolve()).then(()=>toast('Saved ✓')).catch(()=>toast('Save failed'));}}); // edits autosave; Ctrl+S just flushes now (no browser "save page" dialog)
935
1081
  // zoom bar
936
1082
  document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
@@ -949,8 +1095,8 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
949
1095
  const view3dApi={
950
1096
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
951
1097
  fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');return j.scene;}, // endpoint shape: {ok, scene, skipped}
952
- onSelect:(id,additive)=>{if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection
953
- onSelectMany:(ids)=>{selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
1098
+ onSelect:(id,additive)=>{selDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection (clears any 2D dim selection)
1099
+ onSelectMany:(ids)=>{selDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
954
1100
  getMembers:()=>P.members, // raw members (wp) for snap geometry
955
1101
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
956
1102
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
@@ -1142,7 +1288,7 @@ function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList
1142
1288
  :'<div class=hint>No unresolved members — every member has a resolved AISC size. ✓</div>';
1143
1289
  g.querySelectorAll('input.combo[data-mid]').forEach(inp=>inp.onchange=e=>{const m=byId(inp.dataset.mid);if(!m)return;
1144
1290
  const v=e.target.value.toUpperCase().replace(/ /g,'');edit(()=>{m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});openRFI();});
1145
- g.querySelectorAll('button[data-loc]').forEach(b=>b.onclick=()=>{const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeRFI();render();zoomToMember(m);});
1291
+ g.querySelectorAll('button[data-loc]').forEach(b=>b.onclick=()=>{const m=byId(b.dataset.loc);if(!m)return;selDimIds.clear();selIds=new Set([m.id]);geoMode=null;setGeo();closeRFI();render();zoomToMember(m);});
1146
1292
  document.getElementById('rfiModal').style.display='flex';}
1147
1293
  function closeRFI(){document.getElementById('rfiModal').style.display='none';}
1148
1294
  function rfiOpen(){return document.getElementById('rfiModal').style.display==='flex';}
@@ -1249,7 +1395,7 @@ function wireConf(){
1249
1395
  document.querySelectorAll('#confFilter [data-band]').forEach(b=>b.onclick=()=>{confBand=b.dataset.band;renderConf();});
1250
1396
  document.querySelectorAll('#confFilter [data-fcat]').forEach(b=>b.onclick=()=>{confCat=b.dataset.fcat;renderConf();});
1251
1397
  document.querySelectorAll('#confBody tr.confrow').forEach(r=>r.onclick=e=>{if(e.target.closest('button'))return;const id=r.dataset.mid;confExpand=confExpand===id?null:id;renderConf();});
1252
- document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
1398
+ document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selDimIds.clear();selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
1253
1399
  document.getElementById('confStat').onclick=openConf;
1254
1400
  document.getElementById('confTarget').onchange=e=>{ // per-read target override → contract.target_confidence (never the .flo)
1255
1401
  const raw=String(e.target.value).trim();
@@ -1319,6 +1465,7 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
1319
1465
  function setPlan(i){C.active=i;P=C.plans[i];
1320
1466
  if(!Array.isArray(P.members))P.members=[];
1321
1467
  if(!Array.isArray(P.segments))P.segments=[];
1468
+ if(!Array.isArray(P.dims))P.dims=[];
1322
1469
  // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
1323
1470
  // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
1324
1471
  // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
@@ -1341,6 +1488,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
1341
1488
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
1342
1489
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
1343
1490
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
1491
+ dimMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan; setDimMode syncs the button/body.dimon classes + clears any draft/preview (dimsVisible persists across plans)
1344
1492
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
1345
1493
  P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
1346
1494
  if(!P.autofilled){autofillTOS();P.autofilled=true;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {