@floless/app 0.79.0 → 0.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53093,7 +53093,7 @@ function appVersion() {
53093
53093
  return resolveVersion({
53094
53094
  isSea: isSea2(),
53095
53095
  sqVersionXml: readSqVersionXml(),
53096
- define: true ? "0.79.0" : void 0,
53096
+ define: true ? "0.80.0" : void 0,
53097
53097
  pkgVersion: readPkgVersion()
53098
53098
  });
53099
53099
  }
@@ -53103,7 +53103,7 @@ function resolveChannel(s) {
53103
53103
  return "dev";
53104
53104
  }
53105
53105
  function appChannel() {
53106
- return resolveChannel({ isSea: isSea2(), define: true ? "0.79.0" : void 0 });
53106
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.80.0" : void 0 });
53107
53107
  }
53108
53108
 
53109
53109
  // workflow-update.ts
@@ -98,6 +98,19 @@ export function elevationLevels(members, ptPerFt, defaultTosMm, excludeId) {
98
98
  return [...set].sort((a, b) => a - b);
99
99
  }
100
100
 
101
+ // Snap a raw elevation (mm) to the nearest level datum { z, label } within `tol`, returning the snapped
102
+ // value + its source, or the raw value when nothing is in range. Nearest wins. Pure — backs the Mode B
103
+ // column-base pick (the levels come from elevationLevels()). Malformed datums are skipped.
104
+ export function snapElevation(z, levels, tol) {
105
+ let best = null, bestD = tol;
106
+ for (const lv of levels || []) {
107
+ if (!lv || typeof lv.z !== 'number' || !isFinite(lv.z)) continue;
108
+ const d = Math.abs(z - lv.z);
109
+ if (d <= bestD) { bestD = d; best = lv; }
110
+ }
111
+ return best ? { z: best.z, snapped: true, label: best.label ?? null } : { z, snapped: false, label: null };
112
+ }
113
+
101
114
  // Lower wins when two candidates are both within tolerance. Grid intersections rank with member
102
115
  // intersections (columns land on them); grid lines below member centerlines.
103
116
  const PRECEDENCE = { vertex: 0, intersection: 1, 'grid-int': 1, midpoint: 2, centerline: 3, 'vertical-axis': 4, 'grid-line': 5, level: 1 };
@@ -17,7 +17,7 @@
17
17
  import * as THREE from 'three';
18
18
  import { OrbitControls } from 'three/addons/OrbitControls.js';
19
19
  import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg';
20
- import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, dim3dGeom, planeBasis, projectToPlane, planeFrom3Points, vecToPlane } from './steel-3d-core.js';
20
+ import { snapCandidates, snapPoint, planPointToWp, memberGeometry, elevationLevels, snapElevation, dim3dGeom, planeBasis, projectToPlane, planeFrom3Points, vecToPlane } from './steel-3d-core.js';
21
21
  import { gridGeometry, gridCandidates3d, mmPerPx } from './grid-core.js';
22
22
 
23
23
  let renderer, scene, perspCam, orthoCam, camera, controls, root, api, canvasEl, ro, rafId, grid, raycaster, downXY, lastPick = '(none)';
@@ -31,6 +31,10 @@ let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged
31
31
  let refGroup = null; let refLineOn = false; // optional reference (work) lines between each member's end points
32
32
  let insertMode = false; // armed "place a vectored detail" pick (mirrors clipMode)
33
33
  let insertPending = null; // {name,url} — the detail the editor queued for the next placement click
34
+ let basePickCol = null; // Mode B: armed "trim column to base" pick — the target column id, or null
35
+ let basePickLevels = null; // [{ z, label }] level datums the base pick snaps its elevation to
36
+ const ELEV_SNAP_PX = 10; // screen-pixel snap radius for the base-pick elevation (world tol via pxToWorldAt, so it's consistent at any zoom)
37
+ const BASE_PICK_HIT_PX = 40; // the base pick only commits when the click is within this many px of the column axis (rejects an accidental empty-canvas click)
34
38
  let labelsOnFlag = false; // member mark/id overlay labels toggle (a readability aid)
