@floless/app 0.77.0 → 0.79.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.
@@ -53093,7 +53093,7 @@ function appVersion() {
53093
53093
  return resolveVersion({
53094
53094
  isSea: isSea2(),
53095
53095
  sqVersionXml: readSqVersionXml(),
53096
- define: true ? "0.77.0" : void 0,
53096
+ define: true ? "0.79.0" : void 0,
53097
53097
  pkgVersion: readPkgVersion()
53098
53098
  });
53099
53099
  }
@@ -53103,7 +53103,7 @@ function resolveChannel(s) {
53103
53103
  return "dev";
53104
53104
  }
53105
53105
  function appChannel() {
53106
- return resolveChannel({ isSea: isSea2(), define: true ? "0.77.0" : void 0 });
53106
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.79.0" : void 0 });
53107
53107
  }
53108
53108
 
53109
53109
  // workflow-update.ts
@@ -53990,6 +53990,16 @@ var BASE_PLATE_PARAMS = {
53990
53990
  edgeDist: [0, 5e3],
53991
53991
  weldLeg: [0, 200]
53992
53992
  };
53993
+ var SHEAR_PLATE_PARAMS = {
53994
+ plateThickness: [1, 100],
53995
+ plateHeight: [1, 5e3],
53996
+ plateWidth: [1, 2e3],
53997
+ boltDia: [1, 100],
53998
+ boltCols: [1, 10],
53999
+ boltRows: [1, 30],
54000
+ boltPitch: [1, 1e3],
54001
+ edgeDist: [0, 2e3]
54002
+ };
53993
54003
  function sanitizeRecipe(raw) {
53994
54004
  if (!raw || typeof raw !== "object") return void 0;
53995
54005
  const r = raw;
@@ -54008,6 +54018,16 @@ function sanitizeRecipe(raw) {
54008
54018
  }
54009
54019
  return Object.keys(params).length ? { kind: r.kind, params } : void 0;
54010
54020
  }
54021
+ if (r.kind === "shear-plate") {
54022
+ const params = {};
54023
+ for (const [k, [lo, hi]] of Object.entries(SHEAR_PLATE_PARAMS)) {
54024
+ if (k in num3) {
54025
+ if (!(num3[k] >= lo && num3[k] <= hi)) return void 0;
54026
+ params[k] = num3[k];
54027
+ }
54028
+ }
54029
+ return Object.keys(params).length ? { kind: r.kind, params } : void 0;
54030
+ }
54011
54031
  return Object.keys(num3).length ? { kind: r.kind, params: num3 } : void 0;
54012
54032
  }
