@floless/app 0.74.0 → 0.76.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 +746 -270
- package/dist/schemas/steel.takeoff.v1.schema.json +26 -7
- package/dist/web/app.css +21 -2
- package/dist/web/app.js +13 -1
- package/dist/web/aware.js +16 -3
- package/dist/web/index.html +9 -0
- package/dist/web/steel-3d-view.js +17 -1
- package/dist/web/steel-editor.html +247 -14
- package/dist/web/workspaces.js +196 -9
- package/package.json +1 -1
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
g.cohot:hover circle.cobub{opacity:1;stroke-width:2;filter:drop-shadow(0 0 4px currentColor)}
|
|
95
95
|
text.cotx{fill:#e2e8f0;font:bold 11px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none;opacity:.6}
|
|
96
96
|
g.cohot:hover text.cotx{opacity:1}
|
|
97
|
-
#detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
|
|
97
|
+
#detailsModal,#framesModal,#rfiModal,#confModal,#askAiModal,#connModal,#connImportModal{position:fixed;inset:0;z-index:20;display:none;align-items:center;justify-content:center}
|
|
98
98
|
#askAiDrop:hover{border-color:var(--brand);color:var(--text)} #askAiDrop.has{border-style:solid;border-color:var(--brand)}
|
|
99
99
|
.aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
|
|
100
100
|
.aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
|
|
@@ -363,6 +363,30 @@
|
|
|
363
363
|
#propPop .pprow.node{font-weight:500}
|
|
364
364
|
#propPop .pprow .cnt{color:var(--mut);font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
|
|
365
365
|
#propPop .pprow.selrow{border-left:2px solid var(--brand);background:rgba(59,130,246,.16)}
|
|
366
|
+
/* Import-a-connection-from-IFC modal (slice B): dropzone → parse progress → candidate pick-list →
|
|
367
|
+
extract progress → (closes, arms the crosshair place). Built from the same tokens as .mpanel /
|
|
368
|
+
#propPop / #dnDrop — no new vocabulary. Themed inner scroll comes from the global * scrollbar rule. */
|
|
369
|
+
#connImportModal .mpanel{width:min(560px,92vw)}
|
|
370
|
+
#connImportModal.pick .mpanel{width:min(720px,92vw)}
|
|
371
|
+
#ciBody{padding:16px;display:flex;flex-direction:column;gap:10px;overflow:auto;max-height:76vh}
|
|
372
|
+
#ciDrop{border:1px dashed #475569;border-radius:8px;background:#0b1220;min-height:104px;display:flex;align-items:center;justify-content:center;text-align:center;color:var(--mut);cursor:pointer;padding:16px;font-size:12px}
|
|
373
|
+
#ciDrop:hover{border-color:var(--brand);color:var(--text)}
|
|
374
|
+
#ciDrop.drag{border-style:solid;border-color:var(--brand);color:var(--text);background:#0d1526}
|
|
375
|
+
.ci-prog{display:flex;align-items:center;gap:10px;padding:22px 4px;color:var(--text);font-size:13px}
|
|
376
|
+
.ci-spin{width:18px;height:18px;border-radius:50%;flex:none;border:2px solid var(--line);border-top-color:var(--brand);animation:ci-spin .8s linear infinite}
|
|
377
|
+
@keyframes ci-spin{to{transform:rotate(360deg)}}
|
|
378
|
+
#ciSearch{width:100%;height:28px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:0 10px;font:12px system-ui;box-sizing:border-box}
|
|
379
|
+
#ciSearch:focus{outline:none;border-color:var(--brand)}
|
|
380
|
+
#ciCount{color:var(--mut);font-size:11px}
|
|
381
|
+
#ciList{overflow:auto;max-height:min(52vh,360px);border:1px solid var(--line);border-radius:8px}
|
|
382
|
+
.ci-row{display:flex;align-items:center;gap:10px;min-height:34px;padding:6px 12px;cursor:pointer;border:0;border-bottom:1px solid #0f1a2e;background:transparent;text-align:left;width:100%;box-sizing:border-box}
|
|
383
|
+
.ci-row:last-child{border-bottom:0}
|
|
384
|
+
.ci-row:hover{background:#1e293b}
|
|
385
|
+
.ci-row.sel{background:rgba(59,130,246,.16);border-left:2px solid var(--brand);padding-left:10px}
|
|
386
|
+
.ci-row .pn{flex:1;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px}
|
|
387
|
+
.ci-row .pv{color:var(--mut);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
|
|
388
|
+
.ci-empty{color:var(--mut);font-size:12px;padding:16px;text-align:center}
|
|
389
|
+
#ciFoot{display:flex;justify-content:flex-end;gap:8px;margin-top:2px}
|
|
366
390
|
/* thin cluster divider in the 3D toolbar */
|
|
367
391
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
368
392
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -486,7 +510,7 @@
|
|
|
486
510
|
<button id=detailsBtn>Details</button>
|
|
487
511
|
<button id=connBtn data-tip="A lookup table of connection types (moment, shear, pinned, …). Each maps to a design detail and, per platform, a component ID; reference a row from each member end.">Connections</button>
|
|
488
512
|
<div class="m3dwrap ins-in-menu" id=insWrap>
|
|
489
|
-
<button id=m3dInsert data-tip="Insert a
|
|
513
|
+
<button id=m3dInsert data-tip="Insert a detail image or an imported steel connection into the 3D scene (3D view only)">Insert…</button>
|
|
490
514
|
<div id=m3dInsertMenu class=m3dmenu role=menu></div>
|
|
491
515
|
<input id=insFile type=file accept="image/*" style="display:none">
|
|
492
516
|
</div>
|
|
@@ -637,6 +661,30 @@
|
|
|
637
661
|
<div id=rfiModal><div class=mbackdrop id=rfiBackdrop></div>
|
|
638
662
|
<div class=mpanel><div class=mhead><b>Unresolved members — RFI</b><button id=rfiClose>✕</button></div>
|
|
639
663
|
<div id=rfiGrid style="padding:14px;overflow:auto"></div></div></div>
|
|
664
|
+
<div id=connImportModal><div class=mbackdrop id=ciBackdrop></div>
|
|
665
|
+
<div class=mpanel><div class=mhead><b id=ciTitle>Import a connection</b><button id=ciClose data-tip="Close">✕</button></div>
|
|
666
|
+
<div id=ciBody>
|
|
667
|
+
<!-- stage: dropzone -->
|
|
668
|
+
<div id=ciDropStage>
|
|
669
|
+
<div id=ciDrop data-tip="Click to browse, or drag an IFC file here"><span id=ciDropTxt>Drop an IFC file here, or click to browse</span></div>
|
|
670
|
+
<input id=ciFile type=file accept=".ifc,.ifczip" hidden>
|
|
671
|
+
<div class=hint style="margin-top:10px">Design a connection in IDEA StatiCa, Tekla or any tool, export IFC, and drop it here to place it in your model. The file stays on your machine.</div>
|
|
672
|
+
<div class=gerr id=ciDropErr style="display:none"></div>
|
|
673
|
+
</div>
|
|
674
|
+
<!-- stage: progress (parse / extract) -->
|
|
675
|
+
<div id=ciProgStage style="display:none">
|
|
676
|
+
<div class=ci-prog><span class=ci-spin aria-hidden=true></span><span id=ciProgTxt>Parsing IFC… reading connections</span></div>
|
|
677
|
+
<div style="display:flex;justify-content:flex-end;margin-top:8px"><button id=ciCancel class=ghost>Cancel</button></div>
|
|
678
|
+
</div>
|
|
679
|
+
<!-- stage: candidate pick-list -->
|
|
680
|
+
<div id=ciPickStage style="display:none">
|
|
681
|
+
<div style="margin-bottom:8px"><input id=ciSearch placeholder="Filter by name…" autocomplete=off></div>
|
|
682
|
+
<div id=ciCount style="margin-bottom:6px">—</div>
|
|
683
|
+
<div id=ciList></div>
|
|
684
|
+
<div id=ciFoot><button id=ciImport disabled data-tip="Place the selected connection in the model">Import</button></div>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
</div></div>
|
|
640
688
|
<div id=confModal><div class=mbackdrop id=confBackdrop></div>
|
|
641
689
|
<div class=mpanel><div class=mhead><b>Confidence report</b><label class=conf-tgt data-tip="Confidence target for this read (%). Overrides the workflow default; saved with the contract.">Target <input id=confTarget type=number min=0 max=100 step=1> %</label><button id=confClose>✕</button></div>
|
|
642
690
|
<div id=confCats class=conf-cats></div>
|
|
@@ -803,6 +851,7 @@ let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimen
|
|
|
803
851
|
let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
|
|
804
852
|
let sel3dDimIds=new Set(),dim3dAnchor=null; // selected 3D dimension ids (multi-select like parts; 3D view highlights them, Delete removes them)
|
|
805
853
|
let selIds=new Set();
|
|
854
|
+
let findHits=new Set(); // Find-in-model highlight (Workspaces Ctrl+F) — kept SEPARATE from selIds so a search never clobbers/drops the user's real selection
|
|
806
855
|
let undo=[], redo=[];
|
|
807
856
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
808
857
|
const selArr=()=>P.members.filter(m=>selIds.has(m.id));
|
|
@@ -1372,8 +1421,8 @@ function render(){
|
|
|
1372
1421
|
let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
|
|
1373
1422
|
s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
|
|
1374
1423
|
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]}"/>`;
|
|
1375
|
-
for(const m of P.members){const c=colorFor(m.profile);const
|
|
1376
|
-
s+=`<line class="member${m.rfi?' rfi':''}${
|
|
1424
|
+
for(const m of P.members){const c=colorFor(m.profile);const sel=selIds.has(m.id);const fh=findHits.has(m.id);const on=sel||fh;const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
|
|
1425
|
+
s+=`<line class="member${m.rfi?' rfi':''}${sel?' sel':''}${fh?' find-hit':''}" 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}/>`;}
|
|
1377
1426
|
{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
|
|
1378
1427
|
if((mode==='add'||(picking&&pickKind==='profile'))&&P.labels) for(const lb of P.labels){const w=Math.max(40,lb.text.length*11);
|
|
1379
1428
|
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>`;}
|
|
@@ -1520,10 +1569,12 @@ async function connModifyRequest(j){
|
|
|
1520
1569
|
if(!j) return;
|
|
1521
1570
|
try{await window.flushContract();}catch(_){}
|
|
1522
1571
|
try{persist();}catch(_){}
|
|
1523
|
-
const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':'connection';
|
|
1524
|
-
const
|
|
1572
|
+
const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':j.kind==='custom'?'imported':'connection';
|
|
1573
|
+
const onMember=j.main?' on member '+j.main:'';
|
|
1574
|
+
const label=j.kind==='custom'&&j.name?' ("'+j.name+'")':'';
|
|
1575
|
+
const instruction='Modify the '+kindName+' connection "'+j.id+'"'+label+onMember+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
|
|
1525
1576
|
try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
|
|
1526
|
-
body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main]}})});
|
|
1577
|
+
body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main].filter(Boolean)}})});
|
|
1527
1578
|
toast(res.ok?'Change queued for your terminal AI session':'Could not queue the request');
|
|
1528
1579
|
}catch(_){toast('Could not queue the request');}
|
|
1529
1580
|
}
|
|
@@ -1614,7 +1665,27 @@ function panel(){
|
|
|
1614
1665
|
// Modify (relay) / Edit-on-member. Precedes the single-part branch below (which handles the DRILLED case).
|
|
1615
1666
|
{const cs=connSelInfo();
|
|
1616
1667
|
if(cs&&cs.whole){
|
|
1617
|
-
const j=cs.joint
|
|
1668
|
+
const j=cs.joint;
|
|
1669
|
+
// An imported (custom) connection — opaque IFC geometry, move / replace only. NEUTRAL chip (a normal
|
|
1670
|
+
// state, not a fault); NO param block (replaced by a short note, never a disabled empty form).
|
|
1671
|
+
if(j.kind==='custom'){
|
|
1672
|
+
const nm=j.name?esc(j.name):'Imported connection';
|
|
1673
|
+
const onLine=j.main?`On <button class=pilllink id=cmpMember data-tip="Select ${esc(j.main)}">${esc(j.main)}</button> · `:'';
|
|
1674
|
+
p.innerHTML=`<span class=badge>Custom connection</span>
|
|
1675
|
+
<div class=row style="margin:0 0 6px"><span class=chip style="border-color:var(--line);color:var(--mut)">Imported geometry — move / replace only</span></div>
|
|
1676
|
+
<div class="row hint" style="margin:0 0 2px">${onLine}${cs.childIds.length} parts · ${nm}</div>
|
|
1677
|
+
<div class="row hint" style="margin:0 0 6px;font-size:11px">Read from IFC — opaque tessellated geometry (no per-dimension edit). <b>Esc</b> steps back.</div>
|
|
1678
|
+
<div class=divrow><hr></div>
|
|
1679
|
+
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1680
|
+
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to move / replace this connection">Modify connection…</button>
|
|
1681
|
+
<button class=danger id=cmpDel data-tip="Remove this whole imported connection">Delete connection</button>
|
|
1682
|
+
</div>`;
|
|
1683
|
+
{const b=document.getElementById('cmpMember');if(b)b.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};}
|
|
1684
|
+
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1685
|
+
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const isBP=j.kind==='base-plate',pp=j.params||{};
|
|
1618
1689
|
const plate=(partsById||{})[cs.conn+':plate']||null;
|
|
1619
1690
|
const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
|
|
1620
1691
|
const kv=(l,val)=>`<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;margin:3px 0"><span style="color:var(--mut)">${esc(l)}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
|
|
@@ -2964,8 +3035,40 @@ const view3dApi={
|
|
|
2964
3035
|
onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
|
|
2965
3036
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
2966
3037
|
onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
|
|
2967
|
-
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert
|
|
2968
|
-
onInsertPlace:(pick,pending)=>{
|
|
3038
|
+
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
|
|
3039
|
+
onInsertPlace:(pick,pending)=>{
|
|
3040
|
+
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3041
|
+
const conn=pending.connection;const rc=conn.recipe;
|
|
3042
|
+
// Slice C: a RECOGNIZED base plate dropped onto a COLUMN → bake an EDITABLE base-plate recipe joint;
|
|
3043
|
+
// expandBasePlate re-derives it on that column from the fitted params (frame-independent scalars), so
|
|
3044
|
+
// it becomes a first-class parametric connection, not opaque mesh. Needs a column at the pick.
|
|
3045
|
+
const col=(rc&&rc.kind==='base-plate'&&pick.anchorId)?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='column'):null;
|
|
3046
|
+
if(col){
|
|
3047
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3048
|
+
const joint={id,kind:'base-plate',main:col.id,at:'base',params:Object.assign({},rc.params),source:'user'};
|
|
3049
|
+
pendingConnSel=id; // its parts only exist after the 3D rebuild → select the whole connection there
|
|
3050
|
+
// Applying a base plate to a column REPLACES any base plate already on it (a column has one base
|
|
3051
|
+
// plate) — else the two overlap and "edit on member" would target the older joint, not this import.
|
|
3052
|
+
const had=(C.joints||[]).some(x=>x&&x.kind==='base-plate'&&x.main===col.id);
|
|
3053
|
+
edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='base-plate'&&x.main===col.id));C.joints.push(joint);selIds=new Set();});
|
|
3054
|
+
toast((had?'Base plate on '+col.id+' replaced with imported “'+(conn.name||'connection')+'”':'Base plate “'+(conn.name||'imported')+'” applied to '+col.id)+' — edit its parameters on the member');
|
|
3055
|
+
return;
|
|
3056
|
+
}
|
|
3057
|
+
// Slice B: opaque custom mesh — bake at the picked point (joint.place); expandCustom re-expands it into
|
|
3058
|
+
// the scene as one selectable unit. Unrecognized imports, and a recognized base plate NOT dropped on a
|
|
3059
|
+
// column, land here (still faithful geometry) with a hint toward the editable path.
|
|
3060
|
+
if(Array.isArray(conn.geometry)&&conn.geometry.length){
|
|
3061
|
+
const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
|
|
3062
|
+
const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
|
|
3063
|
+
if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
|
|
3064
|
+
edit(()=>{if(!Array.isArray(C.joints))C.joints=[];C.joints.push(joint);selIds=new Set(conn.geometry.map((g,i)=>id+':'+(g.id||'m'+i)));});
|
|
3065
|
+
toast(rc?('Imported “'+(conn.name||'connection')+'” as geometry — drop it on a column to apply it as an editable base plate')
|
|
3066
|
+
:('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace'));
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
toast('That connection has no geometry to place');return;
|
|
3070
|
+
}
|
|
3071
|
+
if(!pending||!pending.name){toast('Pick a detail to insert first');return;} // Slice 4: place the queued detail at the pick, select it, record the create intent
|
|
2969
3072
|
const id='det'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);const sheet=(P&&P.sheet)||'';
|
|
2970
3073
|
const place={id,detailName:pending.name,sheet,anchorId:pick.anchorId||null,pos:pick.point,u:pick.u,n:pick.n,rotZ:0,size:1000,opacity:1};
|
|
2971
3074
|
edit(()=>{if(!Array.isArray(C.detail_placements))C.detail_placements=[];C.detail_placements.push(place);selIds=new Set(['det:'+id]);});
|
|
@@ -2976,7 +3079,11 @@ const view3dApi={
|
|
|
2976
3079
|
};
|
|
2977
3080
|
// Re-extrude the 3D model after a structural edit (keeps the camera where it is). Selection-only
|
|
2978
3081
|
// changes go through render()'s setSelection — only geometry mutations need a rebuild.
|
|
2979
|
-
|
|
3082
|
+
let pendingConnSel=null; // a just-baked connection whose parts exist only AFTER the next rebuild → select the whole thing there (a recognized base-plate import, whose part ids aren't known ahead of expansion)
|
|
3083
|
+
function sync3D(){if(view3d&&view3dReady&&window.Steel3DView){window.Steel3DView.rebuild(false).then(()=>{
|
|
3084
|
+
if(pendingConnSel&&window.Steel3DView.selectWholeConn){const c=pendingConnSel;pendingConnSel=null;window.Steel3DView.selectWholeConn(c);} // whole-connection select (envelope + "Parametric — editable" inspector), same as a 3D click
|
|
3085
|
+
else window.Steel3DView.setSelection(selIds);
|
|
3086
|
+
build3DLegend();panel();}).catch(()=>{pendingConnSel=null;});}} // rebuild also refreshes the legend (an edit may add/remove a profile) + re-renders the inspector so a selection of just-created parts (e.g. a placed custom connection) resolves against the freshly-fetched partsById, not the pre-rebuild stale copy
|
|
2980
3087
|
// Insert-a-detail helpers (Slice 4). armInsert queues a detail image + arms the 3D placement pick;
|
|
2981
3088
|
// detailRequest records a create/modify request on the SAME tweak-contract channel, adding intent+target
|
|
2982
3089
|
// so the terminal AI knows whether to build a new detail or update a placed one, and where.
|
|
@@ -2984,6 +3091,17 @@ function armInsert(name){if(!name)return;const raw=(C.custom_details||{})[name];
|
|
|
2984
3091
|
if(!view3d){toast('Switch to the 3D view to place a detail');return;}
|
|
2985
3092
|
window.Steel3DView.setInsertMode(true,{name});
|
|
2986
3093
|
toast('Click a beam or the model to place “'+name+'” — Esc to cancel');}
|
|
3094
|
+
// Slice B/C: arm the crosshair to place an IMPORTED connection (its LOCAL mesh geometry + optional
|
|
3095
|
+
// recognized recipe ride on the pending object; onInsertPlace bakes a base-plate recipe on a column, or
|
|
3096
|
+
// the custom mesh where the user clicks). 3D view only.
|
|
3097
|
+
function armConnectionInsert(connection){if(!connection||!Array.isArray(connection.geometry)||!connection.geometry.length){toast('That connection has no geometry to place');return;}
|
|
3098
|
+
if(!view3d){toast('Switch to the 3D view to place a connection');return;}
|
|
3099
|
+
window.Steel3DView.setInsertMode(true,{kind:'connection',connection});
|
|
3100
|
+
const nm=connection.name||'connection';
|
|
3101
|
+
// Recognized base plate → tell the user to target a column (the editable path); else the generic place hint.
|
|
3102
|
+
const recognized=connection.recipe&&connection.recipe.kind==='base-plate';
|
|
3103
|
+
toast(recognized?('Click a column to place “'+nm+'” as an editable base plate — Esc to cancel')
|
|
3104
|
+
:('Click in the model to place “'+nm+'” — Esc to cancel'));}
|
|
2987
3105
|
async function detailRequest(intent,place,note){
|
|
2988
3106
|
// flushContract PUTs C to the server so the terminal AI reads the latest contract — but it clears the
|
|
2989
3107
|
// debounced autosave (saveT) WITHOUT writing localStorage, which would drop the just-placed detail from
|
|
@@ -3307,6 +3425,7 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
3307
3425
|
else{const note=document.createElement('div');note.style.cssText='padding:4px 10px;color:var(--mut);font-size:12px';note.textContent='No saved details yet — add one below.';frag.appendChild(note);}
|
|
3308
3426
|
frag.appendChild(document.createElement('hr'));
|
|
3309
3427
|
const add=document.createElement('button');add.textContent='+ Add an image…';add.onclick=()=>{insMenuClose();closeMore();document.getElementById('insFile').click();};frag.appendChild(add);
|
|
3428
|
+
const conn=document.createElement('button');conn.textContent='Connection…';conn.dataset.tip='Import a steel connection from an IFC file';conn.onclick=()=>{insMenuClose();closeMore();window.openConnImport();};frag.appendChild(conn);
|
|
3310
3429
|
insMenu.replaceChildren(frag);}
|
|
3311
3430
|
insBtn.onclick=e=>{e.stopPropagation();
|
|
3312
3431
|
if(d3.insertMode()){d3.setInsertMode(false);return;} // armed → cancel the pick
|
|
@@ -3316,6 +3435,93 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
3316
3435
|
readImageCompressed(f,b64=>{if(!b64){toast('Could not read that image — try another.');return;}
|
|
3317
3436
|
let base=(f.name||'Detail').replace(/\.[^.]+$/,'').trim()||'Detail',name=base,i=2;while(C.custom_details[name]!=null)name=base+' '+(i++);
|
|
3318
3437
|
edit(()=>{C.custom_details[name]=b64;});armInsert(name);});};
|
|
3438
|
+
// ── Import a connection from IFC (slice B): dropzone → parse → pick → extract → arm the crosshair
|
|
3439
|
+
// place. The reader runs in AWARE (web-ifc) via /api/import-connection; the UI renders + relays. Staged
|
|
3440
|
+
// states swap in one modal (never a disabled Place button — the whole body swaps). ──
|
|
3441
|
+
{ const ci$=id=>document.getElementById(id);
|
|
3442
|
+
let ciConns=[],ciSha=null,ciSel=null,ciAbort=null,ciSoftT=null;
|
|
3443
|
+
const ciClearSoft=()=>{if(ciSoftT){clearTimeout(ciSoftT);ciSoftT=null;}};
|
|
3444
|
+
const ciStage=s=>{ // 'drop' | 'prog' | 'pick'
|
|
3445
|
+
ci$('ciDropStage').style.display=s==='drop'?'':'none';
|
|
3446
|
+
ci$('ciProgStage').style.display=s==='prog'?'':'none';
|
|
3447
|
+
ci$('ciPickStage').style.display=s==='pick'?'':'none';
|
|
3448
|
+
ci$('ciClose').style.display=s==='prog'?'none':''; // no ✕ mid-run — Cancel owns the exit
|
|
3449
|
+
ci$('connImportModal').classList.toggle('pick',s==='pick'); // widen the panel for the list
|
|
3450
|
+
ci$('ciTitle').textContent=s==='prog'?'Reading the IFC file':s==='pick'?'Choose a connection':'Import a connection'; };
|
|
3451
|
+
const ciProg=(label,soft)=>{ciClearSoft();ci$('ciProgTxt').textContent=label;
|
|
3452
|
+
if(soft)ciSoftT=setTimeout(()=>{ci$('ciProgTxt').textContent=soft;},6000);}; // honest "still working" after 6s — no fake %
|
|
3453
|
+
const ciDropErr=msg=>{const e=ci$('ciDropErr');if(msg){e.textContent=msg;e.style.display='';}else{e.style.display='none';e.textContent='';}};
|
|
3454
|
+
const ciTotal=c=>(c.plates||0)+(c.bolts||0)+(c.welds||0)+(c.members||0);
|
|
3455
|
+
const ciSummary=c=>{const b=[],one=(n,s)=>{if(n)b.push(n+' '+s+(n===1?'':'s'));};one(c.plates,'plate');one(c.bolts,'bolt');one(c.welds,'weld');return b.join(' · ')||'no hardware read';};
|
|
3456
|
+
function openConnImport(){ if(!view3d){toast('Switch to the 3D view to import a connection');return;}
|
|
3457
|
+
ciConns=[];ciSha=null;ciSel=null;ciDropErr('');ci$('ciDrop').classList.remove('drag');
|
|
3458
|
+
ciStage('drop');ci$('connImportModal').style.display='flex'; }
|
|
3459
|
+
window.openConnImport=openConnImport; // the Insert ▾ menu item calls this
|
|
3460
|
+
function ciClose(){ciClearSoft();if(ciAbort){try{ciAbort.abort();}catch(_){}}ciAbort=null;ci$('connImportModal').style.display='none';}
|
|
3461
|
+
function ciReadFile(f){ if(!f)return;ciDropErr('');
|
|
3462
|
+
if(!/\.(ifc|ifczip)$/i.test(f.name||'')){ciDropErr('Not a valid IFC file — expected .ifc or .ifczip.');return;}
|
|
3463
|
+
const r=new FileReader();r.onload=()=>ciDoList(String(r.result||''));r.onerror=()=>ciDropErr('Could not read that file — try again.');r.readAsDataURL(f); }
|
|
3464
|
+
ci$('ciDrop').onclick=()=>ci$('ciFile').click();
|
|
3465
|
+
ci$('ciFile').onchange=e=>{const f=e.target.files&&e.target.files[0];e.target.value='';ciReadFile(f);};
|
|
3466
|
+
ci$('ciDrop').addEventListener('dragover',e=>{e.preventDefault();ci$('ciDrop').classList.add('drag');});
|
|
3467
|
+
ci$('ciDrop').addEventListener('dragleave',()=>ci$('ciDrop').classList.remove('drag'));
|
|
3468
|
+
ci$('ciDrop').addEventListener('drop',e=>{e.preventDefault();ci$('ciDrop').classList.remove('drag');const f=e.dataTransfer&&e.dataTransfer.files&&e.dataTransfer.files[0];ciReadFile(f);});
|
|
3469
|
+
async function ciDoList(dataUrl){
|
|
3470
|
+
ciStage('prog');ciProg('Parsing IFC… reading connections','Still reading — larger models can take up to 20 seconds');
|
|
3471
|
+
ciAbort=new AbortController();
|
|
3472
|
+
try{
|
|
3473
|
+
const res=await fetch('/api/import-connection/list',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({appId:APP_ID,dataUrl}),signal:ciAbort.signal});
|
|
3474
|
+
const j=await res.json().catch(()=>({ok:false}));ciClearSoft();
|
|
3475
|
+
if(!j.ok){ciStage('drop');ciDropErr(j.error||'Could not read that IFC file.');return;}
|
|
3476
|
+
ciSha=j.sha;ciConns=(j.connections||[]).slice().sort((a,b)=>ciTotal(b)-ciTotal(a)); // richest connection first
|
|
3477
|
+
if(!ciConns.length){ciStage('drop');ciDropErr('No connections found in that IFC — it may not be a detailed/fabricated model.');return;}
|
|
3478
|
+
if(ciConns.length===1){toast('Found one connection — '+(ciConns[0].name||'connection')+'. Building it…');ciDoExtract(ciConns[0]);return;}
|
|
3479
|
+
ciRenderList();
|
|
3480
|
+
}catch(e){ciClearSoft();if(e&&e.name==='AbortError')return;ciStage('drop');ciDropErr('Could not read that IFC file.');}
|
|
3481
|
+
}
|
|
3482
|
+
function ciRenderList(){ciStage('pick');ciSel=null;ci$('ciImport').disabled=true;ci$('ciSearch').value='';ciFilter('');setTimeout(()=>ci$('ciSearch').focus(),0);}
|
|
3483
|
+
function ciFilter(q){ q=(q||'').trim().toLowerCase();
|
|
3484
|
+
const matches=q?ciConns.filter(c=>String(c.name||'').toLowerCase().includes(q)||String(c.id||'').toLowerCase().includes(q)):ciConns;
|
|
3485
|
+
ci$('ciCount').textContent=q?(matches.length+' of '+ciConns.length+' match “'+q+'”'):(ciConns.length+' connection'+(ciConns.length===1?'':'s')+' found');
|
|
3486
|
+
const list=ci$('ciList'),frag=document.createDocumentFragment();
|
|
3487
|
+
if(!matches.length){const e=document.createElement('div');e.className='ci-empty';e.textContent='No connections match “'+q+'”. Try a different name.';frag.appendChild(e);}
|
|
3488
|
+
else for(const c of matches.slice(0,600)){
|
|
3489
|
+
const row=document.createElement('button');row.className='ci-row';row.dataset.id=c.id;
|
|
3490
|
+
const nm=document.createElement('span');nm.className='pn';nm.textContent=c.name||c.id;
|
|
3491
|
+
const pv=document.createElement('span');pv.className='pv';pv.textContent=ciSummary(c);
|
|
3492
|
+
row.append(nm,pv);row.onclick=()=>ciSelect(c,row);row.ondblclick=()=>{ciSelect(c,row);ciDoExtract(c);};
|
|
3493
|
+
frag.appendChild(row);
|
|
3494
|
+
}
|
|
3495
|
+
list.replaceChildren(frag);
|
|
3496
|
+
if(ciSel&&!matches.some(c=>c.id===ciSel.id)){ciSel=null;ci$('ciImport').disabled=true;} // selection got filtered out
|
|
3497
|
+
}
|
|
3498
|
+
function ciSelect(c,row){ciSel=c;ci$('ciImport').disabled=false;
|
|
3499
|
+
for(const r of ci$('ciList').querySelectorAll('.ci-row.sel'))r.classList.remove('sel');
|
|
3500
|
+
if(row)row.classList.add('sel');}
|
|
3501
|
+
function ciSelectByRow(row){const c=ciConns.find(x=>x.id===row.dataset.id);if(c)ciSelect(c,row);}
|
|
3502
|
+
ci$('ciSearch').addEventListener('input',e=>ciFilter(e.target.value));
|
|
3503
|
+
ci$('ciSearch').addEventListener('keydown',e=>{e.stopPropagation();
|
|
3504
|
+
if(e.key==='Enter'){e.preventDefault();if(ciSel)ciDoExtract(ciSel);else{const f=ci$('ciList').querySelector('.ci-row');if(f)f.click();}}
|
|
3505
|
+
else if(e.key==='ArrowDown'){e.preventDefault();const r=ci$('ciList').querySelector('.ci-row');if(r){r.focus();ciSelectByRow(r);}}});
|
|
3506
|
+
ci$('ciList').addEventListener('keydown',e=>{const rows=[...ci$('ciList').querySelectorAll('.ci-row')];if(!rows.length)return;const i=rows.indexOf(document.activeElement);
|
|
3507
|
+
if(e.key==='ArrowDown'){e.preventDefault();const n=rows[Math.min(rows.length-1,i+1)];if(n){n.focus();ciSelectByRow(n);}}
|
|
3508
|
+
else if(e.key==='ArrowUp'){e.preventDefault();if(i<=0){ci$('ciSearch').focus();}else{const n=rows[i-1];n.focus();ciSelectByRow(n);}}
|
|
3509
|
+
else if(e.key==='Enter'){e.preventDefault();if(ciSel)ciDoExtract(ciSel);}});
|
|
3510
|
+
ci$('ciImport').onclick=()=>{if(ciSel)ciDoExtract(ciSel);};
|
|
3511
|
+
async function ciDoExtract(cand){ if(!cand)return;
|
|
3512
|
+
ciStage('prog');ciProg('Building '+(cand.name||'connection')+'…','Still building — tessellating geometry');
|
|
3513
|
+
ciAbort=new AbortController();
|
|
3514
|
+
try{
|
|
3515
|
+
const res=await fetch('/api/import-connection/extract',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({appId:APP_ID,sha:ciSha,id:cand.id}),signal:ciAbort.signal});
|
|
3516
|
+
const j=await res.json().catch(()=>({ok:false}));ciClearSoft();
|
|
3517
|
+
if(!j.ok||!j.connection){toast(j.error||'Could not read that connection.');ciStage(ciConns.length>1?'pick':'drop');return;}
|
|
3518
|
+
ciClose();armConnectionInsert(j.connection);
|
|
3519
|
+
}catch(e){ciClearSoft();if(e&&e.name==='AbortError')return;toast('Could not read that connection.');ciStage(ciConns.length>1?'pick':'drop');}
|
|
3520
|
+
}
|
|
3521
|
+
ci$('ciCancel').onclick=ciClose; ci$('ciClose').onclick=ciClose;
|
|
3522
|
+
ci$('ciBackdrop').onclick=()=>{if(ci$('ciProgStage').style.display==='none')ciClose();}; // no-op mid-run so a stray backdrop click can't orphan the AWARE run
|
|
3523
|
+
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&ci$('connImportModal').style.display==='flex'){e.stopPropagation();ciClose();}},true);
|
|
3524
|
+
}
|
|
3319
3525
|
document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
|
|
3320
3526
|
// Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
|
|
3321
3527
|
const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
|
|
@@ -3591,13 +3797,40 @@ function rfiReason(m){if(!m.profile)return 'No profile assigned';
|
|
|
3591
3797
|
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;
|
|
3592
3798
|
applyZoom(Math.max(.3,Math.min(2.5,Math.min(stage.clientWidth/(w*2.2),stage.clientHeight/(h*2.2)))));
|
|
3593
3799
|
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;}
|
|
3594
|
-
//
|
|
3595
|
-
|
|
3800
|
+
// ── Find in model (Workspaces Ctrl+F) ──────────────────────────────────────────
|
|
3801
|
+
// Match a member's id/mark + profile (case-insensitive substring) across ALL plans, switch to the
|
|
3802
|
+
// first plan with a hit, HIGHLIGHT those hits (via findHits — a set kept SEPARATE from selIds so a
|
|
3803
|
+
// same-plan search never touches the user's real selection; a cross-plan hit switches sheets via
|
|
3804
|
+
// setPlan, which resets selection like any plan change), and frame them. Returns {count,shown}
|
|
3805
|
+
// for the shell's find overlay. Called cross-iframe by workspaces.js via the same-origin
|
|
3806
|
+
// contentWindow, like flushContract(). clearFind() drops the find highlight only (leaves selection).
|
|
3807
|
+
function _findMatch(m, q){ return [m.id, m.mark, m.profile].filter(Boolean).join(' ').toLowerCase().includes(q); }
|
|
3808
|
+
function findMember(query){
|
|
3809
|
+
const q = String(query == null ? '' : query).toLowerCase().trim();
|
|
3810
|
+
if(!q){ clearFind(); return { count: 0, shown: 0 }; }
|
|
3811
|
+
let total = 0, firstPlan = -1, firstHits = [];
|
|
3812
|
+
(C.plans || []).forEach((pl, pi) => {
|
|
3813
|
+
const ids = (pl.members || []).filter((m) => _findMatch(m, q)).map((m) => m.id);
|
|
3814
|
+
total += ids.length;
|
|
3815
|
+
if(ids.length && firstPlan < 0){ firstPlan = pi; firstHits = ids; }
|
|
3816
|
+
});
|
|
3817
|
+
if(firstPlan < 0){ findHits = new Set(); render(); return { count: 0, shown: 0 }; }
|
|
3818
|
+
if(firstPlan !== C.active) setPlan(firstPlan);
|
|
3819
|
+
findHits = new Set(firstHits);
|
|
3820
|
+
render(); zoomToMembers(P.members.filter((m) => findHits.has(m.id)));
|
|
3821
|
+
return { count: total, shown: firstHits.length };
|
|
3822
|
+
}
|
|
3823
|
+
function clearFind(){ if(findHits.size){ findHits = new Set(); render(); } }
|
|
3824
|
+
window.findMember = findMember; window.clearFind = clearFind; // expose to the shell, like window.flushContract (top-level fns aren't reliably on window here)
|
|
3825
|
+
// Frame an arbitrary set of members (bbox → fit); falls back to fit-all when empty. Shared by
|
|
3826
|
+
// zoomToSelection (Tekla "Zoom selected") and Find-in-model — mirrors the 3D view's frameSelection().
|
|
3827
|
+
function zoomToMembers(arr){if(!arr.length){fitToWindow();return;}
|
|
3596
3828
|
let x0=Infinity,y0=Infinity,x1=-Infinity,y1=-Infinity;
|
|
3597
3829
|
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];}
|
|
3598
3830
|
const w=Math.max(x1-x0,40),h=Math.max(y1-y0,40);
|
|
3599
3831
|
applyZoom(Math.min(stage.clientWidth/(w*1.35),stage.clientHeight/(h*1.35)));
|
|
3600
3832
|
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;}
|
|
3833
|
+
function zoomToSelection(){zoomToMembers(selArr());}
|
|
3601
3834
|
function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
|
|
3602
3835
|
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>'+
|
|
3603
3836
|
list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
|
|
@@ -3861,7 +4094,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
3861
4094
|
scheduleSave();setTimeout(()=>toast('Auto-removed '+red.size+' duplicate member'+(red.size>1?'s':'')),60);}}
|
|
3862
4095
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
3863
4096
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
3864
|
-
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
4097
|
+
selIds=new Set();findHits=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null; // findHits resets per plan like selIds — a stale Find highlight must not bleed onto another sheet via a colliding member id
|
|
3865
4098
|
dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
3866
4099
|
if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // grid panel/pick-origin disarm per plan like the other takeover tools (a leaked pick would set the origin in the WRONG sheet's display space)
|
|
3867
4100
|
csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
|