35
39
  let memberLabelHost = null; // fixed-position container for the member labels (positioned each frame)
36
40
  const memberLabelPool = []; // reused <div> labels, one per member, positioned in the loop
@@ -631,6 +635,57 @@ function setInsertMode(on, pending) {
631
635
  if (api && api.onInsertModeChange) api.onInsertModeChange(insertMode);
632
636
  }
633
637
  function insertModeOn() { return insertMode; }
638
+
639
+ // Mode B: arm a "trim column to base" pick on `colId`. The level datums are derived from the OTHER members'
640
+ // rendered top/bottom (world mm, so no display→mm conversion), each labelled by its member mark, so the pick
641
+ // snaps the base to a real framing level. A one-shot left-click hands the picked base elevation to the editor
642
+ // via api.onBasePick. Mirrors setInsertMode.
643
+ function setBasePickMode(colId) {
644
+ basePickCol = colId || null;
645
+ basePickLevels = null;
646
+ if (basePickCol) {
647
+ setInsertMode(false); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); setClipMode(null);
648
+ root.updateMatrixWorld(true);
649
+ basePickLevels = [];
650
+ const _b = new THREE.Box3();
651
+ for (const [id, m] of meshById) {
652
+ // MEMBER meshes only (their id is a plain mark): a ':' marks a connection part (`joint:part`) or a
653
+ // detail (`det:…`), so those are excluded — the base must snap to framing levels, not the moving
654
+ // base-plate/anchor/weld hardware of the very connection being re-seated.
655
+ if (id === basePickCol || !m.visible || String(id).includes(':')) continue;
656
+ _b.setFromObject(m); if (_b.isEmpty()) continue;
657
+ basePickLevels.push({ z: Math.round(_b.max.z), label: id + ' top' }, { z: Math.round(_b.min.z), label: id + ' base' });
658
+ }
659
+ }
660
+ if (canvasEl) canvasEl.style.cursor = basePickCol ? 'crosshair' : 'default';
661
+ if (!basePickCol) { if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
662
+ if (api && api.onBasePickModeChange) api.onBasePickModeChange(basePickModeOn());
663
+ }
664
+ function basePickModeOn() { return !!basePickCol; }
665
+ // A point along the target column's VERTICAL axis at the cursor, elevation snapped to the level datums.
666
+ // Intersect the pick ray with a vertical plane through the column axis facing the camera — so a click
667
+ // anywhere near the (thin) column reads as an elevation on its reference line. Returns { z, snapped, label, x, y }.
668
+ function basePickPoint(cx, cy) {
669
+ const col = meshById.get(basePickCol); if (!col) return null;
670
+ camera.updateMatrixWorld(); root.updateMatrixWorld(true);
671
+ const box = new THREE.Box3().setFromObject(col);
672
+ const ax = (box.min.x + box.max.x) / 2, ay = (box.min.y + box.max.y) / 2;
673
+ const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
674
+ const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
675
+ raycaster.setFromCamera(ndc, camera);
676
+ const toCam = new THREE.Vector3(camera.position.x - ax, camera.position.y - ay, 0);
677
+ if (toCam.lengthSq() < 1e-6) toCam.set(1, 0, 0);
678
+ toCam.normalize();
679
+ const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(toCam, new THREE.Vector3(ax, ay, 0));
680
+ const g = new THREE.Vector3();
681
+ if (!raycaster.ray.intersectPlane(plane, g)) return null;
682
+ // The plane is infinite, so gate the pick to clicks NEAR the column axis (else an accidental empty-canvas
683
+ // click would commit a trim). g lies on the plane through the axis; its horizontal offset from the axis is
684
+ // how far to the side the cursor is.
685
+ if (Math.hypot(g.x - ax, g.y - ay) > pxToWorldAt(BASE_PICK_HIT_PX, g)) return null;
686
+ const s = snapElevation(g.z, basePickLevels, pxToWorldAt(ELEV_SNAP_PX, g)); // screen-scaled tol so the snap window is ~constant on-screen
687
+ return { z: s.z, snapped: s.snapped, label: s.label, x: ax, y: ay };
688
+ }
634
689
  // The placement raycast: a member face under the cursor gives the point + its basis + the member id to
