@floless/app 0.36.0 → 0.37.1

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.36.0" : void 0,
52859
+ define: true ? "0.37.1" : 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.36.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.37.1" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -464,9 +464,9 @@ function addEndpoint(p, mat, id, end) {
464
464
  }
465
465
  function sizeEndpoints() {
466
466
  if (!epGroup) return;
467
- const r = pxToWorld(EP_PX); // small + screen-constant (shrinks as you zoom in, persp AND ortho)
468
467
  const active = dragEp || hoverEp;
469
468
  for (const c of epGroup.children) {
469
+ const r = pxToWorldAt(EP_PX, c.position); // per-dot depth → stays ~EP_PX on screen even zoomed in far from the pivot
470
470
  const isActive = active && c.userData.epId === active.id && c.userData.epEnd === active.end;
471
471
  c.scale.setScalar(isActive ? r * 1.6 : r); // the targeted end node pops
472
472
  }
@@ -474,7 +474,7 @@ function sizeEndpoints() {
474
474
  if (epRing) {
475
475
  let dot = null;
476
476
  if (active) dot = epGroup.children.find((c) => c.userData.epId === active.id && c.userData.epEnd === active.end);
477
- if (dot) { epRing.position.copy(dot.position); epRing.quaternion.copy(camera.quaternion); epRing.scale.setScalar(r * 2.4); epRing.visible = true; }
477
+ if (dot) { epRing.position.copy(dot.position); epRing.quaternion.copy(camera.quaternion); epRing.scale.setScalar(pxToWorldAt(EP_PX, dot.position) * 2.4); epRing.visible = true; }
478
478
  else epRing.visible = false;
479
479
  }
480
480
  }
@@ -582,20 +582,23 @@ const members = () => (api && api.getMembers && api.getMembers()) || [];
582
582
  const geoMode = () => (api && api.geoMode && api.geoMode()) || null;
583
583
  // world units per screen pixel at the target plane — drives screen-CONSTANT marker/dot sizes, for
584
584
  // BOTH perspective (distance) and ortho (frustum height / zoom), so dots stay small when zoomed in.
585
- function worldPerPixel() {
585
+ // world units that map to `px` screen pixels AT a world point. In perspective the scale uses that
586
+ // point's OWN distance to the camera (not the orbit target), so a dot far from the pivot keeps its
587
+ // size when you zoom in heavily; in ortho it's the frustum height / zoom.
588
+ function pxToWorldAt(px, pos) {
586
589
  const h = (canvasEl && canvasEl.clientHeight) || 1;
587
- if (camera === orthoCam) return ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) / h;
588
- return (2 * Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2) * camera.position.distanceTo(controls.target)) / h;
590
+ if (camera === orthoCam) return px * ((orthoCam.top - orthoCam.bottom) / (orthoCam.zoom || 1)) / h;
591
+ const dist = pos ? camera.position.distanceTo(pos) : camera.position.distanceTo(controls.target);
592
+ return px * (2 * Math.tan(THREE.MathUtils.degToRad(perspCam.fov) / 2) * dist) / h;
589
593
  }
590
- const pxToWorld = (px) => px * worldPerPixel();
591
- const markerSize = () => pxToWorld(8); // snap marker ~8px radius
594
+ const markerSize = () => pxToWorldAt(8, marker && marker.position); // snap marker ~8px at its own depth
592
595
 
