@floless/app 0.78.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.78.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.78.0" : void 0 });
53106
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.79.0" : void 0 });
53107
53107
  }
53108
53108
 
53109
53109
  // workflow-update.ts
@@ -60191,23 +60191,38 @@ function weightOf(contract, profile) {
60191
60191
  var isMfMark = (p) => !!p && /(^|[^A-Z])MF($|[^A-Z])/i.test(p);
60192
60192
  var len = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
60193
60193
  function redundantDupIds(members) {
60194
- const key = (m) => {
60194
+ const foot = (m) => {
60195
60195
  if (!m.wp || m.wp.length < 2) return null;
60196
60196
  const r = (p) => `${Math.round(p[0] / 3)},${Math.round(p[1] / 3)}`;
60197
60197
  const a = r(m.wp[0]), b = r(m.wp[1]);
60198
60198
  return a < b ? `${a}|${b}` : `${b}|${a}`;
60199
60199
  };
60200
- const groups = /* @__PURE__ */ new Map();
60200
+ const byFoot = /* @__PURE__ */ new Map();
60201
60201
  for (const m of members) {
60202
- const k = key(m);
60202
+ const k = foot(m);
60203
60203
  if (!k) continue;
60204
- (groups.get(k) ?? groups.set(k, []).get(k)).push(m);
60204
+ (byFoot.get(k) ?? byFoot.set(k, []).get(k)).push(m);
60205
60205
  }
60206
60206
  const out = /* @__PURE__ */ new Set();
60207
- for (const grp of groups.values()) {
60208
- if (grp.length < 2) continue;
60209
- 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));
60210
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);
60211
60226
  }
60212
60227
  return out;
60213
60228
  }
@@ -60217,6 +60232,11 @@ function dupRank(m) {
60217
60232
  if (m.profile && m.profile.trim() !== "") s += 1;
60218
60233
  return s;
60219
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
+ }
60220
60240
  function elevationAssumed(m) {
60221
60241
  if (m.role === "column") return m.col?.tosDef !== false;
60222
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
  }
@@ -3137,9 +3173,10 @@ async function detailRequest(intent,place,note){
3137
3173
  body:JSON.stringify({appId:APP_ID,project:PROJECT||undefined,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
3138
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');
3139
3175
  }catch(_){toast('Could not queue the request');}}
3140
- // Build the 3D legend overlay from the live scene groups (per profile). Single-click hide/show,
3141
- // double-click isolate mirrors the AWARE viewer-3d legend (deferred click so dbl-click can cancel).
3142
- 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;
3143
3180
  // Explorer-style multi-isolate on dbl-click of a legend group row: plain = isolate just this group; Ctrl = toggle
3144
3181
  // it in/out of the isolated set; Shift = the contiguous range from the anchor row to this one (in displayed order).
3145
3182
  function legendIsolate(k,e){
@@ -3196,6 +3233,10 @@ function profileKeyOf(m){return (m&&m.profile||'').trim().toUpperCase();} // ma
3196
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)
3197
3234
  let legendMode=(localStorage.getItem('floless.legendMode')==='type')?'type':'profile';
3198
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));}
3199
3240
  function saveLegendPrefs(){try{localStorage.setItem('floless.legendMode',legendMode);localStorage.setItem('floless.legendCollapsed',JSON.stringify([...collapsedCats]));}catch{}}
3200
3241
  // Drag a typed member row onto another type category to retype it. Pointer Events (NOT the HTML5 drag API,
3201
3242
  // which paints a white browser ghost on Windows). A 6px threshold tells a drag from the row's click(hide) /
@@ -3232,21 +3273,27 @@ const DIM_LABEL=Object.fromEntries(DIM_CATS);
3232
3273
  // edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
3233
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']}];
3234
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
3235
3277
  const groups=window.Steel3DView.getGroups();host.replaceChildren();
3236
3278
  if(!groups.length){host.style.display='none';return;}
3237
- 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);
3238
3280
  const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
3239
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
3240
- 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';
3241
3283
  row.append(sw,document.createTextNode(g.label));
3242
- row.addEventListener('click',()=>{if(row._dragging)return;clearTimeout(leg3dClickT);leg3dClickT=setTimeout(()=>{window.Steel3DView.toggleGroup(g.key);refresh3DLegend();},220);});
3243
- 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
3244
3291
  if(draggable)wireRowDrag(row,g);
3245
3292
  host.appendChild(row);return row;};
3246
3293
  // A collapsible legend category: chevron (collapse) + tri-state master on/off (■/□/◪) + label + count.
3247
3294
  // getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
3248
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;
3249
- 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?'':''});
3250
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';
3251
3298
  const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
3252
3299
  const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
@@ -3266,12 +3313,28 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3266
3313
  mode.appendChild(b);}
3267
3314
  host.insertBefore(mode,host.firstChild);
3268
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
+ }
3269
3332
  if(legendMode==='type'&&members.length){ // group the profile-rows under their member-type categories
3270
3333
  const byCat=new Map(MEMBER_TYPES.map(t=>[t.k,[]]));
3271
3334
  for(const g of members){(byCat.get(categoryOfProfile(g.key))||byCat.get('beam')).push(g);}
3272
3335
  for(const {k,label} of MEMBER_TYPES){const gs=byCat.get(k)||[],keys=gs.map(g=>g.key);
3273
3336
  host.appendChild(buildCatHeader(k,label,gs.length,{empty:!gs.length,getState:()=>grpState(keys),onToggle:()=>grpToggle(keys),toggleTitle:'Show / hide all '+label.toLowerCase()+'s'}));
3274
- 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);}
3275
3338
  } else for(const g of members)addRow(g);
3276
3339
  if(conns.length){ // group connection PARTS under their joint (Phase 2): each part-kind a row, hidden per-id
3277
3340
  if(members.length){host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));}
@@ -3290,10 +3353,11 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3290
3353
  getState:()=>{const h=hiddenSet(),n=allIds.filter(id=>h.has(id)).length;return n===0?'on':(n===allIds.length?'off':'mixed');},
3291
3354
  onToggle:()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(allIds,allIds.every(id=>!h.has(id)));}, // all-on → hide all; else show all
3292
3355
  toggleTitle:'Show / hide the '+label.toLowerCase()+' connection'}));
3293
- 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'};
3294
3357
  const row=document.createElement('div');row.className='lrow typed';row.dataset.connkey=ck+':'+grp;row._ids=ids;
3295
- const sw=document.createElement('span');sw.className='lsw';sw.style.background=m.color;row.append(sw,document.createTextNode(m.label));
3296
- 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)
3297
3361
  host.appendChild(row);}
3298
3362
  }
3299
3363
  }
@@ -3303,11 +3367,12 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3303
3367
  // isolate), and a gentler off-state — off is a normal resting choice here, not a hidden-part warning.
3304
3368
  const ov=C.dim_overlays||{};
3305
3369
  const addDimRow=(cat,label,sub)=>{const row=document.createElement('div');row.className='lrow dim typed'+(sub?' sub':'');row.dataset.dim=cat;
3306
- 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));
3307
3371
  row.append(sw,document.createTextNode(label));
3308
3372
  row.classList.toggle('dimoff',ov[cat]===false);
3309
- // toggle the overlay; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
3310
- 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());
3311
3376
  host.appendChild(row);};
3312
3377
  if(members.length||conns.length)host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
3313
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');};
@@ -3315,7 +3380,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3315
3380
  const dcState=cats=>{const on=cats.filter(k=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===cats.length?'on':'mixed');};
3316
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();};
3317
3382
  host.appendChild(buildCatHeader('dims','Dimensions',DIM_CATS.length,{getState:dimState,onToggle:dimToggle,toggleTitle:'Show / hide all dimension overlays'}));
3318
- 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'}));
3319
3384
  for(const dc of DIM_CONN){const ck='dims-'+dc.ct; // middle category: overlays grouped by connection
3320
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'}));
3321
3386
  if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
@@ -3326,10 +3391,10 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3326
3391
  if(typeof P!=='undefined'&&P&&P.grid){
3327
3392
  host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
3328
3393
  const grow=document.createElement('div');grow.className='lrow dim';grow.dataset.tip='Show / hide the structural grid (2D + 3D)';
3329
- 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()));
3330
3395
  grow.append(gsw,document.createTextNode('Grid lines'));
3331
3396
  grow.classList.toggle('dimoff',!gridOn());
3332
- grow.addEventListener('click',()=>gridSetVisible(!gridOn()));
3397
+ gsw.addEventListener('click',e=>{e.stopPropagation();gridSetVisible(!gridOn());});gsw.addEventListener('dblclick',e=>e.stopPropagation());
3333
3398
  host.appendChild(grow);
3334
3399
  }
3335
3400
  // CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
@@ -3343,22 +3408,22 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
3343
3408
  if(collapsedCats.has('clip')){/* collapsed → no rows */}
3344
3409
  else if(!clips.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'(no clips)'}));}
3345
3410
  else for(const c of clips){
3346
- // Three separate zones (no fighting): swatch/label = SELECT (reveals its 3D drag handles), On/Off pill = ENABLE, × = DELETE.
3347
- 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
3348
- 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
3349
3414
  const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.dataset.tip='Click to select · double-click to rename';
3350
- const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.dataset.tip='Enable / disable this clip';
3351
3415
  const x=document.createElement('span');x.className='lx';x.textContent='×';x.dataset.tip='Delete this clip';
3352
- row.append(sw,lab,tog,x);
3353
- 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());
3354
3419
  let clipClickT=null;
3355
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
3356
3421
  lab.addEventListener('dblclick',e=>{e.stopPropagation();e.preventDefault();clearTimeout(clipClickT);startClipRename(c,lab);});
3357
- tog.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);});
3358
3422
  x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
3359
3423
  host.appendChild(row);
3360
3424
  }
3361
- 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();}
3362
3427
  // The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
3363
3428
  // "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
3364
3429
  function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
@@ -3380,10 +3445,81 @@ function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.
3380
3445
  function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
3381
3446
  const state=hdr._getState();tog.dataset.state=state;tog.textContent=state==='on'?'■':(state==='off'?'□':'◪');}
3382
3447
  function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden),solo=new Set(st.solo);
3383
- 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)
3384
3449
  const ch=new Set(window.Steel3DView.connHiddenIds?window.Steel3DView.connHiddenIds():[]); // per-part connection hide
3385
- 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)));});
3386
- 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);}
3387
3523
  let bar3dWired=false;
3388
3524
  function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
3389
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.
@@ -3884,10 +4020,9 @@ function _wt(profile){if(!profile)return null;
3884
4020
  if(WT){const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());if(h&&h[1]!=null)return h[1];}
3885
4021
  return _nominalPlf(profile);} // fall back to the lb/ft encoded in a standard designation
3886
4022
  const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
3887
- 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;};
3888
- const rank=m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;};
3889
- for(const m of members){const k=key(m);if(!k)continue;(g[k]=g[k]||[]).push(m);}
3890
- 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;}));}
3891
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);}
3892
4027
  function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
3893
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.78.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": {