635
690
  // anchor to; empty space drops the detail on the ground plane (z=0) with a default vertical orientation.
636
691
  function insertPick(cx, cy) {
@@ -1348,6 +1403,7 @@ function onKey(e) {
1348
1403
  if (pending && pending.clipDrag && e.key === 'Escape') { e.preventDefault(); const p = pending; if (p.plane) p.clip.point = p.prePoint; else p.clip.box.copy(p.preBox); rebuildClipPlanes(p.clip); applyClips(); renderClipGizmo(); pending = dragging = null; if (controls) controls.enabled = true; if (readout) readout.style.display = 'none'; return; } // Esc mid-drag → undo the handle move
1349
1404
  if (wpMode && e.key === 'Escape') { e.preventDefault(); if (wpMode === '3pt' && wpDraft && wpDraft.length) { wpDraft.pop(); updateStatusChip(); } else { wpMode = null; wpDraft = null; marker.visible = false; canvasEl.style.cursor = 'default'; reflectWpBar(); updateStatusChip(); } return; } // Esc steps a 3pt pick back, else disarms the set-plane mode
1350
1405
  if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
1406
+ if (basePickCol && e.key === 'Escape') { e.preventDefault(); setBasePickMode(null); if (api && api.toast) api.toast('Trim cancelled'); return; } // Esc disarms the Mode B column-base pick
1351
1407
  if (clipMode && e.key === 'Escape') { e.preventDefault(); if (clipMode === 'box' && clipBoxDraft) { if (clipBoxDraft.b) clipBoxDraft.b = null; else clipBoxDraft = null; setClipPreview(null); updateStatusChip(); } else setClipMode(null); return; } // Esc steps back: height→footprint→cancel, else disarms the pick
1352
1408
  if (isolatedIds && e.key === 'Escape' && !dimMode3d) { e.preventDefault(); clearIsolation(); return; } // Esc exits isolate-selected (the dim tool's own Esc wins while it's armed)
1353
1409
  if (e.key === 'Escape' && !dimMode3d && !cmActive() && ascendConn()) { e.preventDefault(); return; } // Esc ascends the connection drill: part → whole → nothing
@@ -2341,6 +2397,7 @@ function onDown(e) {
2341
2397
  return; }
2342
2398
  if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
2343
2399
  if (cmActive()) { if (tfClick(e)) { e.stopPropagation(); controls.enabled = true; return; } } // Move/Copy armed (editor state) → picks land on the working plane; an empty-selection click falls through to select
2400
+ if (basePickCol) { e.stopPropagation(); const r = basePickPoint(e.clientX, e.clientY); if (r) { if (api && api.onBasePick) api.onBasePick(r); setBasePickMode(null); } return; } // Mode B: commit + disarm only on a valid on-column pick; an off-column click is ignored (stays armed) — Esc cancels
2344
2401
  if (insertMode) { e.stopPropagation(); const r = insertPick(e.clientX, e.clientY); if (r && api && api.onInsertPlace) api.onInsertPlace(r, insertPending); setInsertMode(false); return; } // armed: place the queued detail at the pick, then disarm (one shot)
2345
2402
  if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
2346
2403
  if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
@@ -2620,6 +2677,17 @@ function onHoverMove(e) {
2620
2677
  hoverRAF = requestAnimationFrame(() => {
2621
2678
  hoverRAF = 0;
2622
2679
  if (!lastHoverXY || pending || dragging || boxSel) return;
2680
+ if (basePickCol) { // Mode B armed: reticle on the column axis + a live elevation readout
2681
+ const p = basePickPoint(lastHoverXY[0], lastHoverXY[1]);
2682
+ if (p) {
2683
+ showMarker([p.x, p.y, p.z], p.snapped ? 'end' : 'dot');
2684
+ canvasEl.style.cursor = p.snapped ? 'none' : 'crosshair';
2685
+ readout._dist.textContent = api.fmtLen ? api.fmtLen(p.z) : (p.z / 304.8).toFixed(2) + ' ft';
2686
+ readout._type.textContent = p.snapped ? ' · ' + (p.label || 'level') : ' ↕';
2687
+ readout.style.left = (lastHoverXY[0] + 14) + 'px'; readout.style.top = (lastHoverXY[1] + 14) + 'px'; readout.style.display = 'block';
2688
+ } else { marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'crosshair'; }
2689
+ return;
2690
+ }
2623
2691
  if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
2624
2692
  canvasEl.style.cursor = 'crosshair';
2625
2693
  if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
@@ -2805,6 +2873,7 @@ window.Steel3DView = {
2805
2873
  setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
2806
2874
  setRefLine, refLine: () => refLineOn,
2807
2875
  setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
2876
+ setBasePickMode, basePickMode: basePickModeOn, // arm/query the Mode B column-base pick (level-snapped elevation)
2808
2877
  selectWholeConn, clearConnSel, ascendConn, connContext, connEnvelopeOn: () => !!connEnvelope, // Connection Components (Slice A): whole-select / drill / ascend + test probes
2809
2878
  setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
2810
2879
  syncMemberLabels, // editor calls after a mark/id edit to refresh labels
@@ -941,6 +941,16 @@ function retargetTos(m,srcDef,dstDef){const d=dstDef-srcDef;ensureMeta(m);
941
941
  // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
942
942
  // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
943
943
  let defaultTOS=198, addProfile='';
944
+ let basePickColId=null; // Mode B: the column whose base an armed 3D level-pick retargets
945
+ const MIN_COL_STUB_IN=12; // never trim a column below a 1' stub (keeps bos < tos)
946
+ // Mode B: arm the 3D column-base pick on `colId` (a level-snapped elevation → col.bos). 3D-view only.
947
+ function armBaseTrim(colId){
948
+ if(!view3d){toast('Open the 3D view to pick the column base point');return;}
949
+ if(!(window.Steel3DView&&window.Steel3DView.setBasePickMode))return;
950
+ basePickColId=colId;
951
+ window.Steel3DView.setBasePickMode(colId);
952
+ toast('Click a point along '+colId+' — the base snaps to framing levels · Esc cancels');
953
+ }
944
954
  function syncDefaults(){for(const m of P.members){ensureMeta(m);
945
955
  if(m.role==='column'){if(m.col.tosDef!==false&&defaultTOS!=null)m.col.tos=defaultTOS;if(m.col.bos==null)m.col.bos=0;}
946
956
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
@@ -1738,12 +1748,14 @@ function panel(){
1738
1748
  <div class=divrow><hr></div>
1739
1749
  <div class="row f" style="gap:6px;flex-wrap:wrap">
1740
1750
  <button class=ghostw id=cmpEdit data-tip="Edit this connection's parameters on ${esc(j.main)}">✎ Edit parameters on ${esc(j.main)} →</button>
1751
+ ${isBP?`<button class=ghostw id=cmpTrim data-tip="Pick a point on ${esc(j.main)} — trim or extend the column so its base seats there (snaps to framing levels)">⭶ Trim / Extend column to base…</button>`:''}
1741
1752
  <button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
1742
1753
  <button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
1743
1754
  </div>`;
1744
1755
  const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
1745
1756
  {const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
1746
1757
  {const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
1758
+ {const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
1747
1759
  {const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
1748
1760
  {const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
1749
1761
  return;
@@ -1901,7 +1913,7 @@ function panel(){
1901
1913
  ${pFld('plateWidth','Plate width "N"','auto','mm')}${pFld('plateDepth','Plate depth "B"','auto','mm')}${pFld('thickness','Thickness','1"','mm')}${pFld('weldLeg','Weld leg','5/16"','mm')}
1902
1914
  <div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
1903
1915
  ${pFld('boltCols','Bolt columns','2','')}${pFld('boltRows','Bolt rows','2','')}${pFld('boltDia','Bolt ⌀','1"','mm')}${pFld('embedment','Embedment','auto','mm')}${pFld('grout','Grout','auto','mm')}
1904
- <div class="row f"><button class="ghostw" id=bpRemove data-tip="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
1916
+ <div class="row f"><button class="ghostw" id=bpTrim data-tip="Pick a point on ${esc(m.id)} — trim or extend the column so its base seats there (snaps to framing levels)">⭶ Trim / Extend column to base…</button><button class="ghostw" id=bpRemove data-tip="Delete this column's base plate" style="color:#fca5a5;border-color:#7f1d1d">Remove base plate</button></div>`:'';
1905
1917
  // This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
1906
1918
  // per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
1907
1919
  const spjs=col?[]:[0,1].map(e=>({e,j:(C.joints||[]).find(j=>j&&j.kind==='shear-plate'&&j.main===m.id&&j.at==='end'+e)})).filter(x=>x.j);
@@ -1977,7 +1989,8 @@ function panel(){
1977
1989
  else delete bpj.params[key]; // empty / invalid / ≤0 → drop the override so the engine falls back to its default
1978
1990
  bpj.source='user';});};}; // through edit() → the param change is undoable; editing also makes the plate user-owned (survives the auto-detail "Clear")
1979
1991
  ['plateWidth','plateDepth','thickness','boltDia','weldLeg','embedment','grout'].forEach(k=>wireBp(k,false));['boltCols','boltRows'].forEach(k=>wireBp(k,true));
1980
- {const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}} // Remove is undoable
1992
+ {const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}
1993
+ {const tb=document.getElementById('bpTrim');if(tb)tb.onclick=()=>armBaseTrim(m.id);}} // Remove is undoable; Trim arms the 3D base pick
1981
1994
  }else{
1982
1995
  wireTos('tosA',m.ends[0]);
1983
1996
  document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
@@ -3072,6 +3085,23 @@ const view3dApi={
3072
3085
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
3073
3086
  onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
3074
3087
  onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
3088
+ onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
3089
+ onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
3090
+ const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
3091
+ const tos=(m.col.tos!=null?m.col.tos:defaultTOS); // inches (col.bos/tos are inches; memberGeometry ×25.4 → mm)
3092
+ if(tos==null){toast('Set the column top (TOS) first');return;}
3093
+ const want=p.z/25.4, cap=tos-MIN_COL_STUB_IN;
3094
+ const bosIn=Math.min(want, cap); // world mm → inches; clamp so the column keeps a min stub (bos<tos)
3095
+ const clamped=want>cap+0.02; // the pick was above the min-stub cap → we seated at the cap, not the picked level
3096
+ const cur=(m.col.bos!=null?m.col.bos:0);
3097
+ if(Math.abs(bosIn-cur)<0.02){toast('Column base already at that level');return;}
3098
+ const dir=bosIn<cur?'extended':'trimmed'; // lower base = longer column = extended; higher = trimmed
3099
+ edit(()=>{m.col.bos=bosIn;}); // set BOS only (leave tosDef — the top isn't edited); the base plate re-seats at the new base via expandBasePlate, same undo
3100
+ // Report the ACTUAL result: a snapped level when honored, else the ft-in elevation; call out the clamp so a
3101
+ // pick above the min stub isn't misreported as landing on the snapped level.
3102
+ const where=clamped?(fmtFtIn(bosIn)+' (clamped to a 1′ minimum stub)'):(p.snapped?(p.label||'a level'):fmtFtIn(bosIn));
3103
+ toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
3104
+ },
3075
3105
  onInsertPlace:(pick,pending)=>{
3076
3106
  if(pending&&pending.kind==='connection'&&pending.connection){
3077
3107
  const conn=pending.connection;const rc=conn.recipe;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {