@floless/app 0.73.0 → 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.73.0" : 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.73.0" : 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}
@@ -1440,7 +1441,7 @@ function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"
1440
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]);}}
1441
1442
  function stats(){
1442
1443
  document.getElementById('mc').textContent=P.members.length;
1443
- 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;}
1444
1445
  document.getElementById('wt').textContent=(w/2000).toFixed(1);document.getElementById('wtlb').textContent=Math.round(w).toLocaleString();document.getElementById('rc').textContent=rfi;
1445
1446
  }
1446
1447
  // Aggregate one field across a multi-selection → the shared value, or VARIES when they differ. Drives the
@@ -1685,7 +1686,7 @@ function panel(){
1685
1686
  return;
1686
1687
  }}
1687
1688
  const arr=selArr();
1688
- 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>'+
1689
1690
  '<div style="border-top:1px solid var(--line);margin-top:12px;padding-top:12px"><div class=sect>Project defaults</div>'+
1690
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>'+
1691
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>'+
@@ -1699,9 +1700,9 @@ function panel(){
1699
1700
  arr.forEach(ensureMeta);
1700
1701
  const beams=arr.filter(m=>m.role!=='column'), cols=arr.filter(m=>m.role==='column');
1701
1702
  const allBeam=cols.length===0, allCol=beams.length===0;
1702
- 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);
1703
1704
  const allKnown=arr.every(m=>_wt(m.profile)!=null);
1704
- 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;
1705
1706
  const dupSet=new Set(redundantDups()), dupSel=arr.filter(m=>dupSet.has(m.id)).length;
1706
1707
  const profAgg=agg(arr,m=>m.profile), allVerified=arr.every(m=>m.verified===true);
1707
1708
  const VV=v=>v===VARIES, valOf=v=>VV(v)||v==null?'':esc(fmtFtIn(v)), decOf=v=>VV(v)||v==null?'':esc(fmtDecIn(v));
@@ -1764,7 +1765,7 @@ function panel(){
1764
1765
  wMTos('tosB',s1);wMText('ntB',s1,(o,v)=>o.note=v);wMText('dtB',s1,(o,v)=>o.detail=v);
1765
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};});}}
1766
1767
  return;}
1767
- 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);
1768
1769
  const _lvl=({'S-202':'second','S-203':'roof'})[P.sheet];
1769
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):[];
1770
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>`;
@@ -1823,7 +1824,7 @@ function panel(){
1823
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>
1824
1825
  <div class="seg2 f"><button id=rBeam class="${col?'':'on'}">Beam</button><button id=rCol class="${col?'on':''}">Column</button></div>
1825
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>
1826
- <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>
1827
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>
1828
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>`:''}
1829
1830
  ${elev}
@@ -1981,7 +1982,21 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
1981
1982
  // checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
1982
1983
  // go through the same imperial formatters the side pane uses; the contract stays canonical metric.
1983
1984
  function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
1984
- 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);}
1985
2000
  const PROP_DEFS=[
1986
2001
  {key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
1987
2002
  {key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
@@ -2599,19 +2614,19 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
2599
2614
  return;}}
2600
2615
  if(t.classList.contains('seg')&&mode==='add'){addFromSeg(t.dataset.seg);return;}
2601
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;}
2602
- if(t.classList.contains('member')&&mode==='sel'){const id=t.dataset.id,ctrl=(e.ctrlKey||e.metaKey),p=toSvg(e);
2603
- // Plain click selects on down (so a no-drag click still selects even with drag-move off). Ctrl defers
2604
- // its select to up — a ctrl-DRAG copies, a ctrl-CLICK toggles selection (today's behavior).
2605
- if(!ctrl&&!selIds.has(id)){selIds=new Set([id]);render();}
2606
- 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)
2607
- 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
2608
2623
  const items=[...srcIds].map(mid=>{const m=byId(mid);return{id:mid,o0:m.wp[0].slice(),o1:m.wp[1].slice()};});
2609
2624
  buildSnap(srcIds); // snap the dragged line(s) to OTHER members'/segments' endpoints
