@floless/app 0.72.3 → 0.73.1

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.72.3" : void 0,
53025
+ define: true ? "0.73.1" : 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.72.3" : void 0 });
53035
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.73.1" : void 0 });
53036
53036
  }
53037
53037
 
53038
53038
  // workflow-update.ts
@@ -1862,7 +1862,7 @@ const CYCLE_TOL_PX = 8;
1862
1862
  function resetCycle() { cycleAnchor = null; cycleIds = []; cycleIdx = 0; }
1863
1863
  function clickSelect(cx, cy, ctrl) {
1864
1864
  let hits = []; try { hits = pickAllAt(cx, cy); } catch { hits = []; }
1865
- if (!hits.length) { resetCycle(); clearConnSel(); return; } // empty → deselect (clears any connection too)
1865
+ if (!hits.length) { resetCycle(); if (!ctrl) clearConnSel(); return; } // empty → deselect (clears any connection too); Ctrl/Shift+empty-click preserves the selection (parity with 2D + the additive intent — a near-miss shouldn't wipe it)
1866
1866
  if (ctrl) { resetCycle(); resetConnState(); if (api && api.onSelect) api.onSelect(hits[0], true); return; } // additive toggles the nearest RAW part (leaves connection mode)
1867
1867
  const same = cycleAnchor && Math.hypot(cx - cycleAnchor[0], cy - cycleAnchor[1]) <= CYCLE_TOL_PX
1868
1868
  && cycleIds.length === hits.length && cycleIds.every((v, i) => v === hits[i]);
@@ -2356,9 +2356,9 @@ function onDown(e) {
2356
2356
  const geo = m ? memberGeometry(m, ppf, dtos) : null;
2357
2357
  const mesh = meshById.get(id);
2358
2358
  const planeZ = geo ? (geo.line[0][2] + geo.line[1][2]) / 2 : 0;
2359
- const ctrl = e.ctrlKey || e.metaKey;
2359
+ const ctrl = e.ctrlKey || e.metaKey, add = ctrl || e.shiftKey; // ctrl gates the copy gesture; ctrl OR shift toggles selection on a click (Tekla)
2360
2360
  pending = {
2361
- id, ctrl, ppf, planeZ,
2361
+ id, ctrl, add, ppf, planeZ,
2362
2362
  copy: ctrl, copyIds: ctrl ? (selIds.has(id) ? [...selIds] : [id]) : null, // Ctrl+drag copies: the selection if this member is in it, else just it
2363
2363
  grab: geo ? rayToPlane(e.clientX, e.clientY, planeZ) : null,
2364
2364
  origMm: geo ? geo.line.map((p) => [p[0], p[1], p[2]]) : null,
@@ -2470,24 +2470,39 @@ function onBoxMove(e) {
2470
2470
  const x = Math.min(e.clientX, boxSel.x), y = Math.min(e.clientY, boxSel.y);
2471
2471
  rubber.style.left = x + 'px'; rubber.style.top = y + 'px';
2472
2472
  rubber.style.width = Math.abs(e.clientX - boxSel.x) + 'px'; rubber.style.height = Math.abs(e.clientY - boxSel.y) + 'px';
2473
+ rubber.style.borderStyle = (e.clientX >= boxSel.x) ? 'solid' : 'dashed'; // Tekla affordance: L→R = window (solid), R→L = crossing (dashed)
2473
2474
  rubber.style.display = 'block';
2474
2475
  }
2475
- // every VISIBLE member whose centre projects inside the drag rectangle (centre-point hit-test)
2476
- function membersInRect(x0, y0, x1, y1) {
2476
+ // --- Tekla-style area select: point/segment vs screen rect [x0,y0,x1,y1]. window = both ends enclosed
2477
+ // (L→R drag); crossing = the member's screen footprint touches the rect (R→L drag). Same math the 2D marquee uses. ---
2478
+ function _inRect(p, r) { return p[0] >= r[0] && p[0] <= r[2] && p[1] >= r[1] && p[1] <= r[3]; }
2479
+ function _ccw(a, b, c) { return (c[1] - a[1]) * (b[0] - a[0]) - (b[1] - a[1]) * (c[0] - a[0]); }
2480
+ function _segSeg(a, b, c, d) { return (_ccw(a, c, d) > 0) !== (_ccw(b, c, d) > 0) && (_ccw(a, b, c) > 0) !== (_ccw(a, b, d) > 0); }
2481
+ function _rectHit(p0, p1, r) { if (_inRect(p0, r) || _inRect(p1, r)) return true; const c = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]; for (let i = 0; i < 4; i++) if (_segSeg(p0, p1, c[i], c[(i + 1) % 4])) return true; return false; }
2482
+ // every VISIBLE member whose projected centreline meets the drag rect — window: both ends inside · crossing: touches
2483
+ function membersInRect(x0, y0, x1, y1, windowMode) {
2477
2484
  const rect = canvasEl.getBoundingClientRect();
2478
- const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
2485
+ const r = [Math.min(x0, x1), Math.min(y0, y1), Math.max(x0, x1), Math.max(y0, y1)];
2486
+ const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
2487
+ const memById = new Map(members().map((m) => [m.id, m]));
2488
+ const toScreen = (p) => { const w = new THREE.Vector3(p[0], p[1], p[2]).project(camera); return { s: [rect.left + (w.x * 0.5 + 0.5) * rect.width, rect.top + (-w.y * 0.5 + 0.5) * rect.height], behind: w.z > 1 }; };
2479
2489
  const out = [];
2480
2490
  for (const [id, m] of meshById) {
2481
2491
  if (!m.visible || m.userData.derived) continue; // derived connection parts aren't member-selectable (mirrors pickAt; dbl-click-zoom deliberately includes them)
2482
- const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
2483
- const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
2484
- if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(id);
2492
+ const mem = memById.get(id); if (!mem) continue;
2493
+ let a, b; try { const g = memberGeometry(mem, ppf, dtos); a = toScreen(g.line[0]); b = toScreen(g.line[1]); } catch { continue; }
2494
+ if (a.behind || b.behind) { // an endpoint is behind the camera the projected segment is unreliable; fall back to the member-centre test (old behaviour, no regression for members straddling the camera plane)
2495
+ const w = m.getWorldPosition(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
2496
+ if (_inRect([rect.left + (w.x * 0.5 + 0.5) * rect.width, rect.top + (-w.y * 0.5 + 0.5) * rect.height], r)) out.push(id);
2497
+ continue;
2498
+ }
2499
+ if (windowMode ? (_inRect(a.s, r) && _inRect(b.s, r)) : _rectHit(a.s, b.s, r)) out.push(id);
2485
2500
  }
2486
2501
  return out;
2487
2502
  }
2488
- // Connection Components whose CENTRE falls inside a marquee rect (mirrors membersInRect's member-centre
2489
- // test, applied to each connection's bounding box) so area-select picks up connections, not just members.
2490
- function connsInRect(x0, y0, x1, y1) {
2503
+ // Connection Components whose screen footprint (projected bbox) meets a marquee rect so area-select picks
2504
+ // up connections, not just members. window: the whole footprint enclosed · crossing: it overlaps the rect.
2505
+ function connsInRect(x0, y0, x1, y1, windowMode) {
2491
2506
  const rect = canvasEl.getBoundingClientRect();
2492
2507
  const lo = { x: Math.min(x0, x1), y: Math.min(y0, y1) }, hi = { x: Math.max(x0, x1), y: Math.max(y0, y1) };
2493
2508
  const conns = new Set();
@@ -2495,9 +2510,16 @@ function connsInRect(x0, y0, x1, y1) {
2495
2510
  const out = [];
2496
2511
  for (const conn of conns) {
2497
2512
  const b = connBox(conn); if (b.isEmpty()) continue;
2498
- const w = b.getCenter(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
2499
- const sx = rect.left + (w.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
2500
- if (sx >= lo.x && sx <= hi.x && sy >= lo.y && sy <= hi.y) out.push(conn);
2513
+ let sx0 = Infinity, sy0 = Infinity, sx1 = -Infinity, sy1 = -Infinity, behind = false;
2514
+ for (let i = 0; i < 8; i++) { const v = new THREE.Vector3(i & 1 ? b.max.x : b.min.x, i & 2 ? b.max.y : b.min.y, i & 4 ? b.max.z : b.min.z).project(camera); if (v.z > 1) { behind = true; break; } const sx = rect.left + (v.x * 0.5 + 0.5) * rect.width, sy = rect.top + (-v.y * 0.5 + 0.5) * rect.height; sx0 = Math.min(sx0, sx); sy0 = Math.min(sy0, sy); sx1 = Math.max(sx1, sx); sy1 = Math.max(sy1, sy); }
2515
+ if (behind) { // a bbox corner is behind the camera the screen AABB is unreliable; fall back to the connection-centre test (old behaviour)
2516
+ const w = b.getCenter(new THREE.Vector3()).project(camera); if (w.z > 1) continue;
2517
+ const cx = rect.left + (w.x * 0.5 + 0.5) * rect.width, cy = rect.top + (-w.y * 0.5 + 0.5) * rect.height;
2518
+ if (cx >= lo.x && cx <= hi.x && cy >= lo.y && cy <= hi.y) out.push(conn);
2519
+ continue;
2520
+ }
2521
+ const hit = windowMode ? (sx0 >= lo.x && sx1 <= hi.x && sy0 >= lo.y && sy1 <= hi.y) : (sx0 <= hi.x && sx1 >= lo.x && sy0 <= hi.y && sy1 >= lo.y);
2522
+ if (hit) out.push(conn);
2501
2523
  }
2502
2524
  return out;
2503
2525
  }
@@ -2509,12 +2531,14 @@ function onUp(e) {
2509
2531
  const bs = boxSel; boxSel = null;
2510
2532
  if (bs) { // empty-space gesture: drag = box-select, click = clear selection
2511
2533
  if (bs.moved) { resetCycle();
2512
- const memberIds = membersInRect(bs.x, bs.y, e.clientX, e.clientY);
2513
- const connIds = connsInRect(bs.x, bs.y, e.clientX, e.clientY);
2514
- if (!memberIds.length && connIds.length === 1) selectWholeConn(connIds[0]); // a lone connection framed → full component select (envelope + inspector), same as a click
2515
- else { resetConnState(); const ids = [...memberIds, ...connIds.flatMap((c) => connChildIds(c))]; if (api && api.onSelectMany) api.onSelectMany(ids); } // members and/or several connections → a plain multi-select that INCLUDES the connections' parts
2534
+ const windowMode = e.clientX >= bs.x; // Tekla: drag L→R = window (fully enclose); R→L = crossing (touch)
2535
+ const additive = e.ctrlKey || e.metaKey || e.shiftKey; // Ctrl/Shift → toggle the framed set into the current selection (add/remove), else replace
2536
+ const memberIds = membersInRect(bs.x, bs.y, e.clientX, e.clientY, windowMode);
2537
+ const connIds = connsInRect(bs.x, bs.y, e.clientX, e.clientY, windowMode);
2538
+ if (!additive && !memberIds.length && connIds.length === 1) selectWholeConn(connIds[0]); // a lone connection framed (no modifier) → full component select (envelope + inspector), same as a click
2539
+ else { resetConnState(); const ids = [...memberIds, ...connIds.flatMap((c) => connChildIds(c))]; if (api && api.onSelectMany) api.onSelectMany(ids, additive); } // members and/or several connections → multi-select (replace, or Ctrl/Shift-toggle) INCLUDING the connections' parts
2516
2540
  }
2517
- else clickSelect(e.clientX, e.clientY, e.ctrlKey || e.metaKey); // click in empty space → cycle-pick (may land on a derived part) or clear
2541
+ else clickSelect(e.clientX, e.clientY, e.ctrlKey || e.metaKey || e.shiftKey); // click in empty space → cycle-pick (may land on a derived part) or clear
2518
2542
  downXY = null; return;
2519
2543
  }
2520
2544
  const p = pending, wasDragging = dragging; pending = dragging = null;
@@ -2545,8 +2569,8 @@ function onUp(e) {
2545
2569
  return;
2546
2570
  }
2547
2571
  if (!wasDragging) {
2548
- if (p.id && !p.geo && !p.epDrag && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, p.ctrl); return; } // a blocked drag (drag-move/copy OFF) acts on the GRABBED member, not whatever's under the release point (Ctrl toggles it)
2549
- clickSelect(e.clientX, e.clientY, p.ctrl); return; // click → cycle-pick: member, or a derived part stacked on it at a joint (Ctrl+click = additive select)
2572
+ if (p.id && !p.geo && !p.epDrag && moved > DRAG_TOL_PX && api && api.onSelect) { api.onSelect(p.id, p.add); return; } // a blocked drag (drag-move/copy OFF) acts on the GRABBED member, not whatever's under the release point (Ctrl/Shift toggles it)
2573
+ clickSelect(e.clientX, e.clientY, p.add); return; // click → cycle-pick: member, or a derived part stacked on it at a joint (Ctrl/Shift+click = additive select)
2550
2574
  }
2551
2575
  if (p.copy) { // Ctrl+drag → commit a copy at the plan delta (the editor clones + selects); the ghost was the preview
2552
2576
  if (p.delta && !(p.delta[0] === 0 && p.delta[1] === 0) && api && api.onCopyDrag3d) api.onCopyDrag3d(p.copyIds, p.delta[0], p.delta[1]);
@@ -51,7 +51,8 @@
51
51
  circle.handle{fill:var(--bg);stroke:#f8fafc;stroke-width:3;cursor:grab}
52
52
  line.seg{stroke:#475569;stroke-width:2;opacity:0;cursor:crosshair} body.add line.seg{opacity:.5}
53
53
  line.seg:hover{stroke:#fbbf24;opacity:1;stroke-width:4}
54
- rect.marquee{fill:rgba(59,130,246,.08);stroke:var(--brand);stroke-width:1;stroke-dasharray:5 4;vector-effect:non-scaling-stroke;pointer-events:none}
54
+ rect.marquee{fill:rgba(59,130,246,.08);stroke:var(--brand);stroke-width:1;stroke-dasharray:5 4;vector-effect:non-scaling-stroke;pointer-events:none} /* dashed = crossing (R→L, touch) */
55
+ rect.marquee.window{stroke-dasharray:none} /* solid = window (L→R, fully enclose) — Tekla-style affordance */
55
56
  .legend span{display:inline-flex;align-items:center;gap:4px;margin:0 8px 4px 0;white-space:nowrap}
56
57
  .sw{width:12px;height:12px;border-radius:2px;display:inline-block}
57
58
  .swc{width:14px;height:14px;padding:0;border:1px solid #475569;border-radius:3px;cursor:pointer;background:none;flex:none;-webkit-appearance:none;appearance:none}
@@ -353,6 +354,15 @@
353
354
  #propPop.connmode .ppfoot{display:none}
354
355
  #propPop.connmode #ppClear{display:none}
355
356
  #propPop.connmode .pprow{cursor:default;padding-left:14px} /* no checkbox → nudge the name so it doesn't float where the box was */
357
+ /* Selection tree: clickable object rows (connection nodes + parts + member leaves), reusing the legend's
358
+ chevron/swatch/selected-row vocabulary — click selects in the model (Ctrl toggle, Shift range). */
359
+ #propPop .pprow.trow{cursor:pointer;padding-left:6px}
360
+ #propPop .pprow.trow.ptrow{padding-left:24px} /* parts indent under their connection node */
361
+ #propPop .pprow .chev{width:14px;flex:none;color:var(--mut);text-align:center;font-size:9px;cursor:pointer}
362
+ #propPop .pprow .tsw{width:6px;height:6px;flex:none;border-radius:50%;background:var(--mut)} /* a round status dot — deliberately NOT a checkbox (label checkboxes are square inputs) */
363
+ #propPop .pprow.node{font-weight:500}
364
+ #propPop .pprow .cnt{color:var(--mut);font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:none}
365
+ #propPop .pprow.selrow{border-left:2px solid var(--brand);background:rgba(59,130,246,.16)}
356
366
  /* thin cluster divider in the 3D toolbar */
357
367
  #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
358
368
  /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
@@ -1431,7 +1441,7 @@ function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"
1431
1441
  if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
1432
1442
  function stats(){
1433
1443
  document.getElementById('mc').textContent=P.members.length;
1434
- let w=0,rfi=0; for(const m of P.members){const wpf=_wt(m.profile); if(wpf==null)rfi++; else w+=len(m.wp[0],m.wp[1])/FT*wpf;}
1444
+ let w=0,rfi=0; for(const m of P.members){const wpf=_wt(m.profile); if(wpf==null)rfi++; else w+=_lenFt(ensureMeta(m))*wpf;}
1435
1445
  document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
1436
1446
  }
1437
1447
  // Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
@@ -1453,6 +1463,37 @@ function connSelInfo(){
1453
1463
  const whole=childIds.length>0&&childIds.every(id=>selIds.has(id));
1454
1464
  return {conn,kind:j.kind,main:j.main,joint:j,childIds,whole,mode:whole?'whole':'part'};
1455
1465
  }
1466
+ // ── Selection as a browsable tree (Properties popup): group the flat selIds into connections (each with
1467
+ // its parts) + loose members, so a MIXED selection (a connection + members, several connections) renders
1468
+ // as one tree instead of falling back to member-only. connChildIdsEditor = a connection's selectable parts
1469
+ // (copes excluded — subtractive, not in the 3D mesh set). ---
1470
+ function connChildIdsEditor(conn){return Object.keys(partsById||{}).filter(k=>{const e=partsById[k];return e&&e.conn===conn&&e.kind!=='cut';});}
1471
+ function selTree(){
1472
+ const conns=[],cmap={},members=[];
1473
+ for(const id of selIds){
1474
+ const el=(partsById||{})[id];
1475
+ if(el&&el.conn){ if(!cmap[el.conn]){const j=(C.joints||[]).find(x=>x&&x.id===el.conn);cmap[el.conn]={conn:el.conn,kind:j?j.kind:'',main:j?j.main:'',joint:j,childIds:connChildIdsEditor(el.conn)};conns.push(cmap[el.conn]);} }
1476
+ else if(P.members.some(m=>m.id===id)) members.push(id);
1477
+ }
1478
+ return {conns,members};
1479
+ }
1480
+ // Ctrl/Shift multi-select from a popup tree row → drives the model selection (same semantics as the objects
1481
+ // browser / onSelectDim3d). treeRowIds = the flat, in-display-order LEAF ids (expanded parts + members) for
1482
+ // Shift-range; treeAnchor = the range anchor; treeExpanded = which connection nodes are open (transient).
1483
+ let treeExpanded=new Set(), treeAnchor=null, treeRowIds=[];
1484
+ function treeSelectLeaf(id,mods){
1485
+ let next;
1486
+ if(mods&&mods.shift&&treeAnchor!=null&&treeRowIds.includes(treeAnchor)&&treeRowIds.includes(id)){const i0=treeRowIds.indexOf(treeAnchor),i1=treeRowIds.indexOf(id);next=treeRowIds.slice(Math.min(i0,i1),Math.max(i0,i1)+1);}
1487
+ else if(mods&&(mods.ctrl||mods.meta)){next=new Set(selIds);next.has(id)?next.delete(id):next.add(id);next=[...next];treeAnchor=id;}
1488
+ else{next=[id];treeAnchor=id;}
1489
+ selIds=new Set(next);selDimIds.clear();sel3dDimIds.clear();render();
1490
+ }
1491
+ function treeSelectConn(conn,mods){
1492
+ const cids=connChildIdsEditor(conn);
1493
+ if(mods&&(mods.ctrl||mods.meta)){const next=new Set(selIds);const all=cids.length&&cids.every(id=>next.has(id));cids.forEach(id=>all?next.delete(id):next.add(id));selIds=next;selDimIds.clear();sel3dDimIds.clear();treeAnchor=cids[0]||null;render();}
1494
+ else if(window.Steel3DView&&window.Steel3DView.selectWholeConn){window.Steel3DView.selectWholeConn(conn);treeAnchor=cids[0]||null;} // plain → whole connection (envelope + inspector), same as a 3D click
1495
+ else{selIds=new Set(cids);selDimIds.clear();sel3dDimIds.clear();treeAnchor=cids[0]||null;render();}
1496
+ }
1456
1497
  // The floating breadcrumb over the 3D canvas: Model ▸ <Connection> [▸ <Part>]. Segments jump levels via the
1457
1498
  // 3D view's own ascend/whole-select so the canvas selection + envelope stay in lockstep. 3D-only; hidden at root.
1458
1499
  function updateConnCrumb(){
@@ -1645,7 +1686,7 @@ function panel(){
1645
1686
  return;
1646
1687
  }}
1647
1688
  const arr=selArr();
1648
- if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" data-tip="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl+click</b> to add/remove; drag an empty area to <b>box-select</b> (Ctrl adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
1689
+ if(arr.length===0){p.innerHTML='<h3 style="display:flex;justify-content:space-between;align-items:center">Legend'+(Object.keys(C.profile_colors).length?'<button class=ghost id=resetCols style="font-size:10px;padding:1px 6px">reset colours</button>':'')+'</h3><div class=legend>'+profs.filter(pr=>P.members.some(mm=>mm.profile===pr)).map(pr=>`<span><input type=color class=swc data-prof="${esc(pr)}" value="${colorFor(pr)}" data-tip="Click to recolour ${esc(pr)}">${esc(pr)}</span>`).join('')+'</div><div class=hint style="margin-top:12px">Click a member to edit; <b>Ctrl</b>/<b>Shift+click</b> to add/remove; drag an empty area to <b>box-select</b> — <b>left→right</b> grabs fully-enclosed, <b>right→left</b> grabs touched (Ctrl/Shift adds). Drag a selected line to move it (all selected move together) — it snaps onto nearby grid/endpoints; drag an end dot to adjust — also snaps (<b>Alt</b> off). Hold <b>Shift</b> to keep it straight (H/V). <b>Ctrl+D</b> duplicate, <b>Ctrl+Z/Y</b> undo/redo, <b>Del</b> delete, <b>Esc</b> deselect. <b>Ctrl+scroll</b> zoom, <b>middle-drag</b> pan, <b>Home</b> fit. Dashed = RFI (size unresolved, e.g. MF).</div>'+
1649
1690
  '<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
1650
1691
  '<div class=hint style="margin:0 0 6px;font-size:11px">Level <b>'+esc(fmtFtIn(defaultTOS))+'</b> ('+esc(P.sheet)+'). '+((P.tos_callouts&&P.tos_callouts.length)?'Per-zone T.O. STEEL callouts from the drawing applied to each member; ':'')+'ends ticked <b>default</b> follow the level.</div>'+
1651
1692
  '<div class=elab>Default TOS</div><input id=defTos inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="'+esc(fmtFtIn(defaultTOS))+'"><span class=edec>'+esc(fmtDecIn(defaultTOS))+'</span>'+
@@ -1659,9 +1700,9 @@ function panel(){
1659
1700
  arr.forEach(ensureMeta);
1660
1701
  const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
1661
1702
  const allBeam=cols.length===0, allCol=beams.length===0;
1662
- const totalL=arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT,0).toFixed(1);
1703
+ const totalL=arr.reduce((t,m)=>t+_lenFt(m),0).toFixed(1);
1663
1704
  const allKnown=arr.every(m=>_wt(m.profile)!=null);
1664
- const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+len(m.wp[0],m.wp[1])/FT*_wt(m.profile),0)):null;
1705
+ const totalW=allKnown?Math.round(arr.reduce((t,m)=>t+_lenFt(m)*_wt(m.profile),0)):null;
1665
1706
  const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
1666
1707
  const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
1667
1708
  const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
@@ -1724,7 +1765,7 @@ function panel(){
1724
1765
  wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
1725
1766
  {const me=document.getElementById('matchEnds');if(me)me.onclick=()=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail,conn:m.ends[0].conn,connVerified:m.ends[0].connVerified};});}}
1726
1767
  return;}
1727
- const m=ensureMeta(arr[0]), wpf=_wt(m.profile), L=(len(m.wp[0],m.wp[1])/FT).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
1768
+ const m=ensureMeta(arr[0]), wpf=_wt(m.profile), L=_lenFt(m).toFixed(1), col=(m.role==='column'), pos=posDefault(m);
1728
1769
  const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
1729
1770
  const mfSug=((m.mf||/^MF/i.test(m.profile))&&_lvl&&C.moment_frames)?[...new Set(C.moment_frames.flatMap(f=>f[_lvl]||[]))].filter(Boolean):[];
1730
1771
  const ftFld=(id,label,v)=>`<div class=elab>${label}</div><input id=${id} inputmode=decimal placeholder="5 3/4&quot; · 1&#39;-0 1/4&quot;" value="${esc(fmtFtIn(v))}"><span class=edec>${esc(fmtDecIn(v))}</span>`;
@@ -1783,7 +1824,7 @@ function panel(){
1783
1824
  <div class=row><label>Profile</label><div style="display:flex;gap:6px"><input id=pf class=combo data-src=profiles value="${esc(m.profile)}" style="flex:1" autocomplete=off><button id=pickProf class="ghost${(picking&&pickKind==='profile')?' on':''}" data-tip="Pick profile by clicking a label in the drawing">⌖ pick</button></div>${(picking&&pickKind==='profile')?'<div class="hint" style="margin-top:4px;font-style:italic;color:var(--brand)">Click a profile label in the drawing…</div>':(picking&&pickKind==='detail')?'<div class="hint" style="margin-top:4px;font-style:italic;color:#a855f7">Click a detail callout in the drawing…</div>':''}</div>
1784
1825
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
1785
1826
  <div class="seg2 mtype" id=mTypeS style="margin-top:6px" data-tip="Member type — drives legend grouping (independent of the Beam/Column geometry above)">${MEMBER_TYPES.map(t=>`<button data-mtype="${t.k}" class="${memberTypeOf(m)===t.k?'on':''}">${t.label}</button>`).join('')}</div>
1786
- <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(len(m.wp[0],m.wp[1])/FT*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
1827
+ <div class="row hint">Length <b>${L} ft</b> · ${wpf==null?'<span class=pill style="background:#7f1d1d">RFI — size unresolved</span>':'Weight <b>'+(_lenFt(m)*wpf).toFixed(0)+' lb</b> · '+wpf+' lb/ft'}</div>
1787
1828
  <div class=row><button class=ghostw id=verifyBtn${m.verified?' style="border-color:#166534;color:#86efac"':''} data-tip="Mark this member human-confirmed → 100% in the confidence report">${m.verified?'✓ Verified — human-confirmed':'Mark verified'}</button></div>
1788
1829
  ${mfSug.length?`<div class="row" style="border:1px solid #a855f7;border-radius:6px;padding:7px 8px;background:rgba(168,85,247,.07)"><div class=elab style="color:#c4b5fd;margin:0">Moment-frame girder · ${_lvl==='roof'?'roof':'2nd floor'} (from Frames)</div><div style="display:flex;flex-wrap:wrap;gap:5px;margin-top:5px">${mfSug.map(s=>`<button class="ghost mfsug${s===m.profile?' on':''}" data-s="${esc(s)}">${esc(s)}</button>`).join('')}</div></div>`:''}
1789
1830
  ${elev}
@@ -1941,7 +1982,21 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
1941
1982
  // checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
1942
1983
  // go through the same imperial formatters the side pane uses; the contract stays canonical metric.
1943
1984
  function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
1944
- function _lenFt(m){return len(m.wp[0],m.wp[1])/FT;}
1985
+ // True 3D member length in feet = straight-line distance between the member's two end NODES, exactly as the 3D
1986
+ // scene places them — so it's correct for sloped members (braces, ramped beams, battered columns), not just the
1987
+ // plan projection. Plan XY is in plan px (÷ppf→ft); elevations (TOS/BOS) are in inches (÷12→ft). Elevation is
1988
+ // default-aware (dTos = the plan datum a default-follow end/top rides; a null column base sits on the 0 datum).
1989
+ // Shared by the display (_lenFt, active-plan default) and the BOM (_mTons, per-plan default) so the two agree.
1990
+ function _len3dFt(m,ppf,dTos){
1991
+ const a=m.wp&&m.wp[0], b=m.wp&&m.wp[1]; if(!a||!b)return 0;
1992
+ const p=ppf>0?ppf:1, dx=(b[0]-a[0])/p, dy=(b[1]-a[1])/p; // plan run, feet
1993
+ const eff=o=>o?(o.tosDef!==false?dTos:o.tos):null; // effective elevation (inches), same precedence as _effTos: a default-follow end rides the datum dTos; only an explicit (tosDef===false) end uses its stored TOS
1994
+ let z0,z1;
1995
+ if(m.role==='column'){z0=(m.col&&m.col.bos!=null)?m.col.bos:0; z1=eff(m.col);} // bottom (BOS) → top (TOS)
1996
+ else{z0=eff(m.ends&&m.ends[0]); z1=eff(m.ends&&m.ends[1]);} // start TOS → end TOS
1997
+ const dz=((z1==null?0:z1)-(z0==null?0:z0))/12; // vertical rise, feet
1998
+ return Math.hypot(dx,dy,dz);}
1999
+ function _lenFt(m){return _len3dFt(m,FT,defaultTOS);}
1945
2000
  const PROP_DEFS=[
1946
2001
  {key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
1947
2002
  {key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
@@ -2022,7 +2077,7 @@ function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabel
2022
2077
 
2023
2078
  // ---- The floating Properties popup ----
2024
2079
  let propPopPinned=false;
2025
- let propPopConn=null; // non-null = read-only Connection mode (a connection selected connPropRows); null = the member label-picker mode
2080
+ let propPopConn=null; // truthy = connection/tree mode (selection involves a connection → renderConnTree); null = the member label-picker mode
2026
2081
  function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
2027
2082
  function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
2028
2083
  el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
@@ -2037,7 +2092,12 @@ function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
2037
2092
  +`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
2038
2093
  document.body.appendChild(el);
2039
2094
  const list=el.querySelector('#ppList');
2040
- list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // conn-mode rows carry no data-k → not toggleable
2095
+ list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row)return;
2096
+ if(e.target.closest('.chev')&&row.dataset.conn){treeExpanded.has(row.dataset.conn)?treeExpanded.delete(row.dataset.conn):treeExpanded.add(row.dataset.conn);renderPropPop();return;} // chevron → expand/collapse the connection node
2097
+ const mods={ctrl:e.ctrlKey||e.metaKey,shift:e.shiftKey};
2098
+ if(row.dataset.conn){treeSelectConn(row.dataset.conn,mods);return;} // connection node → select the whole connection in the model
2099
+ if(row.dataset.tid){treeSelectLeaf(row.dataset.tid,mods);return;} // a part / member row → select it (Ctrl toggle, Shift range)
2100
+ if(row.classList.contains('dis')||!row.dataset.k)return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);}); // member label rows carry data-k
2041
2101
  list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row&&row.dataset.k)togglePropKey(row.dataset.k,cb.checked);});
2042
2102
  el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
2043
2103
  el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
@@ -2058,9 +2118,9 @@ function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(
2058
2118
  refreshPropLabels();}
2059
2119
  // rebuild the popup contents against the current selection (chrome + rows), preserving row focus
2060
2120
  function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
2061
- if(propPopPinned){const cs=connSelInfo();if(cs)propPopConn=cs;else if(selArr().length)propPopConn=null;} // a PINNED popup follows the selection across member ↔ connection (an unpinned one re-asserts its mode on each right-click)
2121
+ if(propPopPinned){propPopConn=selTree().conns.length?true:(selArr().length?null:propPopConn);} // a PINNED popup follows the selection: any connection involved tree mode; pure members label-picker
2062
2122
  el.classList.toggle('connmode',!!propPopConn);
2063
- if(propPopConn){renderConnProps(el);return;} // read-only Connection view
2123
+ if(propPopConn){renderConnTree(el);return;} // connection / mixed-selection tree view
2064
2124
  const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
2065
2125
  el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
2066
2126
  el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
@@ -2090,7 +2150,7 @@ function openPropLabels(x,y){if(!selArr().length)return;propPopConn=null;const e
2090
2150
  // `connmode`: the connection's props as read-only name/value rows, label controls hidden. Labeling
2091
2151
  // connection props onto the model is a member-keyed pipeline → deferred; this is a VIEW, not a picker. ----
2092
2152
  function connPropRows(cs){
2093
- const j=cs.joint,isBP=cs.kind==='base-plate',pp=j.params||{};
2153
+ const j=cs.joint,isBP=cs.kind==='base-plate',pp=(j&&j.params)||{}; // tolerate a missing joint (orphaned part) rather than throw
2094
2154
  const plate=(partsById||{})[cs.conn+':plate']||null;
2095
2155
  const dim=mm=>(mm==null?'—':fmtFtIn(Number(mm)/25.4));
2096
2156
  const cols=pp.boltCols||(isBP?2:1),rows=pp.boltRows||(isBP?2:3);
@@ -2106,26 +2166,41 @@ function connPropRows(cs){
2106
2166
  {label:isBP?'Anchor count':'Bolt count', value:String(cols*rows)},
2107
2167
  {label:'Parts', value:String(cs.childIds.length)},
2108
2168
  ];}
2109
- function openConnProps(x,y,cs){propPopConn=cs;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2169
+ function openConnPop(x,y){propPopConn=true;const el=propPopEl();const sr=el.querySelector('#ppSearch');
2110
2170
  sr.value='';el.classList.add('open');renderPropPop();
2111
2171
  if(propPopPinned){dockPropPop();}
2112
2172
  else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
2113
2173
  setTimeout(()=>sr.focus(),0);}
2114
- // render the connection read-view; re-derives the connection each call so it self-heals if the selection moves
2115
- function renderConnProps(el){
2116
- const cs=connSelInfo();
2117
- if(!cs){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
2174
+ // Render the connection/tree view. Single connection its property summary + an interactive parts list;
2175
+ // multiple connections and/or members → a tree of selectable rows (click = select in the model, Ctrl toggle,
2176
+ // Shift range). Re-derived from the live selection each call so it self-heals as the selection moves.
2177
+ function renderConnTree(el){
2178
+ const t=selTree();
2179
+ if(!t.conns.length){ if(!propPopPinned){propPopConn=null;closePropPop(true);return;}
2118
2180
  el.querySelector('#ppTitle').textContent='Properties';el.querySelector('#ppLabeled').textContent='';
2119
2181
  el.querySelector('#ppScope').textContent='Right-click a connection';el.querySelector('#ppMeta').textContent='';
2120
2182
  el.querySelector('#ppList').innerHTML='<div class=ppempty>No connection selected.</div>';return; }
2121
- propPopConn=cs;
2122
2183
  const q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
2123
- el.querySelector('#ppTitle').textContent='Properties · '+(cs.kind==='base-plate'?'Base plate':'Shear plate');
2184
+ const single=t.conns.length===1&&!t.members.length;
2185
+ const kindName=k=>k==='base-plate'?'Base plate':k==='shear-plate'?'Shear plate':'Connection';
2186
+ const nSel=t.conns.length+t.members.length, rowMatch=s=>!q||String(s).toLowerCase().includes(q), sw='<span class=tsw></span>';
2187
+ el.querySelector('#ppTitle').textContent=single?('Properties · '+kindName(t.conns[0].kind)):('Properties · '+nSel+' objects');
2188
+ el.querySelector('#ppScope').textContent=single?('On '+t.conns[0].main):(t.conns.length+' connection'+(t.conns.length===1?'':'s')+(t.members.length?(' · '+t.members.length+' member'+(t.members.length===1?'':'s')):''));
2124
2189
  el.querySelector('#ppLabeled').textContent='';
2125
- el.querySelector('#ppScope').textContent='On '+cs.main;
2126
- const all=connPropRows(cs),rows=all.filter(r=>!q||r.label.toLowerCase().includes(q));
2127
- el.querySelector('#ppList').innerHTML=rows.length?rows.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join(''):'<div class=ppempty>No properties match your search.</div>';
2128
- el.querySelector('#ppMeta').textContent=rows.length+' of '+all.length+' shown';}
2190
+ treeRowIds=[];let html='';
2191
+ if(single){ const all=connPropRows(t.conns[0]).filter(r=>rowMatch(r.label)); // property summary (read-only)
2192
+ html+=all.map(r=>`<div class=pprow><span class=pn>${esc(r.label)}</span><span class=pv>${esc(r.value)}</span></div>`).join('');
2193
+ html+=`<div class=divrow><hr><span class=sect style="margin:0">Parts (${t.conns[0].childIds.length})</span><hr></div>`; } // then the interactive parts
2194
+ for(const c of t.conns){
2195
+ const expanded=single||treeExpanded.has(c.conn), wholeSel=c.childIds.length&&c.childIds.every(id=>selIds.has(id));
2196
+ if(!single) html+=`<div class="pprow trow node${wholeSel?' selrow':''}" data-conn="${esc(c.conn)}"><span class=chev>${expanded?'▾':'▸'}</span>${sw}<span class=pn>${esc(kindName(c.kind)+' · '+c.main)}</span><span class=cnt>${c.childIds.length} parts</span></div>`;
2197
+ if(expanded) for(const id of c.childIds){ const e2=(partsById||{})[id],lbl=(e2&&e2.meta&&e2.meta.label)||id.slice(id.indexOf(':')+1); if(!rowMatch(lbl))continue; treeRowIds.push(id);
2198
+ html+=`<div class="pprow trow ptrow${selIds.has(id)?' selrow':''}" data-tid="${esc(id)}">${sw}<span class=pn>${esc(lbl)}</span></div>`; }
2199
+ }
2200
+ for(const id of t.members){ const m=byId(id),lbl=id+(m&&m.profile?(' · '+m.profile):''); if(!rowMatch(lbl))continue; treeRowIds.push(id);
2201
+ html+=`<div class="pprow trow mrow${selIds.has(id)?' selrow':''}" data-tid="${esc(id)}">${sw}<span class=pn>${esc(lbl)}</span></div>`; }
2202
+ el.querySelector('#ppList').innerHTML=html||'<div class=ppempty>No properties match your search.</div>';
2203
+ el.querySelector('#ppMeta').textContent=single?(t.conns[0].childIds.length+' parts'):(nSel+' selected');}
2129
2204
  function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
2130
2205
  const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
2131
2206
  document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
@@ -2170,9 +2245,9 @@ document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDef
2170
2245
  document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
2171
2246
  if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
2172
2247
  if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
2173
- if(mode==='sel'&&!cmTool&&!picking){const cs=connSelInfo();
2174
- if(cs){openConnProps(e.clientX,e.clientY,cs);return;} // a connection selectedits read-only Properties popup
2175
- if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members selected → the member label-picker popup
2248
+ if(mode==='sel'&&!cmTool&&!picking){
2249
+ if(selTree().conns.length){openConnPop(e.clientX,e.clientY);return;} // any connection in the selection the connection / mixed-selection tree popup
2250
+ if(selArr().length){openPropLabels(e.clientX,e.clientY);return;}} // members only → the member label-picker popup
2176
2251
  });
2177
2252
  document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
2178
2253
  // --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
@@ -2539,19 +2614,19 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
2539
2614
  return;}}
2540
2615
  if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
2541
2616
  if(t.tagName==='image'&&mode==='add'){buildSnap(null);let q=toSvg(e),x0=q.x,y0=q.y;if(!e.altKey){const sn=snap(x0,y0);x0=sn.x;y0=sn.y;}const ln=document.createElementNS('http://www.w3.org/2000/svg','line');ln.setAttribute('class','draw');ln.setAttribute('x1',x0);ln.setAttribute('y1',y0);ln.setAttribute('x2',x0);ln.setAttribute('y2',y0);svg.appendChild(ln);drag={type:'draw',x0,y0,line:ln};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
2542
- if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
2543
- // Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
2544
- // its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
2545
- if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
2546
- const canDrag=dragMoveOn; // the toggle gates BOTH gestures: left-drag=move and Ctrl+drag=copy (Ctrl+CLICK still toggles selection below — a click, not a drag)
2547
- const srcIds=(ctrl&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // ctrl on an unselected member copies just it; else the whole selection
2617
+ if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),add=(ctrl||e.shiftKey),p=toSvg(e);
2618
+ // Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl/Shift defer
2619
+ // their select to up — a Ctrl-DRAG copies, a Shift-DRAG moves (Shift also ortho-locks the move to H/V), and an additive-CLICK toggles selection (Tekla: Ctrl or Shift).
2620
+ if(!add&&!selIds.has(id)){selIds=new Set([id]);render();}
2621
+ const canDrag=dragMoveOn; // the toggle gates BOTH gestures: left-drag=move and Ctrl+drag=copy (Ctrl/Shift+CLICK still toggles selection below — a click, not a drag)
2622
+ const srcIds=(add&&!selIds.has(id))?new Set([id]):new Set(selArr().map(m=>m.id)); // Ctrl/Shift on an unselected member acts on just it; else the whole selection
2548
2623
  const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
2549
2624
  buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
2550
- drag={type:'grab',copy:ctrl,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
2625
+ drag={type:'grab',copy:ctrl,add,armed:canDrag,start:[p.x,p.y],items,clickId:id,pre:snapshot(),moved:false};
2551
2626
  svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // always capture: even an unarmed grab tracks the gesture so a blocked drag can teach the toggle
2552
2627
  if((t===svg||t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e); // t===svg → drag on bare canvas (no raster) still box-selects
2553
- const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
2554
- drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
2628
+ const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee window');svg.appendChild(rc);
2629
+ drag={type:'marquee',x0:p.x,y0:p.y,sx0:e.clientX,window:true,add:(e.ctrlKey||e.metaKey||e.shiftKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
2555
2630
  svg.addEventListener('pointermove',e=>{
2556
2631
  if(csaxisMode){csPrev(e);return;}
2557
2632
  if(dimMode){dimPrev(e);return;}
@@ -2596,6 +2671,8 @@ svg.addEventListener('pointermove',e=>{
2596
2671
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
2597
2672
  drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
2598
2673
  if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
2674
+ drag.window=(e.clientX>=drag.sx0); // Tekla: drag L→R = window (enclose); R→L = crossing (touch)
2675
+ drag.rect.classList.toggle('window',drag.window); // solid rect = window, dashed = crossing (visual affordance)
2599
2676
  drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
2600
2677
  if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
2601
2678
  if(!drag.moved){
@@ -2605,7 +2682,8 @@ svg.addEventListener('pointermove',e=>{
2605
2682
  if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
2606
2683
  const ns=new Set(),ci=[];
2607
2684
  for(const it of drag.items){const src=byId(it.id);if(!src)continue;const c=cloneMember(src);P.members.push(c);ns.add(c.id);ci.push({id:c.id,o0:c.wp[0].slice(),o1:c.wp[1].slice()});}
2608
- selIds=ns;drag.items=ci;buildSnap(ns);render();}} // select the clones + snap them to the sources/others; render shows them (drag continues via updateLine)
2685
+ selIds=ns;drag.items=ci;buildSnap(ns);render();} // select the clones + snap them to the sources/others; render shows them (drag continues via updateLine)
2686
+ else if(drag.add&&!selIds.has(drag.clickId)){selIds=new Set([drag.clickId]);render();}} // Shift/Ctrl-drag on an unselected member → move just it (select so state matches the gesture)
2609
2687
  if(!drag.armed)return;
2610
2688
  let dx=rawdx,dy=rawdy;
2611
2689
  if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
@@ -2640,13 +2718,20 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2640
2718
  const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
2641
2719
  if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
2642
2720
  drag=null;render();return;}
2643
- if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
2644
- const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
2645
- if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
2721
+ if(drag.type==='marquee'){const r=drag.cur,win=drag.window!==false,add=drag.add;
2722
+ if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){
2723
+ const memHit=m=>win?(inRect(m.wp[0],r)&&inRect(m.wp[1],r)):rectHit(m.wp[0],m.wp[1],r); // window: both ends enclosed · crossing: touched
2724
+ const mIds=P.members.filter(memHit).map(m=>m.id), dIds=[];
2725
+ if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot); // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
2726
+ const hit=win?(inRect(g.p1,r)&&inRect(g.p2,r)):(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r));
2727
+ if(hit)dIds.push(d.id);}
2728
+ if(add){for(const id of mIds)selIds.has(id)?selIds.delete(id):selIds.add(id);for(const id of dIds)selDimIds.has(id)?selDimIds.delete(id):selDimIds.add(id);} // Ctrl/Shift → toggle each framed object (add/remove)
2729
+ else{selIds.clear();selDimIds.clear();for(const id of mIds)selIds.add(id);for(const id of dIds)selDimIds.add(id);}
2730
+ }else if(!add){selIds.clear();selDimIds.clear();} // a click (no drag) on empty space clears — unless a modifier is held
2646
2731
  drag.rect.remove();drag=null;render();return;}
2647
2732
  if(drag.type==='grab'){
2648
2733
  if(!drag.moved){ // no drag past threshold → it was a click
2649
- if(drag.copy){selIds.has(drag.clickId)?selIds.delete(drag.clickId):selIds.add(drag.clickId);} // ctrl+click toggles selection (deferred from down)
2734
+ if(drag.add){selIds.has(drag.clickId)?selIds.delete(drag.clickId):selIds.add(drag.clickId);} // Ctrl/Shift+click toggles selection (deferred from down)
2650
2735
  drag=null;render();return;}
2651
2736
  if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
2652
2737
  drag=null;render();
@@ -2811,7 +2896,10 @@ const view3dApi={
2811
2896
  copesByMember=cm;partsById=pb;return sc;}, // cache resolved parts by id (real dims for the part Inspector) AND cope labels per member; placed details ride in as image elements
2812
2897
 
2813
2898
  onSelect:(id,additive)=>{selDimIds.clear();sel3dDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D pick → shared selection (clears 2D + 3D dim selection so Delete is unambiguous)
2814
- onSelectMany:(ids)=>{selDimIds.clear();sel3dDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
2899
+ onSelectMany:(ids,additive)=>{selDimIds.clear();sel3dDimIds.clear();
2900
+ if(additive){for(const id of (ids||[]))selIds.has(id)?selIds.delete(id):selIds.add(id);} // Ctrl/Shift + 3D area-select → toggle each framed object into the current selection (add/remove), like Tekla
2901
+ else selIds=new Set(ids||[]);
2902
+ if(mode==='add'){mode='sel';setMode();}render();if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();}, // 3D box-select → shared selection
2815
2903
  getMembers:()=>P.members, // raw members (wp) for snap geometry
2816
2904
  snapEnabled:()=>snapEnabled, // the persistent running-snaps (⋯ menu → Snapping) — 3D honours the same on/off set
2817
2905
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
@@ -3309,7 +3397,7 @@ stage.addEventListener('pointerdown',e=>{if(e.button!==1)return;e.preventDefault
3309
3397
  stage.addEventListener('pointermove',e=>{if(!pan)return;stage.scrollLeft=pan.sl-(e.clientX-pan.x);stage.scrollTop=pan.st-(e.clientY-pan.y);});
3310
3398
  stage.addEventListener('pointerup',()=>{if(pan){pan=null;stage.style.cursor='';}});
3311
3399
  document.getElementById('exp').onclick=()=>{const out={pt_per_ft:FT,elev_units:'inch',members:P.members.map(m=>{ensureMeta(m);return {profile:m.profile,workpoint:m.wp,role:m.role,rfi:_wt(m.profile)==null,
3312
- length_ft:+(len(m.wp[0],m.wp[1])/FT).toFixed(1),weight_plf:_wt(m.profile),
3400
+ length_ft:+_lenFt(m).toFixed(1),weight_plf:_wt(m.profile), // columns measure vertical height (TOS−BOS), not their ~0 plan-point distance
3313
3401
  ends:m.role==='column'?undefined:m.ends, column:m.role==='column'?m.col:undefined};})};
3314
3402
  const b=new Blob([JSON.stringify(out,null,1)],{type:'application/json'});
3315
3403
  const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='contract_edited.json';a.click();};
@@ -3512,7 +3600,7 @@ function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();retu
3512
3600
  const cx=(x0+x1)/2,cy=(y0+y1)/2;stage.scrollLeft=(cx-X0)*zoom-stage.clientWidth/2;stage.scrollTop=(cy-Y0)*zoom-stage.clientHeight/2;}
3513
3601
  function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
3514
3602
  g.innerHTML=list.length?('<div class=hint style="margin-bottom:10px">'+list.length+' member'+(list.length>1?'s':'')+' have no resolved AISC size, so their weight is excluded from the BOM. Assign a profile to clear it — for <b>MF/BF</b> marks use the <b>Frames</b> schedule. Edits here update the contract.</div><table class=ftab><thead><tr><th>#</th><th>Profile / mark</th><th>Role</th><th>Length</th><th>Why it’s flagged</th><th></th></tr></thead><tbody>'+
3515
- list.map((m,i)=>{ensureMeta(m);const L=(len(m.wp[0],m.wp[1])/FT).toFixed(1);
3603
+ list.map((m,i)=>{ensureMeta(m);const L=_lenFt(m).toFixed(1);
3516
3604
  return `<tr><td><span class=rfichip>${i+1}</span></td>
3517
3605
  <td><input class=combo data-src=profiles data-mid="${esc(m.id)}" value="${esc(m.profile||'')}" placeholder="e.g. W16X26" autocomplete=off style="width:124px"></td>
3518
3606
  <td>${m.role==='column'?'Column':'Beam'}</td><td>${L} ft</td><td class=rea>${esc(rfiReason(m))}</td>
@@ -3558,10 +3646,8 @@ function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
3558
3646
  const isd=dup.has(m.id);if(isd)F.push({key:'dup',label:'Duplicate',state:'fail',detail:'coincident with a kept member — deleting dedupes the BOM'});
3559
3647
  if(m.verified===true)F.push({key:'verified',label:'Verified',state:'ok',detail:'human-confirmed'});
3560
3648
  const band=m.verified===true?'verified':isd?'low':(sched||asm)?'med':'high';return {band,factors:F};}
3561
- function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;let ft;
3562
- if(m.role==='column'){if(!m.col||m.col.tos==null||m.col.bos==null)return 0;ft=Math.abs(m.col.tos-m.col.bos)/12;} // column length = height (TOS−BOS), inches→ft
3563
- else{if(!m.wp||m.wp.length<2)return 0;ft=Math.hypot(m.wp[0][0]-m.wp[1][0],m.wp[0][1]-m.wp[1][1])/(ptPerFt>0?ptPerFt:1);}
3564
- return ft*plf/2000;}
3649
+ function _mTons(m,ptPerFt,dTos){const plf=_wt(m.profile);if(plf==null)return 0;
3650
+ return _len3dFt(m,ptPerFt,dTos)*plf/2000;} // true 3D length × lb/ft — same helper the inspector/top-bar use, so BOM and display agree
3565
3651
  function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
3566
3652
  const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
3567
3653
  function _scoreConnections(){
@@ -3589,7 +3675,7 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
3589
3675
  const _conn=_scoreConnections();
3590
3676
  const known=new Set();for(const p of plans)if(p.sheet)known.add(p.sheet.toUpperCase());for(const s of Object.keys(C.detail_bubbles||{}))known.add(s.toUpperCase());
3591
3677
  plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
3592
- for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft),factors:r.factors});}
3678
+ for(const m of ms){const r=_scoreMember(m,dup);byMember.push({id:m.id,planIdx:pi,sheet:plan.sheet||'',role:m.role==='column'?'column':'beam',profile:m.profile||'',band:r.band,tons:r.band==='rfi'?0:_mTons(m,plan.pt_per_ft,plan.default_tos),factors:r.factors});}
3593
3679
  for(const d of (plan.details||[])){const sh=_detSheet(d.text);const ok=sh!=null&&known.has(sh.toUpperCase());byDetail.push({text:d.text||'',planIdx:pi,sheet:plan.sheet||'',band:ok?'high':'low',reason:ok?('references '+sh+' (in the set)'):sh?('references '+sh+' — not found in the set'):'no sheet reference parsed'});}});
3594
3680
  const roll=(arr,cw)=>{const c=_cnt0();let n=0,d=0,t=0;for(const m of arr){c[m.band]++;if(m.band==='rfi')continue;const w=cw?1:m.tons;n+=w*BANDW[m.band];d+=w;t+=m.tons;}return {score:d>0?Math.round(n/d*100):null,tons:t,counts:c};};
3595
3681
  const dRoll=arr=>{const c=_cnt0();let n=0,d=0;for(const x of arr){c[x.band]++;n+=BANDW[x.band];d++;}return {score:d>0?Math.round(n/d*100):null,tons:0,counts:c};};
@@ -3889,6 +3975,25 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
3889
3975
  snapEnabled.end=true;const x4=snap(1,1);ok(x4.kind==='end','re-enabling Endpoint restores it');
3890
3976
  const ax2=snap(2,40);ok(ax2.x===0,'with Endpoint on, axis-align still pulls to the endpoint column');
3891
3977
  Object.assign(snapEnabled,se);P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
3978
+ // 11) member length — true 3D distance between the two end nodes: level beam = plan run, plumb column = TOS−BOS
3979
+ // height (its plan points coincide → 0 plan distance), and a SLOPED member picks up its elevation rise.
3980
+ {const sf=FT,st=defaultTOS;FT=12;defaultTOS=198;
3981
+ ok(near(_lenFt({role:'beam',wp:[[0,0],[120,0]]}),10),'_lenFt level beam = plan run (no TOS delta)');
3982
+ ok(near(_lenFt({role:'column',wp:[[0,0],[0,0]],col:{bos:0,tos:120,tosDef:false}}),10),'_lenFt plumb column = |TOS−BOS| height (not ~0 plan distance)');
3983
+ ok(near(_lenFt({role:'column',wp:[[5,5],[5,5]],col:{bos:12,tos:null,tosDef:true}}),(198-12)/12),'_lenFt column default TOS follows the level');
3984
+ // sloped brace: 3 ft plan run + 3 ft TOS rise → true length √(3²+3²), NOT the 3 ft plan projection
3985
+ ok(near(_lenFt({role:'beam',wp:[[0,0],[36,0]],ends:[{tos:0,tosDef:false},{tos:36,tosDef:false}]}),Math.hypot(3,0,3)),'_lenFt sloped beam = true 3D length (plan run + TOS rise)');
3986
+ // battered column: 4 ft plan lean + 12 ft rise → √(4²+12²), not the bare height
3987
+ ok(near(_lenFt({role:'column',wp:[[0,0],[48,0]],col:{bos:0,tos:144,tosDef:false}}),Math.hypot(4,0,12)),'_lenFt battered column includes the plan lean');
3988
+ // _mTons (BOM) shares the SAME 3D helper → agrees with the inspector (no BOM-vs-display divergence), incl. default-follow
3989
+ const dc={role:'column',profile:'W10X49',col:{bos:0,tos:null,tosDef:true}};
3990
+ ok(near(_mTons(dc,10,198), _lenFt(dc)*49/2000),'_mTons shares _len3dFt with the display (agree even for a default-follow column)');
3991
+ FT=sf;defaultTOS=st;}
3992
+ // 12) Tekla area-select — window (L→R drag) = fully enclosed; crossing (R→L drag) = touched
3993
+ {const r=[0,0,10,10],win=(a,b)=>inRect(a,r)&&inRect(b,r),crs=(a,b)=>rectHit(a,b,r);
3994
+ ok(win([2,2],[8,8])&&crs([2,2],[8,8]),'fully-inside member: window and crossing both pick it');
3995
+ ok(!win([5,5],[50,5])&&crs([5,5],[50,5]),'straddling member: crossing picks it, window does not');
3996
+ ok(!win([20,20],[30,30])&&!crs([20,20],[30,30]),'outside member: neither picks it');}
3892
3997
  const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (frame + snap + transform math)';
3893
3998
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
3894
3999
  })();}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.72.3",
3
+ "version": "0.73.1",
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": {