54013
54033
  async function extractConnection(companionId, ifcPath, id) {
@@ -60171,23 +60191,38 @@ function weightOf(contract, profile) {
60171
60191
  var isMfMark = (p) => !!p && /(^|[^A-Z])MF($|[^A-Z])/i.test(p);
60172
60192
  var len = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
60173
60193
  function redundantDupIds(members) {
60174
- const key = (m) => {
60194
+ const foot = (m) => {
60175
60195
  if (!m.wp || m.wp.length < 2) return null;
60176
60196
  const r = (p) => `${Math.round(p[0] / 3)},${Math.round(p[1] / 3)}`;
60177
60197
  const a = r(m.wp[0]), b = r(m.wp[1]);
60178
60198
  return a < b ? `${a}|${b}` : `${b}|${a}`;
60179
60199
  };
60180
- const groups = /* @__PURE__ */ new Map();
60200
+ const byFoot = /* @__PURE__ */ new Map();
60181
60201
  for (const m of members) {
60182
- const k = key(m);
60202
+ const k = foot(m);
60183
60203
  if (!k) continue;
60184
- (groups.get(k) ?? groups.set(k, []).get(k)).push(m);
60204
+ (byFoot.get(k) ?? byFoot.set(k, []).get(k)).push(m);
60185
60205
  }
60186
60206
  const out = /* @__PURE__ */ new Set();
60187
- for (const grp of groups.values()) {
60188
- if (grp.length < 2) continue;
60189
- grp.sort((a, b) => dupRank(b) - dupRank(a));
60207
+ const keepBest = (grp) => {
60208
+ if (grp.length < 2) return;
60209
+ grp.sort((a, b) => dupRank(b) - dupRank(a) || (dupElev(b) !== "na" ? 1 : 0) - (dupElev(a) !== "na" ? 1 : 0));
60190
60210
  for (let i = 1; i < grp.length; i++) out.add(grp[i].id);
60211
+ };
60212
+ for (const grp of byFoot.values()) {
60213
+ if (grp.length < 2) continue;
60214
+ const explicit = new Set(grp.map(dupElev).filter((s) => s !== "na"));
60215
+ if (explicit.size <= 1) {
60216
+ keepBest(grp);
60217
+ continue;
60218
+ }
60219
+ const bySig = /* @__PURE__ */ new Map();
60220
+ for (const m of grp) {
60221
+ const s = dupElev(m);
60222
+ if (s === "na") continue;
60223
+ (bySig.get(s) ?? bySig.set(s, []).get(s)).push(m);
60224
+ }
60225
+ for (const sub of bySig.values()) keepBest(sub);
60191
60226
  }
60192
60227
  return out;
60193
60228
  }
@@ -60197,6 +60232,11 @@ function dupRank(m) {
60197
60232
  if (m.profile && m.profile.trim() !== "") s += 1;
60198
60233
  return s;
60199
60234
  }
60235
+ function dupElev(m) {
60236
+ const q = (v) => typeof v === "number" && isFinite(v) ? Math.round(v) : null;
60237
+ const sig = m.role === "column" ? [q(m.col?.tos), q(m.col?.bos)] : [q(m.ends?.[0]?.tos), q(m.ends?.[1]?.tos)];
60238
+ return sig.every((v) => v == null) ? "na" : sig.map((v) => v == null ? "" : v).join(":");
60239
+ }
60200
60240
  function elevationAssumed(m) {
60201
60241
  if (m.role === "column") return m.col?.tosDef !== false;
60202
60242
  const ends = m.ends ?? [];
@@ -398,20 +398,21 @@
398
398
  #m3dLegend .lrow.clip .clab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
399
399
  #m3dLegend .lrow.clip .lx{margin-left:0} /* the label's flex:1 already pushes On/Off + × to the right */
400
400
  #m3dLegend .lrow.clip.sel{border-left:2px solid var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
401
- #m3dLegend .lrow.clip.sel .clab{color:var(--text)}
402
- #m3dLegend .lrow.clip.sel .lsw{box-shadow:0 0 0 1.5px #f8fafc} /* white ring on the selected clip's swatch — mirrors the 3D endpoint ring */
403
- #m3dLegend .cpill{font-size:9px;line-height:1;padding:2px 6px;border-radius:9px;border:1px solid #475569;background:#334155;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;flex:none;box-shadow:none;cursor:pointer}
404
- #m3dLegend .cpill.on{background:var(--brand);border-color:var(--brand);color:#fff}
405
- #m3dLegend .cpill:hover{border-color:#64748b}
401
+ #m3dLegend .lrow.clip.sel .clab{color:var(--text)} /* selection is shown by the row-level .sel styling above (brand left-border + tint); the box is the enable toggle now */
406
402
  #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}
407
403
  #m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px;max-width:230px;white-space:normal} /* wrap-guard: the hint never drives the panel wider than the rows, so it can't clip on any font */
408
- #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:pointer;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap}
404
+ #m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:default;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap} /* only the left-hand box toggles now (it sets cursor:pointer); the row still isolates on dbl-click + right-clicks for the menu */
409
405
  #m3dLegend .lrow:hover{background:#33415580}
410
- #m3dLegend .lrow.off{opacity:.4} #m3dLegend .lrow.off .lsw{filter:grayscale(1)}
406
+ #m3dLegend .lrow.off{opacity:.4}
407
+ /* hidden/off (or a disabled clip) → the box goes HOLLOW (outline of its own colour); the row also dims. No grayscale — the empty box already signals "hidden". */
408
+ #m3dLegend .lrow.off .lsw,#m3dLegend .lrow.dim.dimoff .lsw{background:transparent;box-shadow:inset 0 0 0 1.6px var(--sw)}
411
409
  #m3dLegend .lrow.solo{background:rgba(59,130,246,.12)} /* in the isolated set (Explorer-style multi-select highlight) */
412
- #m3dLegend .lrow.dim .lsw{background:transparent;border:1.5px solid #67e8f9} /* dim-overlay rows: outline swatch (an annotation layer, not a part) */
413
- #m3dLegend .lrow.dim.dimoff{opacity:.55} #m3dLegend .lrow.dim.dimoff .lsw{opacity:.35} /* off = a normal resting choice, not a hidden-part warning gentler than .off, swatch stays cyan (no grayscale) */
414
- #m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none}
410
+ #m3dLegend .lrow.lsel{box-shadow:inset 2px 0 0 var(--brand)} /* selected in 3D brand left-bar (distinct from .solo's isolate tint; the two can coexist) */
411
+ #m3dLegend .lrow.dim .lsw{--sw:#67e8f9} /* dim overlays are cyansame filled(on)/hollow(off) box as every other row */
412
+ #m3dLegend .lrow.dim.dimoff{opacity:.55} /* off = a normal resting choice, gentler than .off; the box hollows via the shared rule above */
413
+ #m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none;background:var(--sw,#94a3b8);cursor:pointer} /* the visibility box: filled(--sw)=shown; the .off/.dimoff rule above hollows it when hidden */
414
+ #m3dLegend .lsw:hover{box-shadow:0 0 0 2px rgba(255,255,255,.25)}
415
+ #m3dLegend .lrow.off .lsw:hover,#m3dLegend .lrow.dim.dimoff .lsw:hover{box-shadow:inset 0 0 0 1.6px var(--sw),0 0 0 2px rgba(255,255,255,.25)}
415
416
  #m3dLegend .lsec{color:#475569;font-size:10px;letter-spacing:.06em;text-transform:uppercase;margin:6px 0 2px;padding:0 4px}
416
417
  /* member grouping: By profile / By type toggle + collapsible type categories (Phase 1) */
417
418
  #m3dLegend .lmode{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;height:24px;margin-bottom:6px;flex:none}
@@ -434,6 +435,24 @@
434
435
  #m3dLegend .lrow.flash{background:rgba(59,130,246,.12)}
435
436
  .leg-drag-ghost{position:fixed;pointer-events:none;z-index:70;background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:3px 8px;display:flex;align-items:center;gap:7px;font:12px system-ui;color:var(--text);width:200px;opacity:.88;box-shadow:0 4px 16px rgba(0,0,0,.6)}
436
437
  #m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
438
+ /* Objects-list SEARCH — narrows the member/connection rows as you type; never the 3D scene, never Dims/Grid/Clip.
439
+ Built from the same tokens as #propPop .ppsearch (no new vocabulary); sits between the mode toggle and the hint. */
440
+ #m3dLegend .lsearch{display:flex;align-items:center;gap:6px;height:26px;margin-bottom:6px;padding:0 8px;background:var(--bg);border:1px solid var(--line);border-radius:6px;flex:none}
441
+ #m3dLegend .lsearch:focus-within{border-color:var(--brand)}
442
+ #m3dLegend .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
443
+ #m3dLegend .lsico svg{display:block}
444
+ #m3dLegend .lsearch input{flex:1;min-width:0;width:auto;height:auto;background:transparent;border:0;outline:none;color:var(--text);font:12px system-ui;padding:0}
445
+ #m3dLegend .lsearch input::placeholder{color:var(--mut)}
446
+ #m3dLegend .lsearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden} /* clear — reuses the .lrow .lx delete-glyph recipe */
447
+ #m3dLegend .lsearch.has .lsx{visibility:visible}
448
+ #m3dLegend .lsearch .lsx:hover{color:#fecaca;background:#7f1d1d}
449
+ #m3dLegend .lrow.qhide,#m3dLegend .cat-hdr.qhide{display:none} /* filtered OUT by search → gone (distinct from .off = hidden-in-3D, which only dims the swatch) */
450
+ #m3dLegend .lsempty{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
451
+ /* Show-all reset bar — panel-local entry to showAllGroups(); shown only when something is hidden/isolated. Clones the
452
+ .lsearch full-width box recipe; brand border on hover only (a one-shot action, not a mode → no solid fill). */
453
+ #m3dLegend .lreset{display:none;align-items:center;justify-content:center;gap:6px;height:26px;margin-bottom:6px;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--text);font:12px system-ui;cursor:pointer;flex:none;user-select:none}
454
+ #m3dLegend .lreset.show{display:flex}
455
+ #m3dLegend .lreset:hover{border-color:var(--brand);background:#1a2740}
437
456
  #m3dCube{position:absolute;right:12px;top:56px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))} /* top-right (Revit-style), below the toolbar row */
438
457
  /* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
439
458
  (pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
@@ -1331,12 +1350,29 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
1331
1350
  if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
1332
1351
  c.rfi=(_wt(c.profile)==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
1333
1352
  // --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
1334
- function dupKey(m){const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
1353
+ function dupFoot(m){if(!m||!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
1354
+ // Elevation signature. Two members at the SAME footprint but a DIFFERENT explicit top-of-steel are different
1355
+ // objects (a beam stacked over a beam); an UNSET elevation is a wildcard — it dedupes against whatever's at that
1356
+ // footprint (a genuine double-read that lost its callout on one copy). tos is rounded to the inch: real callout
1357
+ // elevations are whole values so sub-inch rounding boundaries don't arise; levels differ by feet, so a coarser
1358
+ // tolerance isn't needed. MIRROR of server/steel-confidence.ts dupElev — keep the two in sync.
1359
+ function dupElev(m){const q=v=>(typeof v==='number'&&isFinite(v))?Math.round(v):null;
1360
+ const sig=(m&&m.role==='column')?[q(m.col&&m.col.tos),q(m.col&&m.col.bos)]:[q(m&&m.ends&&m.ends[0]&&m.ends[0].tos),q(m&&m.ends&&m.ends[1]&&m.ends[1].tos)];
1361
+ return sig.every(v=>v==null)?'na':sig.map(v=>v==null?'':v).join(':');}
1335
1362
  function dupScore(m){let s=0;if(_wt(m.profile)!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
1336
- function redundantDups(){const g={};for(const m of P.members){(g[dupKey(m)]=g[dupKey(m)]||[]).push(m);}
1337
- const out=[];for(const k in g){const grp=g[k];if(grp.length<2)continue;
1338
- grp.sort((a,b)=>dupScore(b)-dupScore(a));for(let i=1;i<grp.length;i++)out.push(grp[i].id);} // keep [0], rest redundant
1363
+ // Coincident-member dedupe the redundant ids. Group by 2D footprint; within a footprint: ≤1 distinct EXPLICIT
1364
+ // elevation ⇒ all copies of one member ⇒ keep the best (highest rankFn; an elevation-tagged copy wins ties), rest
1365
+ // redundant; ≥2 explicit elevations ⇒ distinct levels ⇒ keep the best per level, unset copies kept (ambiguous).
1366
+ // MIRROR of server/steel-confidence.ts redundantDupIds — keep in sync.
1367
+ function dedupeFootprintIds(members,rankFn){const byFoot={};for(const m of members){const k=dupFoot(m);if(!k)continue;(byFoot[k]=byFoot[k]||[]).push(m);}
1368
+ const out=[],keepBest=grp=>{if(grp.length<2)return;grp.sort((a,b)=>rankFn(b)-rankFn(a)||((dupElev(b)!=='na')-(dupElev(a)!=='na')));for(let i=1;i<grp.length;i++)out.push(grp[i].id);};
1369
+ for(const k in byFoot){const grp=byFoot[k];if(grp.length<2)continue;
1370
+ const exp=new Set(grp.map(dupElev).filter(s=>s!=='na'));
1371
+ if(exp.size<=1){keepBest(grp);continue;}
1372
+ const bySig={};for(const m of grp){const s=dupElev(m);if(s==='na')continue;(bySig[s]=bySig[s]||[]).push(m);}
1373
+ for(const s in bySig)keepBest(bySig[s]);}
1339
1374
  return out;}
1375
+ function redundantDups(){return dedupeFootprintIds(P.members,dupScore);}
1340
1376
  // --- merge collinear chords: same-profile, end-to-end, STRAIGHT beam runs → one member each.
1341
1377
  // The skew read breaks a chord into collinear sub-segments at every rung; this rejoins each run.
1342
1378
  // MIRROR of server/steel-merge.ts (the tested source of truth) — keep the two in lock-step.
@@ -1455,7 +1491,7 @@ function render(){
1455
1491
  s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
1456
1492
  if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
1457
1493
  svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle();
1458
- if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
1494
+ if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);refreshLegendSel();} // keep the 3D highlight + legend selection in sync; selecting a member clears any clip selection (exclusive)
1459
1495
  try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
1460
1496
  syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
1461
1497
  }
@@ -3054,6 +3090,23 @@ const view3dApi={
3054
3090
  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
3091
  return;
3056
3092
  }
3093
+ // Slice E: a RECOGNIZED shear/fin plate dropped onto a BEAM → bake an EDITABLE shear-plate recipe joint on
3094
+ // the nearest end; expandShearPlate re-derives it there from the fitted params. Needs a beam at the pick.
3095
+ const beam=(rc&&rc.kind==='shear-plate'&&pick.anchorId)?P.members.find(m=>m&&m.id===pick.anchorId&&m.role==='beam'):null;
3096
+ if(beam){
3097
+ const id='cx'+Date.now().toString(36)+Math.floor(Math.random()*1e4).toString(36);
3098
+ // Nearest end via the resolved 3D geometry: end0 = beam.from (wp[0]), end1 = beam.to (wp[1]).
3099
+ const g=partsById[beam.id],d3=(a,b)=>a&&b?Math.hypot(a[0]-b[0],a[1]-b[1],a[2]-b[2]):Infinity;
3100
+ const endIdx=(g&&g.from&&g.to&&d3(pick.point,g.to)<d3(pick.point,g.from))?1:0;
3101
+ const joint={id,kind:'shear-plate',main:beam.id,at:'end'+endIdx,params:Object.assign({},rc.params),source:'user'};
3102
+ pendingConnSel=id; // its parts only exist after the 3D rebuild → select the whole connection there
3103
+ // One shear plate per beam END: replace any existing joint on this end (else two overlap and "edit on
3104
+ // member" would target the older joint, not this import).
3105
+ const had=(C.joints||[]).some(x=>x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx);
3106
+ edit(()=>{C.joints=(Array.isArray(C.joints)?C.joints:[]).filter(x=>!(x&&x.kind==='shear-plate'&&x.main===beam.id&&x.at==='end'+endIdx));C.joints.push(joint);selIds=new Set();});
3107
+ toast((had?'Shear plate on '+beam.id+' '+(endIdx?'end':'start')+' replaced with imported “'+(conn.name||'connection')+'”':'Shear plate “'+(conn.name||'imported')+'” applied to '+beam.id+' '+(endIdx?'end':'start'))+' — edit its parameters on the member');
3108
+ return;
3109
+ }
3057
3110
  // Slice B: opaque custom mesh — bake at the picked point (joint.place); expandCustom re-expands it into
3058
3111
  // the scene as one selectable unit. Unrecognized imports, and a recognized base plate NOT dropped on a
3059
3112
  // column, land here (still faithful geometry) with a hint toward the editable path.
@@ -3062,7 +3115,7 @@ const view3dApi={
3062
3115
  const joint={id,kind:'custom',name:conn.name||'Imported connection',place:pick.point,geometry:conn.geometry,source:'user'};
3063
3116
  if(pick.anchorId)joint.main=pick.anchorId; // snapped to a member face → record it for the inspector's "on member" line
3064
3117
  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')
3118
+ toast(rc?('Imported “'+(conn.name||'connection')+'” as geometry — drop it on a '+(rc.kind==='shear-plate'?'beam end to apply it as an editable shear plate':'column to apply it as an editable base plate'))
3066
3119
  :('Connection “'+(conn.name||'imported')+'” placed'+(pick.anchorId?' on '+pick.anchorId:'')+' — select it to move or replace'));
3067
3120
  return;
3068
3121
  }
@@ -3120,9 +3173,10 @@ async function detailRequest(intent,place,note){
3120
3173
  body:JSON.stringify({appId:APP_ID,project:PROJECT||undefined,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
3121
3174
  toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
3122
3175
  }catch(_){toast('Could not queue the request');}}
3123
- // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
3124
- // double-click isolate mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
3125
- let leg3dClickT=null,legendAnchor=null;
3176
+ // Build the 3D legend overlay from the live scene groups (per profile). Click the BOX to show/hide (filled =
3177
+ // shown, hollow = hidden); click a row (its name) to SELECT the object[s] in 3D Ctrl/Cmd adds/removes, Shift
3178
+ // ranges; double-click a row to isolate. leg3dClickT defers the plain row-click so a dbl-click isolates instead.
3179
+ let leg3dClickT=null,legendAnchor=null,legendSelAnchor=null;
3126
3180
  // Explorer-style multi-isolate on dbl-click of a legend group row: plain = isolate just this group; Ctrl = toggle
3127
3181
  // it in/out of the isolated set; Shift = the contiguous range from the anchor row to this one (in displayed order).
3128
3182
  function legendIsolate(k,e){
@@ -3179,6 +3233,10 @@ function profileKeyOf(m){return (m&&m.profile||'').trim().toUpperCase();} // ma
3179
3233
  function categoryOfProfile(profKey){for(const m of (P.members||[]))if(profileKeyOf(m)===profKey)return memberTypeOf(m);return 'beam';} // a profile-group's category = the type of its member(s)
3180
3234
  let legendMode=(localStorage.getItem('floless.legendMode')==='type')?'type':'profile';
3181
3235
  let collapsedCats=new Set((()=>{try{return JSON.parse(localStorage.getItem('floless.legendCollapsed')||'[]');}catch{return [];}})());
3236
+ let legendQuery=''; // transient objects-list search filter (members + connections only) — NOT persisted
3237
+ // While a search is active, object categories (member types + connections) render EXPANDED so a match inside a
3238
+ // manually-collapsed category still surfaces — WITHOUT mutating the persisted collapsedCats.
3239
+ function catForceOpen(cat){return !!legendQuery&&(MEMBER_TYPES.some(t=>t.k===cat)||/^conn-/.test(cat));}
3182
3240
  function saveLegendPrefs(){try{localStorage.setItem('floless.legendMode',legendMode);localStorage.setItem('floless.legendCollapsed',JSON.stringify([...collapsedCats]));}catch{}}
3183
3241
  // Drag a typed member row onto another type category to retype it. Pointer Events (NOT the HTML5 drag API,
3184
3242
  // which paints a white browser ghost on Windows). A 6px threshold tells a drag from the row's click(hide) /
@@ -3215,21 +3273,27 @@ const DIM_LABEL=Object.fromEntries(DIM_CATS);
3215
3273
  // edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
3216
3274
  const DIM_CONN=[{ct:'base-plate',label:'Base-plate',cats:['base_plate','anchor_depth']},{ct:'shear-plate',label:'Shear-plate',cats:['bolt_pitch','edge_clearance','cope_size']}];
3217
3275
  function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
3276
+ if(!host._ctxWired){host._ctxWired=true;host.addEventListener('contextmenu',e=>e.preventDefault());} // right-click does nothing now (menu removed) — suppress the native OS menu so it never leaks over the dark theme
3218
3277
  const groups=window.Steel3DView.getGroups();host.replaceChildren();
3219
3278
  if(!groups.length){host.style.display='none';return;}
3220
- const hint=document.createElement('div');hint.className='lhint';hint.textContent='click hide/show · dbl-click isolate · Ctrl/Shift multi';host.appendChild(hint);
3279
+ const hint=document.createElement('div');hint.className='lhint';hint.textContent='click to select · box = show/hide (selection) · dbl-click = isolate (selection) · Ctrl/Shift to multi-select';host.appendChild(hint);
3221
3280
  const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
3222
3281
  if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.dataset.tip='Drag onto another type';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);} // handle = the only drag initiator; swallow its own clicks so it never toggles the row
3223
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=g.color;
3282
+ const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',g.color);sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';
3224
3283
  row.append(sw,document.createTextNode(g.label));
3225
- row.addEventListener('click',()=>{if(row._dragging)return;clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
3226
- row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);legendIsolate(g.key,e);});
3284
+ // Show/hide lives on the BOX; dbl-click a row isolates. When the row is part of a multi-selection, the box toggles
3285
+ // ALL selected together and the dbl-click isolates the whole selection. stopPropagation on the box so a dbl-click
3286
+ // landing on it doesn't also fire row-isolate.
3287
+ sw.addEventListener('click',e=>{e.stopPropagation();legendBoxToggle(row);});
3288
+ sw.addEventListener('dblclick',e=>e.stopPropagation());
3289
+ row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);if(row.classList.contains('lsel'))legendIsolateSel();else legendIsolate(g.key,e);});
3290
+ row.addEventListener('click',e=>legendRowClick(e,row)); // click the name to SELECT (Ctrl/Cmd add · Shift range); plain click is deferred so a dbl-click isolates instead
3227
3291
  if(draggable)wireRowDrag(row,g);
3228
3292
  host.appendChild(row);return row;};
3229
3293
  // A collapsible legend category: chevron (collapse) + tri-state master on/off (■/□/◪) + label + count.
3230
3294
  // getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
3231
3295
  const buildCatHeader=(cat,label,count,opts)=>{opts=opts||{};const hdr=document.createElement('div');hdr.className='cat-hdr'+(opts.empty?' empty':'')+(opts.sub?' sub':'');hdr.dataset.cat=cat;hdr._getState=opts.getState;
3232
- const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:collapsedCats.has(cat)?'':''});
3296
+ const catOpen=!collapsedCats.has(cat)||catForceOpen(cat);const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:catOpen?'':''});
3233
3297
  const tog=Object.assign(document.createElement('span'),{className:'cat-tog'});tog.dataset.tip=opts.toggleTitle||('Show / hide all '+label.toLowerCase());if(opts.empty||!opts.onToggle)tog.style.display='none';
3234
3298
  const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
3235
3299
  const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
@@ -3249,12 +3313,28 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3249
3313
  mode.appendChild(b);}
3250
3314
  host.insertBefore(mode,host.firstChild);
3251
3315
  }
3316
+ if(members.length||conns.length){ // SEARCH box — narrows object rows (members + connections); shown whenever there are objects to filter
3317
+ const sb=document.createElement('div');sb.className='lsearch'+(legendQuery?' has':'');
3318
+ const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');
3319
+ const NS='http://www.w3.org/2000/svg',svg=document.createElementNS(NS,'svg'); // magnifier built via DOM (no innerHTML), stroked with currentColor so it inherits --mut
3320
+ svg.setAttribute('viewBox','0 0 16 16');svg.setAttribute('width','12');svg.setAttribute('height','12');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.6');svg.setAttribute('stroke-linecap','round');
3321
+ const cir=document.createElementNS(NS,'circle');cir.setAttribute('cx','7');cir.setAttribute('cy','7');cir.setAttribute('r','4.5');
3322
+ const lin=document.createElementNS(NS,'line');lin.setAttribute('x1','10.6');lin.setAttribute('y1','10.6');lin.setAttribute('x2','14');lin.setAttribute('y2','14');
3323
+ svg.append(cir,lin);ico.append(svg);
3324
+ const inp=document.createElement('input');inp.id='legSearch';inp.type='text';inp.placeholder='Search objects…';inp.autocomplete='off';inp.value=legendQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search objects in the list');
3325
+ const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
3326
+ inp.addEventListener('input',()=>onLegendSearchInput(inp.value));
3327
+ inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';onLegendSearchInput('');}else{inp.blur();}}});
3328
+ clr.addEventListener('click',()=>{if(!inp.value&&!legendQuery)return;inp.value='';onLegendSearchInput('');inp.focus();});
3329
+ sb.append(ico,inp,clr);
3330
+ host.insertBefore(sb,hint);
3331
+ }
3252
3332
  if(legendMode==='type'&&members.length){ // group the profile-rows under their member-type categories
3253
3333
  const byCat=new Map(MEMBER_TYPES.map(t=>[t.k,[]]));
3254
3334
  for(const g of members){(byCat.get(categoryOfProfile(g.key))||byCat.get('beam')).push(g);}
3255
3335
  for(const {k,label} of MEMBER_TYPES){const gs=byCat.get(k)||[],keys=gs.map(g=>g.key);
3256
3336
  host.appendChild(buildCatHeader(k,label,gs.length,{empty:!gs.length,getState:()=>grpState(keys),onToggle:()=>grpToggle(keys),toggleTitle:'Show / hide all '+label.toLowerCase()+'s'}));
3257
- if(!collapsedCats.has(k))for(const g of gs)addRow(g,true,true);}
3337
+ if(!collapsedCats.has(k)||catForceOpen(k))for(const g of gs)addRow(g,true,true);}
3258
3338
  } else for(const g of members)addRow(g);
3259
3339
  if(conns.length){ // group connection PARTS under their joint (Phase 2): each part-kind a row, hidden per-id
3260
3340
  if(members.length){host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));}
@@ -3273,10 +3353,11 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3273
3353
  getState:()=>{const h=hiddenSet(),n=allIds.filter(id=>h.has(id)).length;return n===0?'on':(n===allIds.length?'off':'mixed');},
3274
3354
  onToggle:()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(allIds,allIds.every(id=>!h.has(id)));}, // all-on → hide all; else show all
3275
3355
  toggleTitle:'Show / hide the '+label.toLowerCase()+' connection'}));
3276
- if(!collapsedCats.has(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
3356
+ if(!collapsedCats.has(ck)||catForceOpen(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
3277
3357
  const row=document.createElement('div');row.className='lrow typed';row.dataset.connkey=ck+':'+grp;row._ids=ids;
3278
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=m.color;row.append(sw,document.createTextNode(m.label));
3279
- row.addEventListener('click',()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(ids,!ids.every(id=>h.has(id)));refresh3DLegend();}); // click toggles just this connection's parts of this kind
3358
+ const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',m.color);sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';row.append(sw,document.createTextNode(m.label));
3359
+ sw.addEventListener('click',e=>{e.stopPropagation();legendBoxToggle(row);});sw.addEventListener('dblclick',e=>e.stopPropagation()); // box toggles this connection's parts (or the whole selection when this row is selected)
3360
+ row.addEventListener('click',e=>legendRowClick(e,row)); // click the row to select these connection parts (Ctrl/Shift multi)
3280
3361
  host.appendChild(row);}
3281
3362
  }
3282
3363
  }
@@ -3286,11 +3367,12 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3286
3367
  // isolate), and a gentler off-state — off is a normal resting choice here, not a hidden-part warning.
3287
3368
  const ov=C.dim_overlays||{};
3288
3369
  const addDimRow=(cat,label,sub)=>{const row=document.createElement('div');row.className='lrow dim typed'+(sub?' sub':'');row.dataset.dim=cat;
3289
- const sw=document.createElement('span');sw.className='lsw';
3370
+ const sw=document.createElement('span');sw.className='lsw';sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';sw.setAttribute('aria-checked',String(ov[cat]!==false));
3290
3371
  row.append(sw,document.createTextNode(label));
3291
3372
  row.classList.toggle('dimoff',ov[cat]===false);
3292
- // toggle the overlay; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
3293
- row.addEventListener('click',()=>{const on=C.dim_overlays[cat]!==false;C.dim_overlays[cat]=!on;row.classList.toggle('dimoff',on);scheduleSave();refreshOverlayDims3d();});
3373
+ // toggle the overlay from the BOX; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
3374
+ sw.addEventListener('click',e=>{e.stopPropagation();const on=C.dim_overlays[cat]!==false;C.dim_overlays[cat]=!on;row.classList.toggle('dimoff',on);sw.setAttribute('aria-checked',String(!on));scheduleSave();refreshOverlayDims3d();});
3375
+ sw.addEventListener('dblclick',e=>e.stopPropagation());
3294
3376
  host.appendChild(row);};
3295
3377
  if(members.length||conns.length)host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
3296
3378
  const dimState=()=>{const on=DIM_CATS.filter(([k])=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===DIM_CATS.length?'on':'mixed');};
@@ -3298,7 +3380,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3298
3380
  const dcState=cats=>{const on=cats.filter(k=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===cats.length?'on':'mixed');};
3299
3381
  const dcToggle=cats=>{const anyOn=cats.some(k=>C.dim_overlays[k]!==false);for(const k of cats)C.dim_overlays[k]=!anyOn;scheduleSave();refreshOverlayDims3d();build3DLegend();};
3300
3382
  host.appendChild(buildCatHeader('dims','Dimensions',DIM_CATS.length,{getState:dimState,onToggle:dimToggle,toggleTitle:'Show / hide all dimension overlays'}));
3301
- if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click: show / hide'}));
3383
+ if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click the box to show / hide'}));
3302
3384
  for(const dc of DIM_CONN){const ck='dims-'+dc.ct; // middle category: overlays grouped by connection
3303
3385
  host.appendChild(buildCatHeader(ck,dc.label,dc.cats.length,{sub:true,getState:()=>dcState(dc.cats),onToggle:()=>dcToggle(dc.cats),toggleTitle:'Show / hide all '+dc.label.toLowerCase()+' dimensions'}));
3304
3386
  if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
@@ -3309,10 +3391,10 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3309
3391
  if(typeof P!=='undefined'&&P&&P.grid){
3310
3392
  host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
3311
3393
  const grow=document.createElement('div');grow.className='lrow dim';grow.dataset.tip='Show / hide the structural grid (2D + 3D)';
3312
- const gsw=document.createElement('span');gsw.className='lsw';gsw.style.borderColor='#64748b';
3394
+ const gsw=document.createElement('span');gsw.className='lsw';gsw.style.setProperty('--sw','#64748b');gsw.setAttribute('role','checkbox');gsw.dataset.tip='Show / hide';gsw.setAttribute('aria-checked',String(gridOn()));
3313
3395
  grow.append(gsw,document.createTextNode('Grid lines'));
3314
3396
  grow.classList.toggle('dimoff',!gridOn());
3315
- grow.addEventListener('click',()=>gridSetVisible(!gridOn()));
3397
+ gsw.addEventListener('click',e=>{e.stopPropagation();gridSetVisible(!gridOn());});gsw.addEventListener('dblclick',e=>e.stopPropagation());
3316
3398
  host.appendChild(grow);
3317
3399
  }
3318
3400
  // CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
@@ -3326,22 +3408,22 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3326
3408
  if(collapsedCats.has('clip')){/* collapsed → no rows */}
3327
3409
  else if(!clips.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'(no clips)'}));}
3328
3410
  else for(const c of clips){
3329
- // Three separate zones (no fighting): swatch/label = SELECT (reveals its 3D drag handles), On/Off pill = ENABLE, × = DELETE.
3330
- const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':''); // enable state is shown by the On/Off pill, not by dimming the row
3331
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=c.kind==='box'?'#93c5fd':'#3b82f6';sw.dataset.tip='Select show its drag handles in 3D'; // box = lighter blue, plane = brand blue
3411
+ // Box = ENABLE / disable (filled = cutting, hollow = off); label = SELECT (click) / RENAME (dbl-click); × = DELETE.
3412
+ const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':'')+(c.enabled?'':' off'); // disabled .off hollows the box + dims the row, like a hidden part
3413
+ const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',c.kind==='box'?'#93c5fd':'#3b82f6');sw.setAttribute('role','checkbox');sw.setAttribute('aria-checked',String(!!c.enabled));sw.dataset.tip='Enable / disable this clip'; // box = lighter blue, plane = brand blue
3332
3414
  const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.dataset.tip='Click to select · double-click to rename';
3333
- const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.dataset.tip='Enable / disable this clip';
3334
3415
  const x=document.createElement('span');x.className='lx';x.textContent='×';x.dataset.tip='Delete this clip';
3335
- row.append(sw,lab,tog,x);
3336
- sw.addEventListener('click',e=>{e.stopPropagation();clipSelect(c.id,e);}); // Ctrl/Shift = multi-select (same as parts/dims)
3416
+ row.append(sw,lab,x);
3417
+ sw.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);}); // box toggles enable; selecting (which reveals the 3D drag handles) is the label's job
3418
+ sw.addEventListener('dblclick',e=>e.stopPropagation());
3337
3419
  let clipClickT=null;