593
596
  function onDown(e) {
594
597
  if (e.button !== 0) return; // RIGHT/MIDDLE → OrbitControls (orbit/pan)
595
598
  downXY = [e.clientX, e.clientY];
596
599
  let id = null; try { id = pickAt(e.clientX, e.clientY); } catch { id = null; }
597
600
  // Geometry-edit mode (Trim/Extend or Split armed in the inspector): a click does the edit, no drag.
598
- if (geoMode() && selIds.size === 1) { e.stopPropagation(); pending = { geo: geoMode(), selId: [...selIds][0], targetId: id }; return; }
601
+ if (geoMode() && (geoMode() === 'el' ? selIds.size >= 1 : selIds.size === 1)) { e.stopPropagation(); pending = { geo: geoMode(), selId: [...selIds][0], targetId: id }; return; } // Split: single; Extend/Trim: any selection
599
602
  // An end node (the yellow/magenta dot) under the cursor → grab just that endpoint to stretch the member.
600
603
  let ep = null; try { ep = pickEndpoint(e.clientX, e.clientY); } catch { ep = null; }
601
604
  if (ep) { e.stopPropagation(); controls.enabled = false; startEndpointGrab(ep, e); return; }
@@ -739,7 +742,7 @@ function onUp(e) {
739
742
  }
740
743
  if (p.geo) { // Trim/Extend or Split — a click (no drag) performs the edit
741
744
  if (moved > DRAG_TOL_PX) return;
742
- if (p.geo === 'el') { if (p.targetId && p.targetId !== p.selId && api && api.onTrimExtend) api.onTrimExtend(p.selId, p.targetId); return; }
745
+ if (p.geo === 'el') { if (p.targetId && !selIds.has(p.targetId) && api && api.onTrimExtend) api.onTrimExtend(p.targetId); return; } // target line ≠ a selected member; trims ALL selected
743
746
  if (p.geo === 'split') { // cut the selected member at the clicked point on its centreline
744
747
  const m = members().find((x) => x.id === p.selId); if (!m) return;
745
748
  const ppf = api.ptPerFt();
@@ -59,7 +59,7 @@
59
59
  .elabrow{display:flex;align-items:center;justify-content:space-between;margin:8px 0 2px}
60
60
  .elabrow .elab{margin:0}
61
61
  .defck{display:inline-flex;align-items:center;gap:4px;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);cursor:pointer}
62
- .defck input{width:auto;margin:0;cursor:pointer}
62
+ .defck input{width:auto;margin:0;cursor:pointer;accent-color:var(--brand)}
63
63
  input:disabled{opacity:.55;cursor:default}
64
64
  .badge{display:block;background:var(--brand);color:#fff;text-align:center;font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:5px;border-radius:6px;margin-bottom:10px}
65
65
  .pick{color:var(--brand)!important}
@@ -113,6 +113,18 @@
113
113
  #comboPop{position:fixed;z-index:30;background:var(--panel);border:1px solid #475569;border-radius:6px;max-height:240px;overflow:auto;box-shadow:0 8px 24px rgba(0,0,0,.5);display:none}
114
114
  #comboPop .opt{padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap}
115
115
  #comboPop .opt:hover,#comboPop .opt.active{background:#334155}
116
+ /* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
117
+ #moreWrap{position:relative;display:inline-flex}
118
+ #moreMenu{position:absolute;right:0;top:calc(100% + 6px);min-width:210px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30;display:none}
119
+ #moreMenu.open{display:block}
120
+ #moreMenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
121
+ #moreMenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
122
+ #moreMenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
123
+ #moreMenu button:hover{background:#334155}
124
+ #moreMenu button.mdanger{color:#fca5a5}
125
+ #moreMenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
126
+ button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
127
+ #srcStat{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
116
128
  #detailNewForm input#dnName{margin:2px 0 10px}
117
129
  #dnDrop{border:1px dashed #475569;border-radius:8px;background:#0b1220;min-height:88px;display:flex;align-items:center;justify-content:center;gap:12px;text-align:center;color:var(--mut);cursor:pointer;padding:12px;font-size:12px}
118
130
  #dnDrop:hover{border-color:var(--brand);color:var(--text)} #dnDrop.has{border-style:solid;border-color:var(--brand)}
@@ -175,15 +187,26 @@
175
187
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
176
188
  <button id=undoB title="Undo (Ctrl+Z)">↶</button>
177
189
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
178
- <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
179
- <button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
180
190
  <button id=mAdd title="Toggle add-member mode">Add member</button>
181
- <button id=detailsBtn>Details</button>
182
- <button id=framesBtn>Frames</button>
183
- <button id=revertB title="Discard all saved edits and reload the detected contract">Revert</button>
184
- <button id=exp>Export contract</button>
185
- <button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
186
191
  <button id=askAiBtn>Ask AI ▸</button>
192
+ <div id=moreWrap>
193
+ <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
194
+ <div id=moreMenu role=menu>
195
+ <div class=mlabel>Review</div>
196
+ <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
197
+ <button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
198
+ <hr>
199
+ <div class=mlabel>Reference</div>
200
+ <button id=detailsBtn>Details</button>
201
+ <button id=framesBtn>Frames</button>
202
+ <hr>
203
+ <div class=mlabel>Session</div>
204
+ <button id=reloadB title="Reload from server — picks up any AI writebacks">↻ Reload</button>
205
+ <button id=exp>Export contract</button>
206
+ <hr>
207
+ <button id=revertB class=mdanger title="Discard all saved edits and reload the detected contract">Revert</button>
208
+ </div>
209
+ </div>
187
210
  </header>
188
211
  <main>
189
212
  <div id=stagewrap>
@@ -409,6 +432,14 @@ function posDefault(m){
409
432
  const z1=(m.ends&&m.ends[1]&&m.ends[1].tos!=null)?m.ends[1].tos:defaultTOS;
410
433
  const dz=Math.abs((z0||0)-(z1||0))*25.4, horiz=Math.hypot(m.wp[1][0]-m.wp[0][0],m.wp[1][1]-m.wp[0][1])*k;
411
434
  return dz>horiz*0.5774?'middle':'top';}
435
+ // Swap a member's start (yellow, end 1) and end (magenta, end 2) handles — reverse its direction.
436
+ // Beam: reverse wp + the per-end metadata (each end's TOS/note/detail follows its point). Column:
437
+ // the two handles are bottom (BOS) and top (TOS), so swap those. Used by 2D + 3D via the same panel.
438
+ function swapMemberEnds(m){
439
+ if(Array.isArray(m.wp)&&m.wp.length>=2)m.wp=[m.wp[1].slice(),m.wp[0].slice()];
440
+ if(Array.isArray(m.ends)&&m.ends.length>=2)m.ends=[m.ends[1],m.ends[0]];
441
+ if(m.col){const b=(m.col.bos!=null?m.col.bos:0),t=(m.col.tos!=null?m.col.tos:defaultTOS);m.col.bos=t;m.col.tos=b;m.col.tosDef=false;}
442
+ return m;}
412
443
  // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
413
444
  // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
414
445
  let defaultTOS=198, addProfile='';
@@ -476,8 +507,12 @@ function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
476
507
  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};}
477
508
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
478
509
  // 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.
479
- function snapEnd(m,tA,tB){const I=lineX(m.wp[0],m.wp[1],tA,tB);if(!I)return false;
480
- const pv=snapshot();const h=len(m.wp[0],I)<=len(m.wp[1],I)?0:1;m.wp[h]=I;m.rfi=(WT[m.profile]==null);pushUndo(pv);render();return true;}
510
+ // Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
511
+ // parallel to the target (no intersection) are skipped. One undo for the whole batch.
512
+ function snapEndMulti(ms,tA,tB){const pv=snapshot();let n=0;
513
+ for(const m of ms){const I=lineX(m.wp[0],m.wp[1],tA,tB);if(!I)continue;
514
+ const h=len(m.wp[0],I)<=len(m.wp[1],I)?0:1;m.wp[h]=I;m.rfi=(WT[m.profile]==null);n++;}
515
+ if(n){pushUndo(pv);render();sync3D();}return n;}
481
516
  function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(JSON.stringify(m));
482
517
  const a=m.wp[0].slice(),b=m.wp[1].slice(),q=pt.slice(),mk=()=>({tos:null,note:'',tosDef:true,detail:''});
483
518
  m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
@@ -535,6 +570,10 @@ const svg=document.getElementById('svg');
535
570
  function toSvg(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
536
571
  // --- zoom + pan ---
537
572
  const ZMIN=0.1, ZMAX=4;
573
+ // start/end dot radius in CONTENT units. The member line is a content-unit stroke (scales with zoom),
574
+ // so a screen-constant dot gets dwarfed by the thick line at high zoom. This grows with zoom (the +3
575
+ // content term) while keeping a screen-px floor at low zoom (the 5/zoom term): ~7px at fit, ~17px at 400%.
576
+ const epR=()=>3+5/zoom;
538
577
  let zoom=1; const stage=document.getElementById('stage');
539
578
  // The editor runs in an iframe; keyboard shortcuts (Ctrl+D etc.) only reach our handler when the
540
579
  // iframe has focus. The SVG canvas isn't focusable, so clicking it wouldn't grab focus and a real
@@ -557,12 +596,12 @@ function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot
557
596
  selIds=ns;mode='sel';setMode();pushUndo(pv);render();}
558
597
  function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));}
559
598
  function render(){
560
- if(geoMode&&selIds.size!==1){geoMode=null;document.body.classList.remove('geo');} // geo edit needs exactly one member
599
+ 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
561
600
  let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
562
601
  for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
563
602
  for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
564
603
  s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
565
- {const hsel=selArr();if(hsel.length>=1&&hsel.length<=2){const HR=5.5/zoom;for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · small + zoom-constant
604
+ {const hsel=selArr();if(hsel.length>=1){const HR=epR();for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class="handle ${i===0?'ep-start':'ep-end'}" data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}} // end 1 (start) yellow, end 2 (end) magenta · shown for every selected member · radius grows with zoom (epR) so it stays visible against the thick line
566
605
  if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
567
606
  s+=`<rect class=lblhot data-prof="${esc(lb.text)}" x="${lb.disp[0]-w/2}" y="${lb.disp[1]-10}" width="${w}" height="20" rx="3"><title>${esc(lb.text)}</title></rect>`;}
568
607
  if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
@@ -581,11 +620,12 @@ function updDup(){const n=redundantDups().length;
581
620
  document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
582
621
  const b=document.getElementById('dupB');if(b.dataset.flash)return; // don't clobber transient "none" feedback
583
622
  b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Re-check duplicates';}
584
- // keep selection-number badges + endpoint handles a constant size on screen at any zoom
623
+ // keep selection-number badges constant on screen while zooming; the end dots grow with zoom (epR) so
624
+ // they stay readable against the thick member line — both must track zoom live as the user zooms.
585
625
  function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
586
626
  svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
587
627
  svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
588
- svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',5.5/zoom));}
628
+ svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));}
589
629
  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]);});}
590
630
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
591
631
  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]);}}
@@ -594,6 +634,10 @@ function stats(){
594
634
  let w=0,rfi=0; for(const m of P.members){const wpf=WT[m.profile]; if(wpf==null)rfi++; else w+=len(m.wp[0],m.wp[1])/FT*wpf;}
595
635
  document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
596
636
  }
637
+ // Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
638
+ // "Varies" placeholders + the indeterminate "default" checkbox in the multi-edit panel. get() must return a primitive.
639
+ const VARIES=Symbol('varies');
640
+ 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;}
597
641
  function panel(){
598
642
  const p=document.getElementById('panel');
599
643
  if(mode==='add'){
@@ -619,15 +663,71 @@ function panel(){
619
663
  const _rc=document.getElementById('resetCols');if(_rc)_rc.onclick=()=>{C.profile_colors={};scheduleSave();render();};
620
664
  return;}
621
665
  if(arr.length>1){
666
+ arr.forEach(ensureMeta);
667
+ const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
668
+ const allBeam=cols.length===0, allCol=beams.length===0;
622
669
  const totalL=arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT,0).toFixed(1);
623
- const shared=arr.every(m=>m.profile===arr[0].profile)?arr[0].profile:null;
624
- const dupSet=new Set(redundantDups());const dupSel=arr.filter(m=>dupSet.has(m.id)).length;
670
+ const allKnown=arr.every(m=>WT[m.profile]!=null);
671
+ const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT*WT[m.profile],0)):null;
672
+ const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
673
+ const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
674
+ const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
675
+ const exPH='5 3/4&quot; · 1&#39;-0 1/4&quot;';
676
+ // multi-aware field renderers: show the shared value when all agree, else a "Varies" placeholder (typing applies to all).
677
+ const mTos=(id,label,os)=>{const defA=agg(os,o=>o.tosDef!==false),def=defA===true,vEff=def?defaultTOS:agg(os,o=>o.tosDef!==false?defaultTOS:o.tos);
678
+ return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div>`+
679
+ `<input id=${id} inputmode=decimal placeholder="${(!def&&VV(vEff))?'Varies':exPH}" value="${def?esc(fmtFtIn(defaultTOS)):valOf(vEff)}"${def?' disabled':''}><span class=edec>${def?esc(fmtDecIn(defaultTOS)):decOf(vEff)}</span>`;};
680
+ const mFt=(id,label,os,get)=>{const a=agg(os,get);return `<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="${VV(a)?'Varies':exPH}" value="${valOf(a)}"><span class=edec>${decOf(a)}</span>`;};
681
+ const mNote=(id,os)=>{const a=agg(os,o=>o.note||'');return `<input id=${id} class="f combo" data-src=conntypes placeholder="${VV(a)?'Varies':'connection / note'}" value="${VV(a)?'':esc(a||'')}" autocomplete=off>`;};
682
+ const mDet=(id,os)=>{const a=agg(os,o=>o.detail||'');return `<div class=elab>Connection detail</div><input id=${id} class=combo data-src=details placeholder="${VV(a)?'Varies':'e.g. 5-S504'}" value="${VV(a)?'':esc(a||'')}" autocomplete=off>`;};
683
+ const dotS='<span class=epdot style="background:#facc15"></span>',dotE='<span class=epdot style="background:#f472b6"></span>';
684
+ let elev='';
685
+ if(!allBeam&&!allCol) elev=`<div class="hint f" style="border:1px solid var(--line);border-radius:6px;padding:8px 10px">Mixed beam + column — select one type only to edit levels.</div>`;
686
+ else if(allCol){const cs=cols.map(m=>m.col);
687
+ elev=`<div class="sect f">Elevation</div>${mFt('bos',dotS+'Bottom (BOS)',cs,o=>o.bos)}${mTos('tosC',dotE+'Top (TOS)',cs)}${mNote('ntC',cs)}${mDet('dtC',cs)}`;}
688
+ else{const s0=beams.map(m=>m.ends[0]),s1=beams.map(m=>m.ends[1]);
689
+ elev=`<div class="sect f">${dotS}Start</div>${mTos('tosA','TOS',s0)}${mNote('ntA',s0)}${mDet('dtA',s0)}`+
690
+ `<div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends (each)</button><hr></div>`+
691
+ `<div class=sect>${dotE}End</div>${mTos('tosB','TOS',s1)}${mNote('ntB',s1)}${mDet('dtB',s1)}`;}
692
+ const posA=!allCol?agg(beams,m=>posDefault(m)):null;
625
693
  p.innerHTML=`<h3>${arr.length} members selected</h3>
626
- <div class=row><label>Profile</label><input id=pf class=combo data-src=profiles placeholder="${shared==null?'— mixed —':''}" value="${shared==null?'':esc(shared)}" autocomplete=off></div>
627
- <div class="row hint">Total length <b>${totalL} ft</b>${dupSel?` · <span style="color:#fca5a5">${dupSel} duplicate${dupSel>1?'s':''}</span> (overlap a kept member — deleting these dedupes)`:''}</div>
628
- <div class=row><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
694
+ <div class="row hint" style="margin-top:0">Edits apply to <b>all ${arr.length}</b> selected · a blank <b>Varies</b> field is left unchanged.</div>
695
+ <div class=row><label>Profile</label><input id=pf class=combo data-src=profiles placeholder="${VV(profAgg)?'Varies':''}" value="${VV(profAgg)?'':esc(profAgg||'')}" autocomplete=off></div>
696
+ <div class="seg2 f"><button id=rBeam class="${allBeam?'on':''}">Beam</button><button id=rCol class="${allCol?'on':''}">Column</button></div>
697
+ ${(!allBeam&&!allCol)?`<div class=hint style="margin-top:4px">Mixed — ${beams.length} beam${beams.length>1?'s':''}, ${cols.length} column${cols.length>1?'s':''}</div>`:''}
698
+ <div class="row hint">Total length <b>${totalL} ft</b>${totalW!=null?` · <b>${totalW.toLocaleString()} lb</b>`:''}${dupSel?` · <span style="color:#fca5a5">${dupSel} duplicate${dupSel>1?'s':''}</span> (overlap a kept member — deleting these dedupes)`:''}</div>
699
+ <div class=row><button class=ghostw id=verifyBtn${allVerified?' style="border-color:#166534;color:#86efac"':''}>${allVerified?'✓ All verified — click to unverify':'Mark all verified'}</button></div>
700
+ ${elev}
701
+ ${!allCol?`<div class=divrow><hr><span class=sect style="margin:0">Profile position</span><hr></div>
702
+ <div class=hint style="margin:0 0 6px">Where the section sits on the reference line (beams) — like Tekla.</div>
703
+ <div class=seg2 style="margin-top:0"><button id=posTop class="${posA==='top'?'on':''}">Top</button><button id=posMid class="${posA==='middle'?'on':''}">Middle</button><button id=posBot class="${posA==='bottom'?'on':''}">Bottom</button></div>`:''}
704
+ <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
705
+ <div class=seg2 style="margin-top:0"><button id=geoEL class="${geoMode==='el'?'on':''}">Extend / Trim all</button></div>
706
+ <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 · Shortcut: P">⇄ Swap start ↔ end (all) <span style="opacity:.55">(P)</span></button></div>
708
+ <div class="row f"><button class=danger id=del>Delete selected (${arr.length})</button></div>`;
709
+ // wiring — every commit applies to the whole selection; a blank field is a no-op (leaves each member's own value).
629
710
  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();}});};
711
+ document.getElementById('rBeam').onclick=()=>edit(()=>{for(const m of selArr())if(m.role==='column'){m.role='beam';ensureMeta(m);}});
712
+ document.getElementById('rCol').onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column'){m.role='column';ensureMeta(m);}});
713
+ document.getElementById('verifyBtn').onclick=()=>edit(()=>{const v=!allVerified;for(const m of selArr())m.verified=v;});
714
+ {const _gel=document.getElementById('geoEL');if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};}
715
+ {const _sw=document.getElementById('swapEnds');if(_sw)_sw.onclick=()=>edit(()=>{for(const m of selArr())swapMemberEnds(m);});} // reverse start↔end for the whole selection (2D + 3D via render+sync3D)
630
716
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
717
+ {const sp=v=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.position=v;});
718
+ const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
719
+ if(_pt)_pt.onclick=()=>sp('top');if(_pm)_pm.onclick=()=>sp('middle');if(_pb)_pb.onclick=()=>sp('bottom');}
720
+ // elevation wiring: a blank commit is skipped (Varies = unchanged); the "default" checkbox shows indeterminate when mixed.
721
+ const wMTos=(id,os)=>{const ck=document.getElementById(id+'_ck');if(ck){if(agg(os,o=>o.tosDef!==false)===VARIES)ck.indeterminate=true;ck.onchange=e=>edit(()=>{for(const o of os){o.tosDef=e.target.checked;if(o.tosDef)o.tos=defaultTOS;}});}
722
+ const inp=document.getElementById(id);if(inp)inp.onchange=e=>{const t=e.target.value.trim();if(!t)return;edit(()=>{const v=parseLen(t);for(const o of os){o.tos=v;o.tosDef=false;}});};};
723
+ const wMText=(id,os,set)=>{const i=document.getElementById(id);if(i)i.onchange=e=>{const t=e.target.value.trim();if(!t)return;edit(()=>{for(const o of os)set(o,t);});};};
724
+ if(allCol){const cs=cols.map(m=>m.col);
725
+ {const i=document.getElementById('bos');if(i)i.onchange=e=>{const t=e.target.value.trim();if(!t)return;edit(()=>{const v=parseLen(t);for(const o of cs)o.bos=v;});}}
726
+ wMTos('tosC',cs);wMText('ntC',cs,(o,v)=>o.note=v);wMText('dtC',cs,(o,v)=>o.detail=v);}
727
+ else if(allBeam){const s0=beams.map(m=>m.ends[0]),s1=beams.map(m=>m.ends[1]);
728
+ wMTos('tosA',s0);wMText('ntA',s0,(o,v)=>o.note=v);wMText('dtA',s0,(o,v)=>o.detail=v);
729
+ wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
730
+ {const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});}}
631
731
  return;}
632
732
  const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
633
733
  const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
@@ -656,6 +756,7 @@ function panel(){
656
756
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
657
757
  <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>
658
758
  <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 · Shortcut: P">⇄ Swap start ↔ end <span style="opacity:.55">(P)</span></button></div>
659
760
  <div class="row f"><button class=danger id=del>Delete member</button></div>`;
660
761
  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();}});
661
762
  document.getElementById('pickProf').onclick=()=>{if(picking&&pickKind==='profile'){picking=false;}else{picking=true;pickKind='profile';pickEnd=null;}render();};
@@ -665,6 +766,7 @@ function panel(){
665
766
  const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
666
767
  if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
667
768
  if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
769
+ {const _sw=document.getElementById('swapEnds');if(_sw)_sw.onclick=()=>edit(()=>swapMemberEnds(m));} // reverse start↔end (2D + 3D via render+sync3D)
668
770
  {const setPos=v=>edit(()=>{m.position=v;}); // profile placement on the reference line (Tekla-style); persists + re-extrudes the 3D
669
771
  const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
670
772
  if(_pt)_pt.onclick=()=>setPos('top');if(_pm)_pm.onclick=()=>setPos('middle');if(_pb)_pb.onclick=()=>setPos('bottom');}
@@ -690,9 +792,9 @@ function panel(){
690
792
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
691
793
  {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
692
794
  }
693
- function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const P=addProfile;const id='m'+Date.now();const pv=snapshot();
694
- P.members.push(ensureMeta({id,profile:P,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[P]==null)}));
695
- if(P&&!profs.includes(P)){profs.push(P);profs.sort();}pushUndo(pv);render();}
795
+ function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const prof=addProfile;const id='m'+Date.now();const pv=snapshot();
796
+ P.members.push(ensureMeta({id,profile:prof,wp:[sg.a.slice(),sg.b.slice()],angle:sg.o,rfi:(WT[prof]==null)}));
797
+ if(prof&&!profs.includes(prof)){profs.push(prof);profs.sort();}pushUndo(pv);render();sync3D();}
696
798
  // --- marquee hit-test (crossing selection): does segment ab touch rect [x0,y0,x1,y1]? ---
697
799
  function inRect(p,r){return p[0]>=r[0]&&p[0]<=r[2]&&p[1]>=r[1]&&p[1]<=r[3];}
698
800
  function ccw(a,b,c){return (c[1]-a[1])*(b[0]-a[0])-(b[1]-a[1])*(c[0]-a[0]);}
@@ -727,17 +829,18 @@ function toast(msg){let t=document.getElementById('toast');
727
829
  t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
728
830
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
729
831
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
730
- if(geoMode&&selIds.size===1){const id=[...selIds][0],m=byId(id);
731
- if(geoMode==='split'){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
732
- const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
733
- if(d>=16/zoom||raw.t<=0.03||raw.t>=0.97){geoMode=null;setGeo();snapClear();render();return;}
734
- let cut=raw.pt; // snap the cut to a nearby endpoint/grid, kept ON the member
735
- 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;}}
736
- snapClear();doSplit(m,cut);return;}
737
- let tgt=null; // extend/trim → pick a target line
738
- if(t.classList.contains('member')&&t.dataset.id!==id){const tm=byId(t.dataset.id);if(tm)tgt=[tm.wp[0],tm.wp[1]];}
832
+ if(geoMode==='split'&&selIds.size===1){const id=[...selIds][0],m=byId(id);
833
+ const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
834
+ const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
835
+ if(d>=16/zoom||raw.t<=0.03||raw.t>=0.97){geoMode=null;setGeo();snapClear();render();return;}
836
+ let cut=raw.pt; // snap the cut to a nearby endpoint/grid, kept ON the member
837
+ 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;}}
838
+ snapClear();doSplit(m,cut);return;}
839
+ if(geoMode==='el'&&selIds.size>=1){ // extend/trim → pick ONE target line; ALL selected ends snap to it
840
+ let tgt=null;
841
+ if(t.classList.contains('member')&&!selIds.has(t.dataset.id)){const tm=byId(t.dataset.id);if(tm)tgt=[tm.wp[0],tm.wp[1]];}
739
842
  else if(t.classList.contains('seg')){const sg=P.segments.find(s=>s.id===t.dataset.seg);if(sg)tgt=[sg.a,sg.b];}
740
- if(tgt){geoMode=null;setGeo();snapEnd(m,tgt[0],tgt[1]);} // miss (empty/own line) keeps the mode armed
843
+ if(tgt){geoMode=null;setGeo();snapEndMulti(selArr(),tgt[0],tgt[1]);} // miss (empty/own line) keeps the mode armed
741
844
  return;}
742
845
  if(t.classList.contains('handle')){const id=t.dataset.mid||[...selIds][0],m=byId(id),h=+t.dataset.h;if(!m)return;buildSnap(id);
743
846
  drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
@@ -809,6 +912,7 @@ document.getElementById('undoB').onclick=doUndo;
809
912
  document.getElementById('redoB').onclick=doRedo;
810
913
  addEventListener('keydown',e=>{
811
914
  const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
915
+ if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
812
916
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
813
917
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
814
918
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
@@ -816,21 +920,31 @@ addEventListener('keydown',e=>{
816
920
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
817
921
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
818
922
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
923
+ 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)
819
924
  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;}
820
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;}
821
- if(!inForm&&selIds.size===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
822
- if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;}
823
- if(kk==='s'){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}}
926
+ if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
927
+ if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
928
+ if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
929
+ if(kk==='s'&&selIds.size===1){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // Split — single only
824
930
  if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
825
931
  if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
826
932
  else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
827
933
  else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
828
- else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}});
934
+ else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
935
+ 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)
829
936
  // zoom bar
830
937
  document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
831
938
  document.getElementById('zOut').onclick=()=>zoomCentered(zoom/1.25);
832
939
  document.getElementById('zFit').onclick=fitToWindow;
833
940
  document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
941
+ // --- "More" overflow menu: click-toggles a themed popup; closes on item-click, click-outside, or Esc. ---
942
+ const moreBtn=document.getElementById('moreBtn'),moreMenu=document.getElementById('moreMenu');
943
+ function moreOpen(){return moreMenu.classList.contains('open');}
944
+ function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)closeMore();}
945
+ function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
946
+ moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
947
+ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
834
948
  // --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
835
949
  // fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
836
950
  const view3dApi={
@@ -847,7 +961,7 @@ const view3dApi={
847
961
  onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
848
962
  if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dIn;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dIn;m.col.tosDef=false;} // rigid raise (bos null→0, both shift) so the column isn't stretched
849
963
  else m.ends.forEach(e=>{e.tos=(e.tos!=null?e.tos:defaultTOS)+dIn;e.tosDef=false;});});},
850
- onTrimExtend:(id,targetId)=>{const m=byId(id),tm=byId(targetId);if(m&&tm){snapEnd(m,tm.wp[0],tm.wp[1]);geoMode=null;setGeo();sync3D();}}, // 3D Trim/Extend → nearest end to the target member's line
964
+ onTrimExtend:(targetId)=>{const tm=byId(targetId);if(tm){geoMode=null;setGeo();snapEndMulti(selArr(),tm.wp[0],tm.wp[1]);}}, // 3D Trim/Extend → every selected member's nearest end to the target line
851
965
  onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
852
966
  };
853
967
  // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
@@ -1012,6 +1126,13 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
1012
1126
  function zoomToMember(m){const a=m.wp[0],b=m.wp[1],w=Math.abs(a[0]-b[0])||40,h=Math.abs(a[1]-b[1])||40;
1013
1127
  applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
1014
1128
  const cx=(a[0]+b[0])/2,cy=(a[1]+b[1])/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
1129
+ // Frame the whole current selection (Tekla "Zoom selected"); falls back to fit-all when nothing's picked — mirrors the 3D view's frameSelection().
1130
+ function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();return;}
1131
+ let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
1132
+ for(const m of arr)for(const p of m.wp){if(p[0]<x0)x0=p[0];if(p[0]>x1)x1=p[0];if(p[1]<y0)y0=p[1];if(p[1]>y1)y1=p[1];}
1133
+ const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
1134
+ applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
1135
+ const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
1015
1136
  function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
1016
1137
  g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
1017
1138
  list.map((m,i)=>{ensureMeta(m);const L=(len(m.wp[0],m.wp[1])/FT).toFixed(1);
@@ -1244,7 +1365,7 @@ try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on op
1244
1365
  if(src.read_at){try{const d=new Date(src.read_at);if(!isNaN(d.getTime()))parts.push(d.toLocaleDateString());}catch{/* skip unparseable date */}}
1245
1366
  if(!parts.length)return;
1246
1367
  const el=document.getElementById('srcStat');if(!el)return;
1247
- el.textContent=parts.join(' · ');el.style.display='';
1368
+ el.textContent=parts.join(' · ');el.title=parts.join(' · ');el.style.display=''; // title = full provenance (the chip itself truncates with ellipsis)
1248
1369
  })();
1249
1370
  // --- Ask AI: send instruction + optional screenshots → POST /api/contract-request ---
1250
1371
  // The server records a tweak-contract request; the terminal AI picks it up async and PUTs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.36.0",
3
+ "version": "0.37.1",
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": {