@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.
@@ -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 2D detail image into the 3D scene, near a beam (3D view only)">Insert detail…</button>
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 on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
1376
- 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}/>`;}
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 instruction='Modify the '+kindName+' connection "'+j.id+'" on member '+j.main+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
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,isBP=j.kind==='base-plate',pp=j.params||{};
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 detail…';}}, // armed → cancel target
2968
- onInsertPlace:(pick,pending)=>{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
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
- 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)
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
- // Frame the whole current selection (Tekla "Zoom selected"); falls back to fit-all when nothing's picked — mirrors the 3D view's frameSelection().
3595
- function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();return;}
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