3338
3420
  lab.addEventListener('click',e=>{e.stopPropagation();clearTimeout(clipClickT);const ev={ctrlKey:e.ctrlKey,metaKey:e.metaKey,shiftKey:e.shiftKey};clipClickT=setTimeout(()=>clipSelect(c.id,ev),200);}); // deferred so a double-click (rename) can cancel the select
3339
3421
  lab.addEventListener('dblclick',e=>{e.stopPropagation();e.preventDefault();clearTimeout(clipClickT);startClipRename(c,lab);});
3340
- tog.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);});
3341
3422
  x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
3342
3423
  host.appendChild(row);
3343
3424
  }
3344
- host.style.display='flex';refresh3DLegend();}
3425
+ {const rst=Object.assign(document.createElement('div'),{className:'lreset',id:'m3dLegendReset',textContent:'Show all'});rst.setAttribute('role','button');rst.dataset.tip='Restore every hidden / isolated object in this panel';rst.addEventListener('click',legendReset);host.insertBefore(rst,host.firstChild);} // Show-all reset — panel's first child (above the mode toggle); visibility set by updateLegendReset (via refresh3DLegend)
3426
+ host.style.display='flex';refresh3DLegend();applyLegendFilter();refreshLegendSel();}
3345
3427
  // The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
3346
3428
  // "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
3347
3429
  function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
@@ -3363,10 +3445,81 @@ function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.
3363
3445
  function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
3364
3446
  const state=hdr._getState();tog.dataset.state=state;tog.textContent=state==='on'?'■':(state==='off'?'□':'◪');}
3365
3447
  function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden),solo=new Set(st.solo);
3366
- document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;r.classList.toggle('off',hidden.has(k)||(solo.size>0&&!solo.has(k)));r.classList.toggle('solo',solo.size>0&&solo.has(k));}); // PROFILE rows only (data-key); .off = hidden or outside the isolated set; .solo = inside it (Explorer-style highlight)
3448
+ document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;const off=hidden.has(k)||(solo.size>0&&!solo.has(k));r.classList.toggle('off',off);r.classList.toggle('solo',solo.size>0&&solo.has(k));const sw=r.querySelector('.lsw');if(sw)sw.setAttribute('aria-checked',String(!off));}); // PROFILE rows (data-key); .off = hidden or outside the isolated set → box hollows; .solo = inside it (Explorer-style highlight)
3367
3449
  const ch=new Set(window.Steel3DView.connHiddenIds?window.Steel3DView.connHiddenIds():[]); // per-part connection hide
3368
- document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];r.classList.toggle('off',ids.length>0&&ids.every(id=>ch.has(id)));});
3369
- document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);} // refresh the type-category master toggles too
3450
+ document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];const off=ids.length>0&&ids.every(id=>ch.has(id));r.classList.toggle('off',off);const sw=r.querySelector('.lsw');if(sw)sw.setAttribute('aria-checked',String(!off));});
3451
+ document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);updateLegendReset();} // refresh the type-category master toggles + the show-all reset bar's visibility
3452
+ // ── Objects-list SELECTION — click a row (its name) to select its object[s] in 3D; Ctrl/Cmd add/remove, Shift range ──
3453
+ // Plain click is deferred (leg3dClickT) so a dbl-click can isolate instead; a modified click selects immediately
3454
+ // (so rapid Ctrl-clicking several rows doesn't lose one to the shared timer). Box clicks stopPropagation, so they never reach here.
3455
+ function legendRowClick(e,row){if(row&&row._dragging)return;const mods={ctrl:e.ctrlKey||e.metaKey,shift:e.shiftKey};clearTimeout(leg3dClickT);if(mods.ctrl||mods.shift)legendSelect(row,mods);else leg3dClickT=setTimeout(()=>legendSelect(row,mods),220);}
3456
+ function legendSelect(row,mods){
3457
+ if(!row)return;const ids=legRowIds(row);if(!ids.length)return;
3458
+ if(mods&&mods.shift&&legendSelAnchor&&document.body.contains(legendSelAnchor)){ // Shift → union every VISIBLE object row from the anchor to here (a search-hidden row can't be range-selected)
3459
+ const rows=[...document.querySelectorAll('#m3dLegend .lrow[data-key]:not(.qhide),#m3dLegend .lrow[data-connkey]:not(.qhide)')];
3460
+ const i0=rows.indexOf(legendSelAnchor),i1=rows.indexOf(row);
3461
+ if(i0>=0&&i1>=0){const next=new Set();rows.slice(Math.min(i0,i1),Math.max(i0,i1)+1).forEach(r=>legRowIds(r).forEach(id=>next.add(id)));selIds=next;selDimIds.clear();sel3dDimIds.clear();render();return;}
3462
+ }
3463
+ if(mods&&mods.ctrl){const next=new Set(selIds);const all=ids.every(id=>next.has(id));ids.forEach(id=>all?next.delete(id):next.add(id));selIds=next;} // Ctrl → toggle this group in/out of the selection
3464
+ else selIds=new Set(ids); // plain → replace the selection
3465
+ legendSelAnchor=row;selDimIds.clear();sel3dDimIds.clear();render();
3466
+ }
3467
+ // A row lights up (.lsel) when EVERY object it represents is selected — so a legend click that selects the whole group shows it. Synced from render()'s 3D block + build3DLegend.
3468
+ function refreshLegendSel(){const host=document.getElementById('m3dLegend');if(!host||host.style.display==='none')return;
3469
+ host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]').forEach(r=>{const ids=legRowIds(r);r.classList.toggle('lsel',ids.length>0&&ids.every(id=>selIds.has(id)));});}
3470
+ // Click a row's BOX → show/hide. If that row is part of the current selection, the box acts on EVERY selected row
3471
+ // (the clicked row's current state drives the direction). Members toggle by group key, connections by part id.
3472
+ function legendBoxToggle(row){if(!row||!window.Steel3DView)return;
3473
+ const rows=row.classList.contains('lsel')?[...document.querySelectorAll('#m3dLegend .lrow.lsel')]:[row];
3474
+ const willHide=!row.classList.contains('off'); // one direction for all: hide if the clicked box was shown, else show
3475
+ const keys=[],ids=[];for(const r of rows){if(r.dataset.connkey)ids.push(...(r._ids||[]));else if(r.dataset.key)keys.push(r.dataset.key);}
3476
+ if(keys.length&&window.Steel3DView.setGroupsHidden)window.Steel3DView.setGroupsHidden(keys,willHide);
3477
+ if(ids.length&&window.Steel3DView.setIdsHidden)window.Steel3DView.setIdsHidden(ids,willHide);
3478
+ refresh3DLegend();}
3479
+ // Dbl-click a SELECTED row → isolate the whole selection (all selected profile groups, Explorer-style solo). Only
3480
+ // reachable from a member row's dbl-click (connection rows have no dbl-click), so the double-clicked member's own
3481
+ // key is always present — no empty-keys path.
3482
+ function legendIsolateSel(){if(!window.Steel3DView)return;
3483
+ const keys=[...document.querySelectorAll('#m3dLegend .lrow.lsel[data-key]')].map(r=>r.dataset.key);
3484
+ if(keys.length){window.Steel3DView.setSoloGroups(keys);refresh3DLegend();}}
3485
+ // "Show all" reset — a panel-local door onto showAllGroups() (which already clears box-hides, solo, isolate AND
3486
+ // per-connection hides in one call, then refreshes). Does NOT clear the search filter — the search box's own × owns that.
3487
+ function legendReset(){if(!window.Steel3DView)return;window.Steel3DView.showAllGroups();if(window.Steel3DView.clearIsolation)window.Steel3DView.clearIsolation();}
3488
+ // Show the reset bar only when 3D visibility is non-default (something hidden / solo'd / isolated). Indifferent to search.
3489
+ function updateLegendReset(){const b=document.getElementById('m3dLegendReset');if(!b||!window.Steel3DView)return;
3490
+ const st=window.Steel3DView.groupState();
3491
+ const filtered=(st.hidden&&st.hidden.length>0)||(st.solo&&st.solo.length>0)||(window.Steel3DView.isIsolated&&window.Steel3DView.isIsolated())||(window.Steel3DView.connHiddenIds&&window.Steel3DView.connHiddenIds().length>0);
3492
+ b.classList.toggle('show',filtered);}
3493
+ // ── Objects-list SEARCH ──────────────────────────────────────────────────────────────────────────────────────
3494
+ // Search narrows the MEMBER + CONNECTION rows only (never Dimensions/Grid/Clip, never the 3D scene). Crossing
3495
+ // empty↔non-empty rebuilds once (so collapsed categories force-expand and their matches can surface); refining
3496
+ // within an active query is a cheap show/hide pass that keeps the input focused.
3497
+ function onLegendSearchInput(q){
3498
+ const prev=legendQuery;legendQuery=q;
3499
+ if((!!prev)!==(!!q)){build3DLegend();const inp=document.getElementById('legSearch');if(inp){inp.focus();try{inp.setSelectionRange(inp.value.length,inp.value.length);}catch(_){}}}
3500
+ else applyLegendFilter();
3501
+ }
3502
+ // Show/hide object rows by label; hide object categories left with no visible child; toggle the "no matches" line.
3503
+ function applyLegendFilter(){
3504
+ const host=document.getElementById('m3dLegend');if(!host)return;
3505
+ const q=(legendQuery||'').trim().toLowerCase();
3506
+ const old=host.querySelector('.lsempty');if(old)old.remove();
3507
+ const rows=[...host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]')];
3508
+ if(!q){rows.forEach(r=>r.classList.remove('qhide'));host.querySelectorAll('.cat-hdr.qhide').forEach(h=>h.classList.remove('qhide'));return;}
3509
+ let any=false;
3510
+ rows.forEach(r=>{const hit=(r.textContent||'').toLowerCase().includes(q);r.classList.toggle('qhide',!hit);if(hit)any=true;});
3511
+ host.querySelectorAll('.cat-hdr').forEach(h=>{const cat=h.dataset.cat||'';
3512
+ if(!(MEMBER_TYPES.some(t=>t.k===cat)||/^conn-/.test(cat)))return; // leave the Dimensions/Clip headers untouched
3513
+ let vis=false;
3514
+ for(let n=h.nextElementSibling;n&&!n.classList.contains('cat-hdr')&&!n.classList.contains('lsec')&&!n.classList.contains('ldiv');n=n.nextElementSibling){
3515
+ if((n.matches('.lrow[data-key]')||n.matches('.lrow[data-connkey]'))&&!n.classList.contains('qhide')){vis=true;break;}}
3516
+ h.classList.toggle('qhide',!vis);});
3517
+ if(!any){const hint=host.querySelector('.lhint');const e=Object.assign(document.createElement('div'),{className:'lsempty',textContent:'No objects match “'+legendQuery.trim()+'”.'});
3518
+ if(hint&&hint.nextSibling)host.insertBefore(e,hint.nextSibling);else host.appendChild(e);}
3519
+ }
3520
+ // Resolve a row to the member/connection ids it represents. A member row's data-key is a profile key; a connection
3521
+ // row carries its part ids on row._ids. Used by legend click-to-select + the box/isolate SELECTION actions below.
3522
+ function legRowIds(row){if(!row)return [];if(row.dataset.connkey)return (row._ids||[]).slice();const k=row.dataset.key;if(!k)return [];return (P.members||[]).filter(m=>profileKeyOf(m)===k).map(m=>m.id);}
3370
3523
  let bar3dWired=false;
