@floless/app 0.70.0 → 0.71.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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.70.0" : void 0,
53025
+ define: true ? "0.71.0" : void 0,
53026
53026
  pkgVersion: readPkgVersion()
53027
53027
  });
53028
53028
  }
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
53032
53032
  return "dev";
53033
53033
  }
53034
53034
  function appChannel() {
53035
- return resolveChannel({ isSea: isSea2(), define: true ? "0.70.0" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.71.0" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -54698,6 +54698,8 @@ function expandJoints(joints, memberGeo) {
54698
54698
  return;
54699
54699
  }
54700
54700
  for (const part of expandBasePlate(j, col)) {
54701
+ part.conn = j.id;
54702
+ part.connKind = j.kind;
54701
54703
  elements.push(part);
54702
54704
  usedGroups.add(part.group);
54703
54705
  }
@@ -54719,6 +54721,8 @@ function expandJoints(joints, memberGeo) {
54719
54721
  return;
54720
54722
  }
54721
54723
  for (const part of parts) {
54724
+ part.conn = j.id;
54725
+ part.connKind = j.kind;
54722
54726
  elements.push(part);
54723
54727
  if (part.kind !== "cut") usedGroups.add(part.group);
54724
54728
  }
@@ -526,6 +526,7 @@ function applyCopes(mesh, cuts) {
526
526
 
527
527
  function buildFromScene(sc) {
528
528
  clearRoot();
529
+ resetConnState(); // a fresh scene rebuilds selection from scratch — drop any stale connection envelope/context
529
530
  for (const mat of baseMat.values()) mat.dispose(); // shared per-profile materials from the prior build
530
531
  groupColor.clear(); baseMat.clear();
531
532
  sceneGroups = (sc.groups || []).map((g) => ({ key: g.key, label: g.label, color: g.color || '#94a3b8' }));
@@ -547,6 +548,7 @@ function buildFromScene(sc) {
547
548
  if (memberCuts && memberCuts.length) applyCopes(mesh, memberCuts); // notch a coped member end
548
549
  mesh.userData.id = el.id; mesh.userData.group = el.group; mesh.userData.profile = el.meta && el.meta.profile;
549
550
  mesh.userData.derived = !!(el.kind && el.kind !== 'box'); // connection parts: rendered, not member-editable
551
+ mesh.userData.conn = el.conn || null; mesh.userData.connKind = el.connKind || null; // Connection Component membership (Slice A) — the whole-select/drill handle
550
552
  root.add(mesh); meshById.set(el.id, mesh);
551
553
  box.expandByObject(mesh);
552
554
  }
@@ -1302,6 +1304,7 @@ function onKey(e) {
1302
1304
  if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
1303
1305
  if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
1304
1306
  if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
1307
+ if (e.key === 'Escape' && !dimMode3d && !cmActive() && ascendConn()) { e.preventDefault(); return; } // Esc ascends the connection drill: part → whole → nothing
1305
1308
  if ((e.key === ' ' && e.shiftKey) || ((e.key === 'z' || e.key === 'Z') && e.altKey)) { e.preventDefault(); frameSelection(); return; } // zoom-selected (Tekla Shift+Space / viewer Alt+Z)
1306
1309
  const k = e.key.toLowerCase();
1307
1310
  // Don't touch the dim tool while a member gesture (drag / box-select) owns the shared marker/readout —
@@ -1354,6 +1357,16 @@ function onDblClick(e) {
1354
1357
  const hits = raycaster.intersectObjects([...meshById.values()].filter((m) => m.visible), false); // incl. connection parts
1355
1358
  if (!hits.length) return; // empty space → no-op (Fit / Home fit-all; avoids an accidental camera teleport)
1356
1359
  const p = hits[0].point, mesh = hits[0].object;
1360
+ // Connection drill-down (Slice A): double-clicking a part of a connection we're NOT already inside ENTERS
1361
+ // that connection (selects the part under the cursor) and frames it. A part of the connection we're
1362
+ // already in, or a bare member, falls through to the classic zoom-to-part below (non-breaking).
1363
+ const dblConn = mesh.userData && mesh.userData.conn;
1364
+ if (dblConn && ctxConn !== dblConn) {
1365
+ enterConn(dblConn, mesh.userData.id);
1366
+ const cb = connBox(dblConn);
1367
+ if (!cb.isEmpty()) { const vDir = camera.position.clone().sub(controls.target).normalize(); fitCamera(cb, vDir.lengthSq() > 0.5 ? vDir : undefined); }
1368
+ return;
1369
+ }
1357
1370
  if (mesh.geometry && !mesh.geometry.boundingBox) mesh.geometry.computeBoundingBox();
1358
1371
  const s = mesh.geometry && mesh.geometry.boundingBox ? mesh.geometry.boundingBox.getSize(new THREE.Vector3()) : V(400, 400, 400);
1359
1372
  const sect = Math.max(40, Math.min(s.x, s.y, s.z)); // the part's smallest extent ≈ a section / plate scale
@@ -1378,6 +1391,7 @@ function setSelection(ids) {
1378
1391
  }
1379
1392
  applyDisplayMode(); // selection swapped the materials → re-apply wire/xray
1380
1393
  selIds = new Set(set);
1394
+ reconcileConnState(set); // any selection path (2D click, box-select, keyboard) must not leave a stale connection envelope/drill
1381
1395
  rebuildEndpoints(); // endpoint dots follow the selection (+ any hover)
1382
1396
  updateStatusChip();
1383
1397
  }
@@ -1817,14 +1831,19 @@ const CYCLE_TOL_PX = 8;
1817
1831
  function resetCycle() { cycleAnchor = null; cycleIds = []; cycleIdx = 0; }
1818
1832
  function clickSelect(cx, cy, ctrl) {
1819
1833
  let hits = []; try { hits = pickAllAt(cx, cy); } catch { hits = []; }
1820
- if (!hits.length) { resetCycle(); if (api && api.onSelect) api.onSelect(null, !!ctrl); return; }
1821
- if (ctrl) { resetCycle(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest
1834
+ if (!hits.length) { resetCycle(); clearConnSel(); return; } // empty → deselect (clears any connection too)
1835
+ if (ctrl) { resetCycle(); resetConnState(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest RAW part (leaves connection mode)
1822
1836
  const same = cycleAnchor && Math.hypot(cx - cycleAnchor[0], cy - cycleAnchor[1]) <= CYCLE_TOL_PX
1823
1837
  && cycleIds.length === hits.length && cycleIds.every((v, i) => v === hits[i]);
1824
1838
  if (same) cycleIdx = (cycleIdx + 1) % hits.length; else { cycleIds = hits; cycleIdx = 0; cycleAnchor = [cx, cy]; }
1825
- const pick = hits[cycleIdx], grp = boltGroupOf(pick); // a bolt → select the whole bolt ARRAY (a pattern, not one bolt)
1826
- if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); }
1827
- else if (api && api.onSelect) api.onSelect(pick, false);
1839
+ const pick = hits[cycleIdx], conn = connOf(pick);
1840
+ if (!conn) { resetConnState(); if (api && api.onSelect) api.onSelect(pick, false); return; } // a bare member → normal single select
1841
+ if (ctxConn === conn) { // drilled INTO this connection → clicks land on its parts (bolt array or a single part)
1842
+ const grp = boltGroupOf(pick);
1843
+ if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); } else if (api && api.onSelect) api.onSelect(pick, false);
1844
+ return;
1845
+ }
1846
+ selectWholeConn(conn); // at root (or over a different connection) → select the WHOLE connection
1828
1847
  }
1829
1848
  // A bolt/head/nut id → all bolt-group part ids of the same joint (the connection's bolt array); else just [id].
1830
1849
  function boltGroupOf(id) {
@@ -1834,6 +1853,63 @@ function boltGroupOf(id) {
1834
1853
  const ids = [...meshById.keys()].filter((k) => { const c = k.indexOf(':'); return c >= 0 && k.slice(0, c) === jid && /^(bolt|head|nut)/.test(k.slice(c + 1)); });
1835
1854
  return ids.length ? ids : [id];
1836
1855
  }
1856
+
1857
+ // ── Connection Components (Slice A): select/drill a whole connection (base-plate / shear-plate) as ONE
1858
+ // unit. `selConn` = the connection currently whole-selected at root; `ctxConn` = the connection we've
1859
+ // DRILLED INTO (double-click) so subsequent clicks land on its individual parts. Both derive from the
1860
+ // `conn` tag every ConnPart carries (buildFromScene stashes el.conn on userData). A bare member (no conn)
1861
+ // clears both. The host editor re-derives its breadcrumb + component inspector from the selection ids each
1862
+ // render() — no view→editor callback needed; reconcileConnState() (from setSelection) keeps this honest.
1863
+ let selConn = null, ctxConn = null;
1864
+ function connOf(id) { const m = id && meshById.get(id); return m && m.userData ? (m.userData.conn || null) : null; }
1865
+ function connChildIds(conn) { const out = []; for (const [id, m] of meshById) { if (m.userData && m.userData.conn === conn) out.push(id); } return out; } // every rendered part of this connection
1866
+ function connBox(conn) { const b = new THREE.Box3(); for (const m of meshById.values()) { if (m.userData && m.userData.conn === conn && m.visible) b.expandByObject(m); } return b; }
1867
+ // The dashed brand-blue envelope = the single "this is a group" cue for a whole-connection selection.
1868
+ let connEnvelope = null;
1869
+ function clearConnEnvelope() { if (connEnvelope) { if (overlayScene) overlayScene.remove(connEnvelope); connEnvelope.geometry.dispose(); connEnvelope.material.dispose(); connEnvelope = null; } }
1870
+ function renderConnEnvelope(conn) {
1871
+ clearConnEnvelope();
1872
+ if (!conn || !overlayScene) return;
1873
+ const b = connBox(conn); if (b.isEmpty()) return;
1874
+ b.expandByScalar(Math.max(6, b.getSize(new THREE.Vector3()).length() * 0.02)); // a little breathing room around the parts
1875
+ connEnvelope = new THREE.Box3Helper(b, new THREE.Color(SELECT_EMISSIVE)); // --brand
1876
+ connEnvelope.material.depthTest = false; connEnvelope.material.transparent = true; connEnvelope.material.opacity = 0.6; connEnvelope.renderOrder = 996;
1877
+ overlayScene.add(connEnvelope);
1878
+ }
1879
+ function resetConnState() { selConn = null; ctxConn = null; clearConnEnvelope(); } // internal reset, no callbacks
1880
+ // Select the WHOLE connection at root (single-click a part, or a breadcrumb click). Clears any drill context.
1881
+ function selectWholeConn(conn) {
1882
+ if (!conn || !connChildIds(conn).length) return clearConnSel();
1883
+ selConn = conn; ctxConn = null;
1884
+ renderConnEnvelope(conn);
1885
+ if (api && api.onSelectMany) api.onSelectMany(connChildIds(conn));
1886
+ }
1887
+ // Clear any connection selection/drill (back to bare Model root — deselects).
1888
+ function clearConnSel() { resetConnState(); if (api && api.onSelect) api.onSelect(null, false); }
1889
+ // Enter a connection (double-click) and select the part under the cursor — the drill-in step.
1890
+ function enterConn(conn, partId) {
1891
+ ctxConn = conn; selConn = conn; clearConnEnvelope(); // inside → the part-level highlight carries; no whole envelope
1892
+ const grp = boltGroupOf(partId);
1893
+ if (grp.length > 1) { if (api && api.onSelectMany) api.onSelectMany(grp); }
1894
+ else if (api && api.onSelect) api.onSelect(partId, false);
1895
+ }
1896
+ // Ascend one level: drilled part → whole connection → nothing. Returns true if it consumed the gesture.
1897
+ function ascendConn() {
1898
+ if (ctxConn) { selectWholeConn(ctxConn); return true; } // part → whole
1899
+ if (selConn) { clearConnSel(); return true; } // whole → nothing
1900
+ return false;
1901
+ }
1902
+ // Keep the connection state honest against ANY selection change — not just the 3D click paths but a 2D
1903
+ // member click, box-select, keyboard, or Delete that route through setSelection(). Whole: the full child
1904
+ // set must still be selected, else drop the stale envelope; drilled: the selection must stay WITHIN the
1905
+ // connection, else exit the drill. Callback-free (resetConnState) so it can't recurse through render().
1906
+ function reconcileConnState(set) {
1907
+ if (!selConn) return;
1908
+ const kids = connChildIds(selConn);
1909
+ if (ctxConn) { if (!set.size || ![...set].every((id) => kids.includes(id))) resetConnState(); } // drilled: any pick outside the connection → exit
1910
+ else if (!kids.length || !kids.every((k) => set.has(k))) resetConnState(); // whole: must remain the full set, else drop the envelope
1911
+ }
1912
+ function connContext() { return { selConn, ctxConn }; } // test/editor read
1837
1913
  // The (currently shown) end-node dot nearest the cursor within a screen tolerance → { id, end } or
1838
1914
  // null. Screen-space (not a raycast) so the small dots are easy to grab at any zoom. Dots win over
1839
1915
  // the member body, letting you grab one end to stretch it.
@@ -2575,7 +2651,7 @@ function dispose() {
2575
2651
  gridTexCache.clear();
2576
2652
  clearRoot();
2577
2653
  if (workAreaHelper) { if (overlayScene) overlayScene.remove(workAreaHelper); workAreaHelper.geometry.dispose(); workAreaHelper.material.dispose(); workAreaHelper = null; }
2578
- clearClipGizmo(); setClipPreview(null); overlayScene = null;
2654
+ clearConnEnvelope(); clearClipGizmo(); setClipPreview(null); overlayScene = null;
2579
2655
  clips = []; workArea = null; clipMode = null; selectedClipIds.clear(); clipBoxDraft = null; // clips live on the renderer; drop them with the renderer
2580
2656
  if (renderer) renderer.dispose();
2581
2657
  renderer = scene = camera = perspCam = orthoCam = controls = root = api = canvasEl = ro = null; built = false;
@@ -2637,6 +2713,7 @@ window.Steel3DView = {
2637
2713
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
2638
2714
  setRefLine, refLine: () => refLineOn,
2639
2715
  setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
2716
+ selectWholeConn, clearConnSel, ascendConn, connContext, connEnvelopeOn: () => !!connEnvelope, // Connection Components (Slice A): whole-select / drill / ascend + test probes
2640
2717
  setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2641
2718
  syncMemberLabels, // editor calls after a mark/id edit to refresh labels
2642
2719
  setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
@@ -33,6 +33,13 @@
33
33
  .detf input{width:100%}
34
34
  #detOpacity{accent-color:var(--brand);flex:1;min-width:0}
35
35
  #zoombar #zPct{min-width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums}
36
+ /* Connection Component breadcrumb (Slice A) — a floating chip over the 3D canvas, same recipe as #zoombar. */
37
+ #connCrumb{position:absolute;left:50%;top:48px;transform:translateX(-50%);display:none;align-items:center;gap:1px;max-width:min(72%,560px);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px 10px;box-shadow:0 4px 14px rgba(0,0,0,.45);z-index:58;font-size:12px;white-space:nowrap;overflow:hidden} /* below #m3dBar (top:12,h~29,z:59); z:58 keeps it clickable above the dim-label chips (57) */
38
+ #connCrumb .seg{color:var(--mut);cursor:pointer;padding:1px 4px;border-radius:4px;background:none;border:0;font:inherit;max-width:260px;overflow:hidden;text-overflow:ellipsis}
39
+ #connCrumb .seg:hover{color:var(--text);text-decoration:underline}
40
+ #connCrumb .seg.cur{color:var(--brand);font-weight:600;cursor:default;text-decoration:none}
41
+ #connCrumb .sep{color:var(--mut);opacity:.7;padding:0 2px}
42
+ .pilllink{background:none;border:0;color:var(--brand);cursor:pointer;font:inherit;padding:0;text-decoration:underline}
36
43
  aside{width:240px;flex:none;background:var(--panel);border-left:1px solid var(--line);padding:12px;overflow:auto}
37
44
  aside h3{margin:0 0 8px;font-size:12px;color:var(--mut);text-transform:uppercase;letter-spacing:.05em}
38
45
  select,input{background:#0f172a;color:var(--text);border:1px solid #475569;border-radius:6px;padding:6px;width:100%;font:13px system-ui}
@@ -496,6 +503,7 @@
496
503
  <div id=stagewrap>
497
504
  <div id=stage><svg id=svg></svg></div>
498
505
  <canvas id=stage3d tabindex=0 aria-label="3D model"></canvas>
506
+ <div id=connCrumb role=navigation aria-label="Connection breadcrumb"></div>
499
507
  <div id=m3dBar role=group aria-label="3D view controls">
500
508
  <!-- Camera projection — dropdown (like Plane / Work area); the button shows the current mode -->
501
509
  <div class=m3dwrap>
@@ -1377,6 +1385,7 @@ function render(){
1377
1385
  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)
1378
1386
  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();
1379
1387
  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)
1388
+ try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
1380
1389
  syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
1381
1390
  }
1382
1391
  function updDup(){const n=redundantDups().length;
@@ -1417,6 +1426,54 @@ function stats(){
1417
1426
  // "Varies" placeholders + the indeterminate "default" checkbox in the multi-edit panel. get() must return a primitive.
1418
1427
  const VARIES=Symbol('varies');
1419
1428
  function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
1429
+ // ── Connection Components (Slice A). Derive the current connection-selection state from selIds + the
1430
+ // resolved scene parts (partsById carries each part's `conn` tag). Returns {conn,kind,main,joint,childIds,
1431
+ // whole,mode} or null when the selection isn't one connection's parts. `whole` = every selectable part of
1432
+ // the connection is selected (copes are subtractive → not rendered/selectable, so excluded). Robust: no
1433
+ // dependence on cross-view callback timing — every render() re-derives it.
1434
+ function connSelInfo(){
1435
+ const ids=[...selIds]; if(!ids.length) return null;
1436
+ let conn=null;
1437
+ for(const id of ids){ const el=(partsById||{})[id]; const c=el&&el.conn; if(!c) return null; if(conn==null) conn=c; else if(conn!==c) return null; }
1438
+ if(!conn) return null;
1439
+ const j=(C.joints||[]).find(x=>x&&x.id===conn); if(!j) return null;
1440
+ const childIds=Object.keys(partsById||{}).filter(id=>{const el=partsById[id];return el&&el.conn===conn&&el.kind!=='cut';});
1441
+ const whole=childIds.length>0&&childIds.every(id=>selIds.has(id));
1442
+ return {conn,kind:j.kind,main:j.main,joint:j,childIds,whole,mode:whole?'whole':'part'};
1443
+ }
1444
+ // The floating breadcrumb over the 3D canvas: Model ▸ <Connection> [▸ <Part>]. Segments jump levels via the
1445
+ // 3D view's own ascend/whole-select so the canvas selection + envelope stay in lockstep. 3D-only; hidden at root.
1446
+ function updateConnCrumb(){
1447
+ const el=document.getElementById('connCrumb'); if(!el) return;
1448
+ const cs=view3d?connSelInfo():null;
1449
+ if(!cs){ el.style.display='none'; el.innerHTML=''; return; }
1450
+ const name=(cs.kind==='base-plate'?'Base plate':cs.kind==='shear-plate'?'Shear plate':'Connection')+' · '+cs.main;
1451
+ let html='<button class=seg data-lvl=root data-tip="Back to the model (deselect)">Model</button><span class=sep>▸</span>';
1452
+ if(cs.whole){ html+='<span class="seg cur">'+esc(name)+'</span>'; }
1453
+ else{
1454
+ html+='<button class=seg data-lvl=whole data-tip="Select the whole connection">'+esc(name)+'</button><span class=sep>▸</span>';
1455
+ const partId=[...selIds].find(id=>/:bolt\d+$/.test(id))||[...selIds][0];
1456
+ const pel=(partsById||{})[partId]; const plbl=(pel&&pel.meta&&pel.meta.label)||'Part';
1457
+ html+='<span class="seg cur">'+esc(plbl)+'</span>';
1458
+ }
1459
+ el.innerHTML=html; el.style.display='flex';
1460
+ {const b=el.querySelector('[data-lvl=root]'); if(b)b.onclick=()=>{ if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel(); else{selIds=new Set();render();} };}
1461
+ {const b=el.querySelector('[data-lvl=whole]'); if(b)b.onclick=()=>{ if(window.Steel3DView&&window.Steel3DView.selectWholeConn)window.Steel3DView.selectWholeConn(cs.conn); };}
1462
+ }
1463
+ // Route a "modify this connection" ask through the Request relay (intent+target). A recipe connection's
1464
+ // geometry is member-derived, so move/replace/adjust go to the terminal AI (the UI relays intent) — unlike
1465
+ // Delete, which is a direct, deterministic contract edit.
1466
+ async function connModifyRequest(j){
1467
+ if(!j) return;
1468
+ try{await window.flushContract();}catch(_){}
1469
+ try{persist();}catch(_){}
1470
+ const kindName=j.kind==='base-plate'?'base plate':j.kind==='shear-plate'?'shear plate':'connection';
1471
+ const instruction='Modify the '+kindName+' connection "'+j.id+'" on member '+j.main+' (sheet '+((P&&P.sheet)||'?')+') — adjust, replace or move it per my request.';
1472
+ try{const res=await fetch('/api/contract-request',{method:'POST',headers:{'content-type':'application/json'},
1473
+ body:JSON.stringify({appId:APP_ID,instruction,intent:'modify',target:{sheet:(P&&P.sheet)||undefined,ids:[j.id,j.main]}})});
1474
+ toast(res.ok?'Change queued for your terminal AI session':'Could not queue the request');
1475
+ }catch(_){toast('Could not queue the request');}
1476
+ }
1420
1477
  function panel(){
1421
1478
  const p=document.getElementById('panel');
1422
1479
  if(!selDimIds.size||!dimsVisible)dimSplitMode=false;document.body.classList.toggle('dimsplit',dimSplitMode); // split mode is meaningless without a (visible) dim selected — also disarms when dims are hidden
@@ -1499,6 +1556,38 @@ function panel(){
1499
1556
  {const rm=document.getElementById('detRemove');if(rm)rm.onclick=()=>edit(()=>{C.detail_placements=(C.detail_placements||[]).filter(x=>x&&x.id!==detId);selIds.clear();});}
1500
1557
  return;
1501
1558
  }}
1559
+ // A WHOLE connection selected (Slice A) — the Component inspector: type badge + editability chip +
1560
+ // on-member link + part count + a read-only param summary, then Delete (direct contract edit) /
1561
+ // Modify (relay) / Edit-on-member. Precedes the single-part branch below (which handles the DRILLED case).
1562
+ {const cs=connSelInfo();
1563
+ if(cs&&cs.whole){
1564
+ const j=cs.joint,isBP=j.kind==='base-plate',pp=j.params||{};
1565
+ const plate=(partsById||{})[cs.conn+':plate']||null;
1566
+ const dim=(n)=>(n==null?'<span style="color:var(--mut)">auto</span>':esc(fmtFtIn(Number(n)/25.4)));
1567
+ const kv=(l,val)=>`<div style="display:flex;justify-content:space-between;gap:8px;font-size:12px;margin:3px 0"><span style="color:var(--mut)">${esc(l)}</span><span style="font-variant-numeric:tabular-nums">${val}</span></div>`;
1568
+ const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${esc(t)}</span><hr></div>`;
1569
+ const sz=plate?dim(plate.width)+' × '+dim(plate.depth):'<span style="color:var(--mut)">auto</span>';
1570
+ let body='';
1571
+ if(isBP)body=sec('Plate')+kv('Size',sz)+kv('Thickness',plate?dim(plate.thickness):dim(pp.thickness))+sec('Anchors')+kv('Grid (cols × rows)',esc(`${pp.boltCols||2} × ${pp.boltRows||2}`))+kv('Diameter',pp.boltDia?dim(pp.boltDia):dim(24));
1572
+ else body=sec('Plate')+kv('Size',sz)+kv('Thickness',plate?dim(plate.thickness):dim(pp.plateThickness))+sec('Bolts')+kv('Grid (cols × rows)',esc(`${pp.boltCols||1} × ${pp.boltRows||3}`))+kv('Diameter',pp.boltDia?dim(pp.boltDia):dim(20))+sec('Weld')+kv('Leg',pp.weldLeg?dim(pp.weldLeg):dim(6));
1573
+ p.innerHTML=`<span class=badge>${isBP?'Base plate':'Shear plate'}</span>
1574
+ <div class=row style="margin:0 0 6px"><span class=chip style="border-color:var(--brand);color:#bfdbfe">Parametric — editable</span></div>
1575
+ <div class="row hint" style="margin:0 0 2px">On <button class=pilllink id=cmpMember data-tip="Select ${esc(j.main)}">${esc(j.main)}</button> · ${cs.childIds.length} parts</div>
1576
+ <div class="row hint" style="margin:0 0 6px;font-size:11px">Double-click to enter and pick a part · <b>Esc</b> steps back.</div>
1577
+ ${body}
1578
+ <div class=divrow><hr></div>
1579
+ <div class="row f" style="gap:6px;flex-wrap:wrap">
1580
+ <button class=ghostw id=cmpEdit data-tip="Edit this connection's parameters on ${esc(j.main)}">✎ Edit parameters on ${esc(j.main)} →</button>
1581
+ <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
1582
+ <button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
1583
+ </div>`;
1584
+ const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1585
+ {const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
1586
+ {const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
1587
+ {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1588
+ {const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
1589
+ return;
1590
+ }}
1502
1591
  // A derived CONNECTION PART selected in 3D (plate / bolt / weld / cope / stiffener) — show its details
1503
1592
  // read-only (parts have no own state; their params live on the parent joint) + a jump to that member.
1504
1593
  {const selList=[...selIds];
@@ -1523,7 +1612,7 @@ function panel(){
1523
1612
  const sec=t=>`<div class=divrow><hr><span class=sect style="margin:0">${t}</span><hr></div>`;
1524
1613
  let body='';
1525
1614
  if(pk==='plate'&&j.kind==='shear-plate')body=sec('Plate')+kv('Width',dim(el&&el.width))+kv('Height',dim(el&&el.depth))+kv('Thickness',dim(el&&el.thickness))+kv('Weld leg',v('weldLeg','mm'))+kv('Clearance',v('clearance','mm'));
1526
- else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)',`${pp.boltCols||1} × ${pp.boltRows||3}`)+kv('Diameter',v('boltDia','mm'))+kv('Grade',pp.boltGrade?esc(pp.boltGrade):'A325'+dft)+kv('Pitch',v('boltPitch','mm'))+kv('Length','<span style="color:var(--mut)">auto (from grip)</span>');
1615
+ else if(pk==='bolt')body=sec('Bolts')+kv('Grid (cols × rows)',esc(`${pp.boltCols||1} × ${pp.boltRows||3}`))+kv('Diameter',v('boltDia','mm'))+kv('Grade',pp.boltGrade?esc(pp.boltGrade):'A325'+dft)+kv('Pitch',v('boltPitch','mm'))+kv('Length','<span style="color:var(--mut)">auto (from grip)</span>');
1527
1616
  else if(pk==='weld')body=sec('Weld')+kv('Leg',v('weldLeg','mm'));
1528
1617
  else if(pk==='cope')body=sec('Cope')+kv('Length',dim(el&&el.width))+kv('Depth',dim(el&&el.depth))+kv('Re-entrant radius',v('copeRadius','mm'));
1529
1618
  else if(pk==='stiff')body=sec('Stiffener')+`<div class=hint style="margin:0">Opposite-side web stiffener on the support.</div>`;
@@ -1538,8 +1627,9 @@ function panel(){
1538
1627
  ${lbl?`<div class="row" style="margin:3px 0 0;font-size:12px;color:var(--brand);font-variant-numeric:tabular-nums">${esc(lbl)}</div>`:''}
1539
1628
  ${body}
1540
1629
  <div class=divrow><hr></div>
1541
- <div class="row f"><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1542
- const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1630
+ <div class="row f" style="gap:6px;flex-wrap:wrap"><button class=ghostw id=partBack data-tip="Back to the whole connection (Esc)">◂ Connection</button><button class=ghostw id=partEdit data-tip="Select the parent member to edit this connection">✎ Edit on ${esc(j.main)} →</button></div>`;
1631
+ {const bb=document.getElementById('partBack');if(bb)bb.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.ascendConn)window.Steel3DView.ascendConn();};}
1632
+ const eb=document.getElementById('partEdit');if(eb)eb.onclick=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1543
1633
  return;
1544
1634
  }}
1545
1635
  const arr=selArr();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.70.0",
3
+ "version": "0.71.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": {