@floless/app 0.35.0 → 0.36.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.
@@ -144,6 +144,21 @@
144
144
  #viewToggle .seg.on{background:var(--panel);color:var(--text);box-shadow:inset 0 -2px 0 var(--brand)}
145
145
  #stagewrap{position:relative}
146
146
  #stage3d{position:absolute;inset:0;width:100%;height:100%;display:none;outline:none}
147
+ /* yellow = START (end 1), magenta = END (end 2) — also the drag handles. Same colours in 3D. */
148
+ circle.handle.ep-start{fill:#facc15;stroke:#0b1220;stroke-width:2}
149
+ circle.handle.ep-end{fill:#f472b6;stroke:#0b1220;stroke-width:2}
150
+ .epdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px;vertical-align:middle}
151
+ /* 3D-only floating chrome (parity with AWARE viewer-3d): toolbar TL, legend BL, ViewCube BR */
152
+ #m3dBar{position:absolute;top:12px;left:12px;display:none;gap:6px;align-items:center;z-index:6}
153
+ #m3dBar .seg-group,#m3dBar>button{box-shadow:0 4px 14px rgba(0,0,0,.45)}
154
+ #m3dBar>button{font-size:12px;padding:5px 9px}
155
+ #m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;gap:1px;max-height:40%;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
156
+ #m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px}
157
+ #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap}
158
+ #m3dLegend .lrow:hover{background:#33415580}
159
+ #m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
160
+ #m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
161
+ #m3dCube{position:absolute;right:12px;bottom:12px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
147
162
  </style>
148
163
  <script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/"}}</script>
149
164
  <script type="module" src="./steel-3d-view.js"></script>
@@ -174,6 +189,14 @@
174
189
  <div id=stagewrap>
175
190
  <div id=stage><svg id=svg></svg></div>
176
191
  <canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
192
+ <div id=m3dBar role=group aria-label="3D view controls">
193
+ <div class=seg-group id=m3dProj title="Camera projection"><button data-proj=persp class=on>Persp</button><button data-proj=ortho>Ortho</button></div>
194
+ <div class=seg-group id=m3dMode title="Display mode"><button data-mode=solid class=on>Solid</button><button data-mode=wire>Wire</button><button data-mode=xray>X-ray</button></div>
195
+ <button id=m3dFit title="Fit all to view">Fit</button>
196
+ <button id=m3dRef title="Show the reference line (work-line between the end points) for every member">Ref line</button>
197
+ </div>
198
+ <div id=m3dLegend></div>
199
+ <div id=m3dCube title="Click a face for that view · drag the scene with the right button to orbit"></div>
177
200
  <div id=zoombar>
178
201
  <button id=zOut title="Zoom out">−</button>
179
202
  <input id=zRange type=range min=10 max=400 step=1 value=100>
@@ -375,6 +398,17 @@ const fmtDecIn=v=>(v==null||isNaN(v))?'—':((Math.round(v*100)/100)+'"');
375
398
  function ensureMeta(m){if(!m.role)m.role='beam';
376
399
  if(!m.ends){const c=/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':'';m.ends=[{tos:null,note:c,tosDef:true,detail:''},{tos:null,note:c,tosDef:true,detail:''}];}
377
400
  if(!m.col)m.col={bos:null,tos:null,note:(/(^|[^A-Z])MF/i.test(m.profile||'')?'moment':''),tosDef:true,detail:''};return m;}
401
+ // Effective profile position on the reference line (the work-line between the end dots), à la Tekla.
402
+ // Explicit m.position wins; else a flat beam → top (top-of-steel), a steep beam (brace) / column → middle.
403
+ // MIRROR of server/contract-to-scene.ts depthPosition (same 0.5774 ≈ 30° threshold, mm units).
404
+ function posDefault(m){
405
+ if(m.position==='top'||m.position==='middle'||m.position==='bottom')return m.position;
406
+ if(m.role==='column')return 'middle';
407
+ const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1);
408
+ const z0=(m.ends&&m.ends[0]&&m.ends[0].tos!=null)?m.ends[0].tos:defaultTOS;
409
+ const z1=(m.ends&&m.ends[1]&&m.ends[1].tos!=null)?m.ends[1].tos:defaultTOS;
410
+ 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
+ return dz>horiz*0.5774?'middle':'top';}
378
412
  // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
379
413
  // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
380
414
  let defaultTOS=198, addProfile='';
@@ -385,10 +419,10 @@ const autofillTOS=syncDefaults;
385
419
  // profs is per-plan — computed in setPlan()
386
420
  function snapshot(){return JSON.stringify(P.members);}
387
421
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
388
- function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();}
422
+ function apply(json){P.members=JSON.parse(json);selIds=new Set([...selIds].filter(id=>byId(id)));scheduleSave();render();sync3D();}
389
423
  function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
390
424
  function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
391
- function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();}
425
+ function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
392
426
  // --- auto-save: edits persist to this browser (localStorage). Raster stays embedded; only members + TOS are stored. ---
393
427
  const LSKEY = 'steeltakeoff:edits:v1:' + APP_ID;
394
428
  function dataSig(){return C.plans.map(p=>p.sheet+':'+((p.segments||[]).length)).join('|');} // ignore stored edits if the dataset changed
@@ -528,7 +562,7 @@ function render(){
528
562
  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]}"/>`;
529
563
  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})"`:'';
530
564
  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}/>`;}
531
- {const hsel=selArr();if(hsel.length>=1&&hsel.length<=2){const HR=9/zoom;for(const sm of hsel)for(let i=0;i<2;i++) s+=`<circle class=handle data-mid="${sm.id}" data-h="${i}" cx="${sm.wp[i][0]}" cy="${sm.wp[i][1]}" r="${HR}"/>`;}}
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
532
566
  if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
533
567
  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>`;}
534
568
  if(picking&&pickKind==='detail'&&P.details) for(const d of P.details){const w=Math.max(34,d.text.length*8);
@@ -551,7 +585,7 @@ function updDup(){const n=redundantDups().length;
551
585
  function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
552
586
  svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
553
587
  svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
554
- svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',9/zoom));}
588
+ svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',5.5/zoom));}
555
589
  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]);});}
556
590
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
557
591
  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]);}}
@@ -595,7 +629,7 @@ function panel(){
595
629
  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();}});};
596
630
  document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
597
631
  return;}
598
- const m=ensureMeta(arr[0]), wpf=WT[m.profile], L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column');
632
+ 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);
599
633
  const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
600
634
  const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
601
635
  const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
@@ -603,11 +637,12 @@ function panel(){
603
637
  return `<div class=elabrow><span class=elab>${label}</span><label class=defck><input type=checkbox id=${id}_ck ${def?'checked':''}>default</label></div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"${def?' disabled':''}><span class=edec>${esc(fmtDecIn(v))}</span>`;};
604
638
  const nin=(id,v)=>`<input id=${id} class="f combo" data-src=conntypes placeholder="connection / note" value="${esc(v||'')}" autocomplete=off>`;
605
639
  const dFld=(id,o)=>{const hasPrev=o.detail&&previewFor(o.detail);return `<div class=elab>Connection detail</div><div style="display:flex;gap:6px"><input id=${id} class=combo data-src=details placeholder="e.g. 5-S504" value="${esc(o.detail||'')}" style="flex:1" autocomplete=off>${hasPrev?`<button id=${id}_open class=ghost title="Open detail ${esc(o.detail)}">⤢</button>`:''}<button id=${id}_pk class="ghost${(picking&&pickKind==='detail'&&pickEnd===o)?' on':''}" title="Pick a detail callout from the drawing">⌖</button></div>`;};
640
+ const dotS='<span class=epdot style="background:#facc15"></span>',dotE='<span class=epdot style="background:#f472b6"></span>'; // start=yellow, end=magenta — matches the canvas end markers
606
641
  const elev = col
607
- ? `<div class="sect f">Elevation</div>${ftFld('bos','Bottom (BOS)',m.col.bos)}${tosFld('tosC','Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
608
- : `<div class="sect f">Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
642
+ ? `<div class="sect f">Elevation</div>${ftFld('bos',dotS+'Bottom (BOS)',m.col.bos)}${tosFld('tosC',dotE+'Top (TOS)',m.col)}${nin('ntC',m.col.note)}${dFld('dtC',m.col)}`
643
+ : `<div class="sect f">${dotS}Start</div>${tosFld('tosA','TOS',m.ends[0])}${nin('ntA',m.ends[0].note)}${dFld('dtA',m.ends[0])}
609
644
  <div class=divrow><hr><button class=ghost id=matchEnds>Match → both ends</button><hr></div>
610
- <div class=sect>End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
645
+ <div class=sect>${dotE}End</div>${tosFld('tosB','TOS',m.ends[1])}${nin('ntB',m.ends[1].note)}${dFld('dtB',m.ends[1])}`;
611
646
  p.innerHTML=`<h3>Member ${esc(m.id)}</h3>
612
647
  <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" title="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
613
648
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
@@ -615,6 +650,9 @@ function panel(){
615
650
  <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} title="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
616
651
  ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
617
652
  ${elev}
653
+ ${col?'':`<div class=divrow><hr><span class=sect style="margin:0">Profile position</span><hr></div>
654
+ <div class=hint style="margin:0 0 6px">Where the section sits on the reference line (between the end dots) — like Tekla. Top = top-of-steel (dots on the top flange).</div>
655
+ <div class=seg2 style="margin-top:0"><button id=posTop class="${pos==='top'?'on':''}">Top</button><button id=posMid class="${pos==='middle'?'on':''}">Middle</button><button id=posBot class="${pos==='bottom'?'on':''}">Bottom</button></div>`}
618
656
  <div class=divrow><hr><span class=sect style="margin:0">Modify geometry</span><hr></div>
619
657
  <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>
620
658
  <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>
@@ -627,6 +665,9 @@ function panel(){
627
665
  const _gel=document.getElementById('geoEL'),_gsp=document.getElementById('geoSplit');
628
666
  if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};
629
667
  if(_gsp)_gsp.onclick=()=>{geoMode=(geoMode==='split'?null:'split');setGeo();render();};
668
+ {const setPos=v=>edit(()=>{m.position=v;}); // profile placement on the reference line (Tekla-style); persists + re-extrudes the 3D
669
+ const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
670
+ if(_pt)_pt.onclick=()=>setPos('top');if(_pm)_pm.onclick=()=>setPos('middle');if(_pb)_pb.onclick=()=>setPos('bottom');}
630
671
  const wireTos=(id,o)=>{const ck=document.getElementById(id+'_ck');if(ck)ck.onchange=e=>edit(()=>{o.tosDef=e.target.checked;if(o.tosDef)o.tos=defaultTOS;});
631
672
  const inp=document.getElementById(id);if(inp)inp.onchange=e=>edit(()=>{o.tos=parseLen(e.target.value);o.tosDef=false;});};
632
673
  const wireDet=(id,o)=>{const i=document.getElementById(id);if(i)i.onchange=e=>edit(()=>{o.detail=e.target.value.trim();});
@@ -774,7 +815,7 @@ addEventListener('keydown',e=>{
774
815
  if(e.key==='Escape'&&framesOpen()){closeFrames();return;}
775
816
  if(e.key==='Escape'&&rfiOpen()){closeRFI();return;}
776
817
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
777
- if(e.key==='Home'){e.preventDefault();fitToWindow();return;}
818
+ if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
778
819
  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;}
779
820
  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;}
780
821
  if(!inForm&&selIds.size===1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
@@ -795,15 +836,46 @@ document.getElementById('zRange').oninput=e=>zoomCentered(+e.target.value/100);
795
836
  const view3dApi={
796
837
  // POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
797
838
  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}
798
- 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 (Phase B4)
839
+ 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
840
+ onSelectMany:(ids)=>{selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
799
841
  getMembers:()=>P.members, // raw members (wp) for snap geometry
800
842
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
801
843
  defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
844
+ geoMode:()=>geoMode, // 'el' | 'split' | null — armed Trim/Extend/Split state
802
845
  onMoveMember:(id,newWp)=>{edit(()=>{const m=byId(id);if(m)m.wp=newWp;});}, // 3D drag → write wp (undoable, autosaves, syncs 2D/chip/BOM)
846
+ onMoveEndpoint:(id,end,wp)=>{edit(()=>{const m=byId(id);if(m&&Array.isArray(m.wp)&&m.wp.length>=2)m.wp[end]=wp;});}, // 3D end-node drag → write just that endpoint
803
847
  onElevateMember:(id,dIn)=>{edit(()=>{const m=byId(id);if(!m)return;ensureMeta(m); // 3D Alt-drag → raise/lower T.O.S by dIn inches
804
848
  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
805
849
  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
851
+ onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
806
852
  };
853
+ // Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
854
+ // changes go through render()'s setSelection — only geometry mutations need a rebuild.
855
+ function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{window.Steel3DView.setSelection(selIds);build3DLegend();}).catch(()=>{});}} // rebuild also refreshes the legend (an edit may add/remove a profile)
856
+ // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
857
+ // double-click isolate — mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
858
+ let leg3dClickT=null;
859
+ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
860
+ const groups=window.Steel3DView.getGroups();host.replaceChildren();
861
+ if(!groups.length){host.style.display='none';return;}
862
+ const hint=document.createElement('div');hint.className='lhint';hint.textContent='click: hide/show · dbl-click: isolate';host.appendChild(hint);
863
+ for(const g of groups){const row=document.createElement('div');row.className='lrow';row.dataset.key=g.key;
864
+ const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
865
+ row.append(sw,document.createTextNode(g.label));
866
+ row.addEventListener('click',()=>{clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
867
+ row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);window.Steel3DView.soloToggle(g.key);refresh3DLegend();});
868
+ host.appendChild(row);}
869
+ host.style.display='flex';refresh3DLegend();}
870
+ function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden);
871
+ document.querySelectorAll('#m3dLegend .lrow').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(st.solo!==null&&st.solo!==k));});}
872
+ let bar3dWired=false;
873
+ function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
874
+ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
875
+ document.querySelectorAll('#m3dProj button').forEach(b=>b.onclick=()=>{window.Steel3DView.setProjection(b.dataset.proj);seg3dActive('#m3dProj','data-proj',b.dataset.proj);});
876
+ document.querySelectorAll('#m3dMode button').forEach(b=>b.onclick=()=>{window.Steel3DView.setDisplayMode(b.dataset.mode);seg3dActive('#m3dMode','data-mode',b.dataset.mode);});
877
+ document.getElementById('m3dFit').onclick=()=>window.Steel3DView.frameAll();
878
+ document.getElementById('m3dRef').onclick=()=>{const on=!window.Steel3DView.refLine();window.Steel3DView.setRefLine(on);document.getElementById('m3dRef').classList.toggle('on',on);};}
807
879
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
808
880
  view3d=on;
809
881
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
@@ -812,6 +884,10 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
812
884
  document.getElementById('stage').style.display=on?'none':'';
813
885
  document.getElementById('stage3d').style.display=on?'block':'none';
814
886
  document.getElementById('zoombar').style.display=on?'none':''; // zoom slider is 2D-only
887
+ document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
888
+ document.getElementById('m3dBar').style.display=on?'flex':'none';
889
+ document.getElementById('m3dCube').style.display=on?'block':'none';
890
+ if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
815
891
  }
816
892
  async function setView(on){
817
893
  if(on){
@@ -820,8 +896,10 @@ async function setView(on){
820
896
  if(!view3dReady){window.Steel3DView.init(document.getElementById('stage3d'),view3dApi);view3dReady=true;}
821
897
  applyViewState(true);
822
898
  window.Steel3DView.show();
823
- await window.Steel3DView.rebuild();
899
+ await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
824
900
  window.Steel3DView.setSelection(selIds);
901
+ wire3DBar();build3DLegend();
902
+ seg3dActive('#m3dProj','data-proj',window.Steel3DView.projection());seg3dActive('#m3dMode','data-mode',window.Steel3DView.mode()); // reflect persisted state
825
903
  }catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
826
904
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
827
905
  toast('Could not open 3D view: '+((e&&e.message)||e));
@@ -1144,6 +1222,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
1144
1222
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
1145
1223
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
1146
1224
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
1225
+ 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)
1147
1226
  if(!P.autofilled){autofillTOS();P.autofilled=true;}
1148
1227
  const ds=new Set([...(P.details||[]).map(d=>d.text),...Object.keys(C.custom_details)]);document.getElementById('detailsBtn').textContent='Details ('+ds.size+')';
1149
1228
  setMode();render();fitToWindow();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.35.0",
3
+ "version": "0.36.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": {