3371
3524
  function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
3372
3525
  // Reflect the live projection / display mode into the Camera + Display dropdowns: tick the active menu item AND label the trigger button, so the current mode shows without opening the menu.
@@ -3867,10 +4020,9 @@ function _wt(profile){if(!profile)return null;
3867
4020
  if(WT){const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());if(h&&h[1]!=null)return h[1];}
3868
4021
  return _nominalPlf(profile);} // fall back to the lb/ft encoded in a standard designation
3869
4022
  const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
3870
- function _confDupIds(members){const g={};const key=m=>{if(!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;};
3871
- const rank=m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;};
3872
- for(const m of members){const k=key(m);if(!k)continue;(g[k]=g[k]||[]).push(m);}
3873
- const out=new Set();for(const k in g){const grp=g[k];if(grp.length<2)continue;grp.sort((a,b)=>rank(b)-rank(a));for(let i=1;i<grp.length;i++)out.add(grp[i].id);}return out;}
4023
+ // Elevation-aware coincident dedupe for the browser confidence panel — same footprint+wildcard-elevation logic as
4024
+ // redundantDups / the server, so the in-browser confidence report and the server score agree on stacked members.
4025
+ function _confDupIds(members){return new Set(dedupeFootprintIds(members,m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;}));}
3874
4026
  function _elevAssumed(m){if(m.role==='column')return !(m.col&&m.col.tosDef===false);const en=m.ends||[];if(!en.length)return true;return en.some(e=>e.tosDef!==false);}
3875
4027
  function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
3876
4028
  if(plf==null){F.push({key:'profile',label:'Profile',state:'fail',detail:(!m.profile||!m.profile.trim())?'no profile assigned':_isMf(m.profile)?('unresolved mark "'+m.profile+'" — not an AISC size'):('"'+m.profile+'" not in the AISC weight table')});return {band:'rfi',factors:F};}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.77.0",
3
+ "version": "0.79.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {