@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.
- package/dist/floless-server.cjs +14 -4
- package/dist/schemas/steel.takeoff.v1.schema.json +1 -0
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +8 -0
- package/dist/skills/floless-app-workflows/references/dev-server-and-run-trace.md +4 -2
- package/dist/web/app.css +24 -23
- package/dist/web/app.js +7 -3
- package/dist/web/aware.js +47 -5
- package/dist/web/index.html +22 -9
- package/dist/web/panels.js +25 -11
- package/dist/web/steel-3d-view.js +496 -38
- package/dist/web/steel-editor.html +90 -11
- package/package.json +1 -1
|
@@ -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=
|
|
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',
|
|
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" · 1'-0 1/4"" 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" · 1'-0 1/4"" 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"
|
|
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
|
|
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
|
|
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();
|