2610
- 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};
2611
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
2612
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
2613
- const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
2614
- 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();}});
2615
2630
  svg.addEventListener('pointermove',e=>{
2616
2631
  if(csaxisMode){csPrev(e);return;}
2617
2632
  if(dimMode){dimPrev(e);return;}
@@ -2656,6 +2671,8 @@ svg.addEventListener('pointermove',e=>{
2656
2671
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
2657
2672
  drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
2658
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)
2659
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;}
2660
2677
  if(drag.type==='grab'){const rawdx=p.x-drag.start[0],rawdy=p.y-drag.start[1];
2661
2678
  if(!drag.moved){
@@ -2665,7 +2682,8 @@ svg.addEventListener('pointermove',e=>{
2665
2682
  if(drag.copy){ // materialize clones once; from here it's a normal move of the clones
2666
2683
  const ns=new Set(),ci=[];
2667
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()});}
2668
- 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)
2669
2687
  if(!drag.armed)return;
2670
2688
  let dx=rawdx,dy=rawdy;
2671
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)
@@ -2700,13 +2718,20 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2700
2718
  const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
2701
2719
  if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
2702
2720
  drag=null;render();return;}
2703
- if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
2704
- 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);
2705
- 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
2706
2731
  drag.rect.remove();drag=null;render();return;}
2707
2732
  if(drag.type==='grab'){
2708
2733
  if(!drag.moved){ // no drag past threshold → it was a click
2709
- 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)
2710
2735
  drag=null;render();return;}
2711
2736
  if(!drag.armed){ // dragged but drag-move OFF → nothing moved; teach the toggle once
2712
2737
  drag=null;render();
@@ -2871,7 +2896,10 @@ const view3dApi={
2871
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
2872
2897
 
2873
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)
2874
- 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
2875
2903
  getMembers:()=>P.members, // raw members (wp) for snap geometry
2876
2904
  snapEnabled:()=>snapEnabled, // the persistent running-snaps (⋯ menu → Snapping) — 3D honours the same on/off set
2877
2905
  ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
@@ -3369,7 +3397,7 @@ stage.addEventListener('pointerdown',e=>{if(e.button!==1)return;e.preventDefault
3369
3397
  stage.addEventListener('pointermove',e=>{if(!pan)return;stage.scrollLeft=pan.sl-(e.clientX-pan.x);stage.scrollTop=pan.st-(e.clientY-pan.y);});
3370
3398
  stage.addEventListener('pointerup',()=>{if(pan){pan=null;stage.style.cursor='';}});
3371
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,
3372
- 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
3373
3401
  ends:m.role==='column'?undefined:m.ends, column:m.role==='column'?m.col:undefined};})};
3374
3402
  const b=new Blob([JSON.stringify(out,null,1)],{type:'application/json'});
3375
3403
  const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='contract_edited.json';a.click();};
@@ -3572,7 +3600,7 @@ function zoomToSelection(){const arr=selArr();if(!arr.length){fitToWindow();retu
3572
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;}
3573
3601
  function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList();
3574
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>'+
3575
- 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);
3576
3604
  return `<tr><td><span class=rfichip>${i+1}</span></td>
3577
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>
3578
3606
  <td>${m.role==='column'?'Column':'Beam'}</td><td>${L} ft</td><td class=rea>${esc(rfiReason(m))}</td>
@@ -3618,10 +3646,8 @@ function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
3618
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'});
3619
3647
  if(m.verified===true)F.push({key:'verified',label:'Verified',state:'ok',detail:'human-confirmed'});
3620
3648
  const band=m.verified===true?'verified':isd?'low':(sched||asm)?'med':'high';return {band,factors:F};}
3621
- function _mTons(m,ptPerFt){const plf=_wt(m.profile);if(plf==null)return 0;let ft;
3622
- 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
3623
- 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);}
3624
- 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
3625
3651
  function _detSheet(t){const m=String(t||'').toUpperCase().match(/S-?\s?\d{2,3}/);return m?m[0].replace(/\s/g,''):null;}
3626
3652
  const _cnt0=()=>({verified:0,high:0,med:0,low:0,rfi:0});
3627
3653
  function _scoreConnections(){
@@ -3649,7 +3675,7 @@ function scoreContractJS(){const plans=C.plans||[];const byMember=[],byDetail=[]
3649
3675
  const _conn=_scoreConnections();
3650
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());
3651
3677
  plans.forEach((plan,pi)=>{segs+=(plan.segments||[]).length;const ms=plan.members||[];const dup=_confDupIds(ms);
3652
- 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});}
3653
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'});}});
3654
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};};
3655
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};};
@@ -3949,6 +3975,25 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
3949
3975
  snapEnabled.end=true;const x4=snap(1,1);ok(x4.kind==='end','re-enabling Endpoint restores it');
3950
3976
  const ax2=snap(2,40);ok(ax2.x===0,'with Endpoint on, axis-align still pulls to the endpoint column');
3951
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');}
3952
3997
  const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (frame + snap + transform math)';
3953
3998
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
3954
3999
  })();}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.73.0",
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": {