@floless/app 0.37.1 → 0.39.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.1" : void 0,
52859
+ define: true ? "0.39.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.1" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.39.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,21 @@
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} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
150
+ circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
151
+ .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
152
+ body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
144
153
  body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
145
154
  body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
146
155
  circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
147
156
  text.numtx{fill:#fff;font:bold 12px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
148
157
  #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}
158
+ #dupB.ok,#revB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
150
159
  header select#planSel{width:auto;min-width:210px;padding:5px 8px;font-weight:600}
151
160
  /* 2D|3D view toggle — segmented, far-left; active segment filled (--panel) with a --brand underline */
152
161
  #viewToggle{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;flex:none}
@@ -188,12 +197,17 @@
188
197
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
189
198
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
190
199
  <button id=mAdd title="Toggle add-member mode">Add member</button>
200
+ <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
201
  <button id=askAiBtn>Ask AI ▸</button>
192
202
  <div id=moreWrap>
193
203
  <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
194
204
  <div id=moreMenu role=menu>
205
+ <div class=mlabel>Dimensions</div>
206
+ <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
207
+ <hr>
195
208
  <div class=mlabel>Review</div>
196
209
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
210
+ <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
211
  <button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
198
212
  <hr>
199
213
  <div class=mlabel>Reference</div>
@@ -397,6 +411,8 @@ function showAiUpdateBanner(message) {
397
411
  function main() {
398
412
  let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
399
413
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
414
+ 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
415
+ let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off}, and a counter for unique ids on rapid clicks
400
416
  let selIds=new Set();
401
417
  let undo=[], redo=[];
402
418
  const byId=id=>P.members.find(m=>m.id===id);
@@ -448,9 +464,15 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
448
464
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
449
465
  const autofillTOS=syncDefaults;
450
466
  // profs is per-plan — computed in setPlan()
451
- function snapshot(){return JSON.stringify(P.members);}
467
+ function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
452
468
  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();}
469
+ function apply(json){const d=JSON.parse(json);
470
+ if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
471
+ else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;}
472
+ selIds=new Set([...selIds].filter(id=>byId(id)));
473
+ selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
474
+ dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
475
+ scheduleSave();render();sync3D();}
454
476
  function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
455
477
  function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
456
478
  function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
@@ -465,7 +487,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
465
487
  else el.textContent=msg||'Auto-save on';}
466
488
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
467
489
  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);}}
490
+ 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
491
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
470
492
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
471
493
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
@@ -494,7 +516,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
494
516
  if(d.custom_details)C.custom_details=d.custom_details;
495
517
  if(d.profile_colors)C.profile_colors=d.profile_colors;
496
518
  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;}});
519
+ 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
520
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
499
521
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
500
522
  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 +527,48 @@ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y
505
527
  const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
506
528
  function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
507
529
  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};}
530
+ // --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
531
+ // y -> the dim-line X; free -> signed perpendicular distance from the a->b baseline. ---
532
+ function dimGeo(a,b,axis,off){
533
+ 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])};}
534
+ 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])};}
535
+ 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)
536
+ const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
537
+ return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
538
+ // measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
539
+ function dimValueText(px){return fmtFtIn(px/FT*12);}
540
+ // the offset a click at pointer p implies, given the two anchors + axis.
541
+ function dimOffFromPointer(a,b,axis,p){
542
+ if(axis==='x')return p[1];
543
+ if(axis==='y')return p[0];
544
+ const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
545
+ return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
546
+ // a sensible offset for the stage-1 preview (before the 3rd click picks one): clear of the anchors by a constant screen distance.
547
+ function dimDefaultOff(a,b,axis){
548
+ if(axis==='x')return Math.min(a[1],b[1])-28/zoom; // X dim line sits above the higher anchor
549
+ if(axis==='y')return Math.min(a[0],b[0])-28/zoom; // Y dim line sits left of the leftmost anchor
550
+ return -28/zoom;} // free: a constant perpendicular offset
551
+ // one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
552
+ // screen); the chip rect/text + end dots are sized in 1/zoom and re-synced by updateBadges on zoom.
553
+ function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off);
554
+ const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
555
+ const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
556
+ // a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
557
+ // horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
558
+ // 8° of horizontal it stays flat.
559
+ 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;}
560
+ 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]}"/>`;
561
+ s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
562
+ 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}"/>`;
563
+ 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}"/>`
564
+ +`<text class="dimtx ${c}" x="${g.mid[0]}" y="${g.mid[1]}" style="font-size:${13/zoom}px">${txt}</text>`;
565
+ 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
566
+ return s;}
567
+ function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
568
+ let s=P.dims.map(d=>dimSvg(d,{id:d.id,sel:selDimIds.has(d.id)})).join('');
569
+ const HR=epR();for(const d of P.dims)if(selDimIds.has(d.id)) // each selected dim gets draggable anchor handles — drag an end to re-measure (snaps like drawing; Alt off)
570
+ s+=`<circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="0" cx="${d.a[0]}" cy="${d.a[1]}" r="${HR}"/><circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="1" cx="${d.b[0]}" cy="${d.b[1]}" r="${HR}"/>`;
571
+ return s;}
508
572
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
509
573
  // 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
574
  // Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
@@ -518,7 +582,7 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
518
582
  m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
519
583
  const c=JSON.parse(JSON.stringify(base));c.id='m'+Date.now();c.wp=[q.slice(),b];
520
584
  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();}
585
+ 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
586
  // --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
523
587
  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
588
  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 +608,15 @@ function _mAble(a,b){if((a.profile||'').toUpperCase()!==(b.profile||'').toUpperC
544
608
  const s=_mShare(a,b);if(!s)return null;return _mStraight(a,b,s)&&_mElev(a,b,s)?s:null;}
545
609
  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
610
  return dy<=dx*t?'H':dx<=dy*t?'V':'D';}
611
+ // A beam is drawn the canonical way when its work-line runs left→right (mostly-horizontal) or
612
+ // bottom→up (steeper/skew). Display space is y-down, so "up" = a smaller y. Returns true when
613
+ // start→end points the WRONG way — swapping its ends (P) would make it canonical. 45° split:
614
+ // |run| ≥ |rise| ⇒ judge left/right, else up/down. (A zero-length beam has no direction.)
615
+ function isReversed(m){if(!m||!m.wp||m.wp.length<2)return false;
616
+ 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;
617
+ return Math.abs(dx)>=Math.abs(dy)?dx<0:dy>0;}
618
+ // Ids of the active plan's beams (not columns) drawn the wrong way — the "Reversed beams" selection.
619
+ function reversedBeams(){return P.members.filter(m=>m.role!=='column'&&isReversed(m)).map(m=>m.id);}
547
620
  function mergeCollinearJS(members){
548
621
  const idx=members.map((_,i)=>i).filter(i=>_mElig(members[i]));
549
622
  const adj=new Map();for(const i of idx)adj.set(i,[]);
@@ -593,7 +666,7 @@ function fitToWindow(){applyZoom(Math.min(stage.clientWidth/EXTX,stage.clientHei
593
666
  function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot(),ns=new Set();
594
667
  let i=0;for(const s of arr){const id='m'+Date.now()+'_'+(i++);const c=JSON.parse(JSON.stringify(s));
595
668
  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();}
669
+ selIds=ns;selDimIds.clear();mode='sel';setMode();pushUndo(pv);render();}
597
670
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
598
671
  function render(){
599
672
  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 +686,24 @@ function render(){
613
686
  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
687
  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
688
  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>`;});}}
689
+ s+=renderDims();
616
690
  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
691
  if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
618
692
  }
619
693
  function updDup(){const n=redundantDups().length;
620
694
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
621
695
  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';}
696
+ b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Check duplicates';}
623
697
  // keep selection-number badges constant on screen while zooming; the end dots grow with zoom (epR) so
624
698
  // they stay readable against the thick member line — both must track zoom live as the user zooms.
625
699
  function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
626
700
  svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
627
701
  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()));}
702
+ svg.querySelectorAll('circle.handle,circle.dimhandle').forEach(h=>h.setAttribute('r',epR()));
703
+ const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
704
+ svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
705
+ svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
706
+ 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
707
  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
708
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
631
709
  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 +718,18 @@ const VARIES=Symbol('varies');
640
718
  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
719
  function panel(){
642
720
  const p=document.getElementById('panel');
721
+ if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
722
+ <div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
723
+ <div class=sect style="margin-top:12px">Direction</div>
724
+ <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>
725
+ <div class=sect style="margin-top:12px">Mode</div>
726
+ <div class=seg2><button id=dimChainB class="${dimChain?'on':''}" title="Chain (C) — keep adding dimensions from the last point; Esc/Enter ends the chain">⛓ Chain</button></div>
727
+ <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>`;
728
+ document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
729
+ document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
730
+ document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
731
+ document.getElementById('dimChainB').onclick=()=>toggleDimChain();
732
+ return;}
643
733
  if(mode==='add'){
644
734
  p.innerHTML=`<span class=badge>Add member</span>
645
735
  <div class=sect>Current profile</div>
@@ -823,12 +913,73 @@ function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
823
913
  return {x:sx,y:sy,hit:false};}
824
914
  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
915
  function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
916
+ // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
917
+ // buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
918
+ // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
919
+ const SVGNS='http://www.w3.org/2000/svg';
920
+ function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
921
+ 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
922
+ dimDraft=null;dimChainPrev=null;dimPrevClear();}
923
+ function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
924
+ 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};}
925
+ return {x,y,raw:[q.x,q.y],hit:false};}
926
+ function dimClick(e){const s=dimSnapAt(e);
927
+ if(dimChainPrev){const a=dimChainPrev.point,b=[s.x,s.y],axis=dimChainPrev.axis,off=dimChainPrev.off; // CHAIN: each click adds the next dim from the previous point, on the same axis + offset line
928
+ const span=axis==='x'?Math.abs(b[0]-a[0]):axis==='y'?Math.abs(b[1]-a[1]):Math.hypot(b[0]-a[0],b[1]-a[1]);
929
+ if(span<1){dimPrev(e);return;} // ignore a zero / sub-pixel click — don't commit a degenerate dim
930
+ edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off});});
931
+ dimChainPrev={point:b,axis,off};dimPrev(e);return;} // keep going from b; Esc/Enter ends the chain
932
+ if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
933
+ if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
934
+ 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
935
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw); // click 3 → offset/side from raw pointer
936
+ const a=dimDraft.a,b=dimDraft.b;
937
+ edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off});}); // undoable; render() clears the preview
938
+ if(dimChain)dimChainPrev={point:b,axis,off}; // chain mode → continue from b along this same offset line
939
+ dimDraft=null;}
940
+ function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
941
+ function dimPrev(e){if(!dimMode){dimPrevClear();return;}
942
+ {const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();} // keep the panel hint live (chain / stage state)
943
+ if(dimChainPrev){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear(); // chain: preview the next segment from the last point at the shared axis + offset
944
+ let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
945
+ g.innerHTML=dimSvg({a:dimChainPrev.point,b:[s.x,s.y],axis:dimChainPrev.axis,off:dimChainPrev.off},{preview:true});return;}
946
+ if(!dimDraft){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear();const og=document.getElementById('dimPrevG');if(og)og.remove();return;} // before click 1: show the snap marker for the prospective FIRST anchor (no dim drawn yet)
947
+ let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
948
+ 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
949
+ const b=[s.x,s.y];dimLastPtr=b;
950
+ 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)
951
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax)},{preview:true});return;}
952
+ const q=toSvg(e);dimLastPtr=[q.x,q.y];snapClear();
953
+ 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
954
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y]); // stage 2: full dim rides the pointer
955
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off},{preview:true});dimDraft._ax=ax;}
956
+ function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
957
+ const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
958
+ 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)
959
+ function dimHintText(){if(dimChainPrev)return 'Chaining — click the next point to add another dimension from the last one. Enter or Esc ends the chain.';
960
+ const a={free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
961
+ return 'Dimension — '+a+(dimChain?' · Chain ON':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
962
+ function toggleDimChain(){dimChain=!dimChain;if(!dimChain){dimChainPrev=null;dimPrevClear();}render();} // Chain (C): toggle continuous dimensioning; turning it off ends any running chain (render() repaints the panel)
963
+ let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
964
+ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
965
+ const g=document.getElementById('dimPrevG');if(!g)return;
966
+ if(!dimDraft.b){const b=dimLastPtr; // stage 1: redraw the forming dim with the just-changed axis (key/button), no mouse move needed
967
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis)},{preview:true});return;}
968
+ 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
969
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off},{preview:true});}
826
970
  // themed transient toast (baseline tokens — never a native alert)
827
971
  function toast(msg){let t=document.getElementById('toast');
828
972
  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
973
  t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
830
974
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
831
975
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
976
+ if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
977
+ if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(null);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps)
978
+ if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
979
+ 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
980
+ 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)
981
+ 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
982
+ 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
983
  if(geoMode==='split'&&selIds.size===1){const id=[...selIds][0],m=byId(id);
833
984
  const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
834
985
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
@@ -858,14 +1009,17 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
858
1009
  const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
859
1010
  buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
860
1011
  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);
1012
+ 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
1013
  const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
863
1014
  drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
864
1015
  svg.addEventListener('pointermove',e=>{
1016
+ if(dimMode){dimPrev(e);return;}
865
1017
  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
1018
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
867
1019
  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
1020
  if(!drag)return;const p=toSvg(e);
1021
+ if(drag.type==='dimend'){const d=P.dims.find(x=>x.id===drag.id);if(d){let x=p.x,y=p.y;if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();if(drag.end===0)d.a=[x,y];else d.b=[x,y];render();}return;} // dragging a dim anchor → snap + re-measure live
1022
+ 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
1023
  if(drag.type==='draw'){let x=p.x,y=p.y;
870
1024
  if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
871
1025
  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 +1048,28 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
894
1048
  const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(WT[addProfile]==null)}));
895
1049
  if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
896
1050
  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);
1051
+ if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
1052
+ 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);
1053
+ 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
1054
  drag.rect.remove();drag=null;render();return;}
900
1055
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
901
1056
  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();};
1057
+ 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
1058
+ document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
1059
+ function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
1060
+ document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
903
1061
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
904
1062
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
905
1063
  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
1064
+ if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
1065
+ document.getElementById('revB').onclick=()=>{const ids=reversedBeams(); // beams drawn against the canonical direction
1066
+ if(!ids.length){const b=document.getElementById('revB');b.dataset.flash='1';b.classList.add('ok');b.textContent='All aligned ✓';
1067
+ setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');b.textContent='Reversed beams';},1500);return;}
1068
+ if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();
1069
+ toast(ids.length+' reversed beam'+(ids.length>1?'s':'')+' selected — press P to swap direction');}; // select reversed → review → P swaps them
907
1070
  document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members); // conservative; result is undoable (Ctrl+Z)
908
1071
  if(!r.mergedAway){toast('Nothing to merge');return;}
909
- edit(()=>{P.members=r.members;selIds=new Set();});
1072
+ edit(()=>{P.members=r.members;selIds=new Set();selDimIds.clear();});
910
1073
  toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
911
1074
  document.getElementById('undoB').onclick=doUndo;
912
1075
  document.getElementById('redoB').onclick=doRedo;
@@ -921,8 +1084,16 @@ addEventListener('keydown',e=>{
921
1084
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
922
1085
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
923
1086
  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)
1087
+ if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1088
+ if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
924
1089
  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;}
1090
+ 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)
1091
+ if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
1092
+ if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
1093
+ if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
1094
+ if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1095
+ if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1096
+ if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
926
1097
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
927
1098
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
928
1099
  if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
@@ -931,7 +1102,7 @@ addEventListener('keydown',e=>{
931
1102
  if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
932
1103
  else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
933
1104
  else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
934
- else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
1105
+ else if(k==='a'&&!inForm){e.preventDefault();selDimIds.clear();selIds=new Set(P.members.map(m=>m.id));render();}
935
1106
  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)
936
1107
  // zoom bar
937
1108
  document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
@@ -950,8 +1121,8 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
950
1121
  const view3dApi={
951
1122
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
952
1123
  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}
953
- 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
954
- onSelectMany:(ids)=>{selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
1124
+ 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)
1125
+ onSelectMany:(ids)=>{selDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
955
1126
  getMembers:()=>P.members, // raw members (wp) for snap geometry
956
1127
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
957
1128
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
@@ -1143,7 +1314,7 @@ function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList
1143
1314
  :'<div class=hint>No unresolved members — every member has a resolved AISC size. ✓</div>';
1144
1315
  g.querySelectorAll('input.combo[data-mid]').forEach(inp=>inp.onchange=e=>{const m=byId(inp.dataset.mid);if(!m)return;
1145
1316
  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();});
1146
- 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);});
1317
+ 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);});
1147
1318
  document.getElementById('rfiModal').style.display='flex';}
1148
1319
  function closeRFI(){document.getElementById('rfiModal').style.display='none';}
1149
1320
  function rfiOpen(){return document.getElementById('rfiModal').style.display==='flex';}
@@ -1250,7 +1421,7 @@ function wireConf(){
1250
1421
  document.querySelectorAll('#confFilter [data-band]').forEach(b=>b.onclick=()=>{confBand=b.dataset.band;renderConf();});
1251
1422
  document.querySelectorAll('#confFilter [data-fcat]').forEach(b=>b.onclick=()=>{confCat=b.dataset.fcat;renderConf();});
1252
1423
  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();});
1253
- 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);});}
1424
+ 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);});}
1254
1425
  document.getElementById('confStat').onclick=openConf;
1255
1426
  document.getElementById('confTarget').onchange=e=>{ // per-read target override → contract.target_confidence (never the .flo)
1256
1427
  const raw=String(e.target.value).trim();
@@ -1320,6 +1491,7 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
1320
1491
  function setPlan(i){C.active=i;P=C.plans[i];
1321
1492
  if(!Array.isArray(P.members))P.members=[];
1322
1493
  if(!Array.isArray(P.segments))P.segments=[];
1494
+ if(!Array.isArray(P.dims))P.dims=[];
1323
1495
  // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
1324
1496
  // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
1325
1497
  // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
@@ -1342,6 +1514,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
1342
1514
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
1343
1515
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
1344
1516
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
1517
+ dimMode=false;dimChain=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan; setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
1345
1518
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
1346
1519
  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)
1347
1520
  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.1",
3
+ "version": "0.39.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": {