@floless/app 0.78.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.
- package/dist/floless-server.cjs +29 -9
- package/dist/web/steel-3d-core.js +13 -0
- package/dist/web/steel-3d-view.js +70 -1
- package/dist/web/steel-editor.html +215 -50
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -53093,7 +53093,7 @@ function appVersion() {
|
|
|
53093
53093
|
return resolveVersion({
|
|
53094
53094
|
isSea: isSea2(),
|
|
53095
53095
|
sqVersionXml: readSqVersionXml(),
|
|
53096
|
-
define: true ? "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.
|
|
53106
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.80.0" : void 0 });
|
|
53107
53107
|
}
|
|
53108
53108
|
|
|
53109
53109
|
// workflow-update.ts
|
|
@@ -60191,23 +60191,38 @@ function weightOf(contract, profile) {
|
|
|
60191
60191
|
var isMfMark = (p) => !!p && /(^|[^A-Z])MF($|[^A-Z])/i.test(p);
|
|
60192
60192
|
var len = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
|
60193
60193
|
function redundantDupIds(members) {
|
|
60194
|
-
const
|
|
60194
|
+
const foot = (m) => {
|
|
60195
60195
|
if (!m.wp || m.wp.length < 2) return null;
|
|
60196
60196
|
const r = (p) => `${Math.round(p[0] / 3)},${Math.round(p[1] / 3)}`;
|
|
60197
60197
|
const a = r(m.wp[0]), b = r(m.wp[1]);
|
|
60198
60198
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
|
60199
60199
|
};
|
|
60200
|
-
const
|
|
60200
|
+
const byFoot = /* @__PURE__ */ new Map();
|
|
60201
60201
|
for (const m of members) {
|
|
60202
|
-
const k =
|
|
60202
|
+
const k = foot(m);
|
|
60203
60203
|
if (!k) continue;
|
|
60204
|
-
(
|
|
60204
|
+
(byFoot.get(k) ?? byFoot.set(k, []).get(k)).push(m);
|
|
60205
60205
|
}
|
|
60206
60206
|
const out = /* @__PURE__ */ new Set();
|
|
60207
|
-
|
|
60208
|
-
if (grp.length < 2)
|
|
60209
|
-
grp.sort((a, b) => dupRank(b) - dupRank(a));
|
|
60207
|
+
const keepBest = (grp) => {
|
|
60208
|
+
if (grp.length < 2) return;
|
|
60209
|
+
grp.sort((a, b) => dupRank(b) - dupRank(a) || (dupElev(b) !== "na" ? 1 : 0) - (dupElev(a) !== "na" ? 1 : 0));
|
|
60210
60210
|
for (let i = 1; i < grp.length; i++) out.add(grp[i].id);
|
|
60211
|
+
};
|
|
60212
|
+
for (const grp of byFoot.values()) {
|
|
60213
|
+
if (grp.length < 2) continue;
|
|
60214
|
+
const explicit = new Set(grp.map(dupElev).filter((s) => s !== "na"));
|
|
60215
|
+
if (explicit.size <= 1) {
|
|
60216
|
+
keepBest(grp);
|
|
60217
|
+
continue;
|
|
60218
|
+
}
|
|
60219
|
+
const bySig = /* @__PURE__ */ new Map();
|
|
60220
|
+
for (const m of grp) {
|
|
60221
|
+
const s = dupElev(m);
|
|
60222
|
+
if (s === "na") continue;
|
|
60223
|
+
(bySig.get(s) ?? bySig.set(s, []).get(s)).push(m);
|
|
60224
|
+
}
|
|
60225
|
+
for (const sub of bySig.values()) keepBest(sub);
|
|
60211
60226
|
}
|
|
60212
60227
|
return out;
|
|
60213
60228
|
}
|
|
@@ -60217,6 +60232,11 @@ function dupRank(m) {
|
|
|
60217
60232
|
if (m.profile && m.profile.trim() !== "") s += 1;
|
|
60218
60233
|
return s;
|
|
60219
60234
|
}
|
|
60235
|
+
function dupElev(m) {
|
|
60236
|
+
const q = (v) => typeof v === "number" && isFinite(v) ? Math.round(v) : null;
|
|
60237
|
+
const sig = m.role === "column" ? [q(m.col?.tos), q(m.col?.bos)] : [q(m.ends?.[0]?.tos), q(m.ends?.[1]?.tos)];
|
|
60238
|
+
return sig.every((v) => v == null) ? "na" : sig.map((v) => v == null ? "" : v).join(":");
|
|
60239
|
+
}
|
|
60220
60240
|
function elevationAssumed(m) {
|
|
60221
60241
|
if (m.role === "column") return m.col?.tosDef !== false;
|
|
60222
60242
|
const ends = m.ends ?? [];
|
|
@@ -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
|
|
@@ -398,20 +398,21 @@
|
|
|
398
398
|
#m3dLegend .lrow.clip .clab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
|
|
399
399
|
#m3dLegend .lrow.clip .lx{margin-left:0} /* the label's flex:1 already pushes On/Off + × to the right */
|
|
400
400
|
#m3dLegend .lrow.clip.sel{border-left:2px solid var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
|
|
401
|
-
#m3dLegend .lrow.clip.sel .clab{color:var(--text)}
|
|
402
|
-
#m3dLegend .lrow.clip.sel .lsw{box-shadow:0 0 0 1.5px #f8fafc} /* white ring on the selected clip's swatch — mirrors the 3D endpoint ring */
|
|
403
|
-
#m3dLegend .cpill{font-size:9px;line-height:1;padding:2px 6px;border-radius:9px;border:1px solid #475569;background:#334155;color:var(--mut);text-transform:uppercase;letter-spacing:.04em;flex:none;box-shadow:none;cursor:pointer}
|
|
404
|
-
#m3dLegend .cpill.on{background:var(--brand);border-color:var(--brand);color:#fff}
|
|
405
|
-
#m3dLegend .cpill:hover{border-color:#64748b}
|
|
401
|
+
#m3dLegend .lrow.clip.sel .clab{color:var(--text)} /* selection is shown by the row-level .sel styling above (brand left-border + tint); the box is the enable toggle now */
|
|
406
402
|
#m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;gap:1px;max-height:40%;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 10px;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
|
|
407
403
|
#m3dLegend .lhint{color:var(--mut);font-size:10px;margin-bottom:4px;max-width:230px;white-space:normal} /* wrap-guard: the hint never drives the panel wider than the rows, so it can't clip on any font */
|
|
408
|
-
#m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:
|
|
404
|
+
#m3dLegend .lrow{display:flex;align-items:center;gap:7px;cursor:default;user-select:none;padding:2px 4px;border-radius:5px;white-space:nowrap} /* only the left-hand box toggles now (it sets cursor:pointer); the row still isolates on dbl-click + right-clicks for the menu */
|
|
409
405
|
#m3dLegend .lrow:hover{background:#33415580}
|
|
410
|
-
#m3dLegend .lrow.off{opacity:.4}
|
|
406
|
+
#m3dLegend .lrow.off{opacity:.4}
|
|
407
|
+
/* hidden/off (or a disabled clip) → the box goes HOLLOW (outline of its own colour); the row also dims. No grayscale — the empty box already signals "hidden". */
|
|
408
|
+
#m3dLegend .lrow.off .lsw,#m3dLegend .lrow.dim.dimoff .lsw{background:transparent;box-shadow:inset 0 0 0 1.6px var(--sw)}
|
|
411
409
|
#m3dLegend .lrow.solo{background:rgba(59,130,246,.12)} /* in the isolated set (Explorer-style multi-select highlight) */
|
|
412
|
-
#m3dLegend .lrow.
|
|
413
|
-
#m3dLegend .lrow.dim
|
|
414
|
-
#m3dLegend .
|
|
410
|
+
#m3dLegend .lrow.lsel{box-shadow:inset 2px 0 0 var(--brand)} /* selected in 3D — brand left-bar (distinct from .solo's isolate tint; the two can coexist) */
|
|
411
|
+
#m3dLegend .lrow.dim .lsw{--sw:#67e8f9} /* dim overlays are cyan — same filled(on)/hollow(off) box as every other row */
|
|
412
|
+
#m3dLegend .lrow.dim.dimoff{opacity:.55} /* off = a normal resting choice, gentler than .off; the box hollows via the shared rule above */
|
|
413
|
+
#m3dLegend .lsw{width:11px;height:11px;border-radius:2px;flex:none;background:var(--sw,#94a3b8);cursor:pointer} /* the visibility box: filled(--sw)=shown; the .off/.dimoff rule above hollows it when hidden */
|
|
414
|
+
#m3dLegend .lsw:hover{box-shadow:0 0 0 2px rgba(255,255,255,.25)}
|
|
415
|
+
#m3dLegend .lrow.off .lsw:hover,#m3dLegend .lrow.dim.dimoff .lsw:hover{box-shadow:inset 0 0 0 1.6px var(--sw),0 0 0 2px rgba(255,255,255,.25)}
|
|
415
416
|
#m3dLegend .lsec{color:#475569;font-size:10px;letter-spacing:.06em;text-transform:uppercase;margin:6px 0 2px;padding:0 4px}
|
|
416
417
|
/* member grouping: By profile / By type toggle + collapsible type categories (Phase 1) */
|
|
417
418
|
#m3dLegend .lmode{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;height:24px;margin-bottom:6px;flex:none}
|
|
@@ -434,6 +435,24 @@
|
|
|
434
435
|
#m3dLegend .lrow.flash{background:rgba(59,130,246,.12)}
|
|
435
436
|
.leg-drag-ghost{position:fixed;pointer-events:none;z-index:70;background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:3px 8px;display:flex;align-items:center;gap:7px;font:12px system-ui;color:var(--text);width:200px;opacity:.88;box-shadow:0 4px 16px rgba(0,0,0,.6)}
|
|
436
437
|
#m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
|
|
438
|
+
/* Objects-list SEARCH — narrows the member/connection rows as you type; never the 3D scene, never Dims/Grid/Clip.
|
|
439
|
+
Built from the same tokens as #propPop .ppsearch (no new vocabulary); sits between the mode toggle and the hint. */
|
|
440
|
+
#m3dLegend .lsearch{display:flex;align-items:center;gap:6px;height:26px;margin-bottom:6px;padding:0 8px;background:var(--bg);border:1px solid var(--line);border-radius:6px;flex:none}
|
|
441
|
+
#m3dLegend .lsearch:focus-within{border-color:var(--brand)}
|
|
442
|
+
#m3dLegend .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
|
|
443
|
+
#m3dLegend .lsico svg{display:block}
|
|
444
|
+
#m3dLegend .lsearch input{flex:1;min-width:0;width:auto;height:auto;background:transparent;border:0;outline:none;color:var(--text);font:12px system-ui;padding:0}
|
|
445
|
+
#m3dLegend .lsearch input::placeholder{color:var(--mut)}
|
|
446
|
+
#m3dLegend .lsearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden} /* clear — reuses the .lrow .lx delete-glyph recipe */
|
|
447
|
+
#m3dLegend .lsearch.has .lsx{visibility:visible}
|
|
448
|
+
#m3dLegend .lsearch .lsx:hover{color:#fecaca;background:#7f1d1d}
|
|
449
|
+
#m3dLegend .lrow.qhide,#m3dLegend .cat-hdr.qhide{display:none} /* filtered OUT by search → gone (distinct from .off = hidden-in-3D, which only dims the swatch) */
|
|
450
|
+
#m3dLegend .lsempty{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
|
|
451
|
+
/* Show-all reset bar — panel-local entry to showAllGroups(); shown only when something is hidden/isolated. Clones the
|
|
452
|
+
.lsearch full-width box recipe; brand border on hover only (a one-shot action, not a mode → no solid fill). */
|
|
453
|
+
#m3dLegend .lreset{display:none;align-items:center;justify-content:center;gap:6px;height:26px;margin-bottom:6px;background:var(--bg);border:1px solid var(--line);border-radius:6px;color:var(--text);font:12px system-ui;cursor:pointer;flex:none;user-select:none}
|
|
454
|
+
#m3dLegend .lreset.show{display:flex}
|
|
455
|
+
#m3dLegend .lreset:hover{border-color:var(--brand);background:#1a2740}
|
|
437
456
|
#m3dCube{position:absolute;right:12px;top:56px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))} /* top-right (Revit-style), below the toolbar row */
|
|
438
457
|
/* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
|
|
439
458
|
(pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
|
|
@@ -922,6 +941,16 @@ function retargetTos(m,srcDef,dstDef){const d=dstDef-srcDef;ensureMeta(m);
|
|
|
922
941
|
// best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
|
|
923
942
|
// 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
|
|
924
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
|
+
}
|
|
925
954
|
function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
926
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;}
|
|
927
956
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
@@ -1331,12 +1360,29 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
|
|
|
1331
1360
|
if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
|
|
1332
1361
|
c.rfi=(_wt(c.profile)==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
|
|
1333
1362
|
// --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
|
|
1334
|
-
function
|
|
1363
|
+
function dupFoot(m){if(!m||!m.wp||m.wp.length<2)return null;const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
|
|
1364
|
+
// Elevation signature. Two members at the SAME footprint but a DIFFERENT explicit top-of-steel are different
|
|
1365
|
+
// objects (a beam stacked over a beam); an UNSET elevation is a wildcard — it dedupes against whatever's at that
|
|
1366
|
+
// footprint (a genuine double-read that lost its callout on one copy). tos is rounded to the inch: real callout
|
|
1367
|
+
// elevations are whole values so sub-inch rounding boundaries don't arise; levels differ by feet, so a coarser
|
|
1368
|
+
// tolerance isn't needed. MIRROR of server/steel-confidence.ts dupElev — keep the two in sync.
|
|
1369
|
+
function dupElev(m){const q=v=>(typeof v==='number'&&isFinite(v))?Math.round(v):null;
|
|
1370
|
+
const sig=(m&&m.role==='column')?[q(m.col&&m.col.tos),q(m.col&&m.col.bos)]:[q(m&&m.ends&&m.ends[0]&&m.ends[0].tos),q(m&&m.ends&&m.ends[1]&&m.ends[1].tos)];
|
|
1371
|
+
return sig.every(v=>v==null)?'na':sig.map(v=>v==null?'':v).join(':');}
|
|
1335
1372
|
function dupScore(m){let s=0;if(_wt(m.profile)!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1373
|
+
// Coincident-member dedupe → the redundant ids. Group by 2D footprint; within a footprint: ≤1 distinct EXPLICIT
|
|
1374
|
+
// elevation ⇒ all copies of one member ⇒ keep the best (highest rankFn; an elevation-tagged copy wins ties), rest
|
|
1375
|
+
// redundant; ≥2 explicit elevations ⇒ distinct levels ⇒ keep the best per level, unset copies kept (ambiguous).
|
|
1376
|
+
// MIRROR of server/steel-confidence.ts redundantDupIds — keep in sync.
|
|
1377
|
+
function dedupeFootprintIds(members,rankFn){const byFoot={};for(const m of members){const k=dupFoot(m);if(!k)continue;(byFoot[k]=byFoot[k]||[]).push(m);}
|
|
1378
|
+
const out=[],keepBest=grp=>{if(grp.length<2)return;grp.sort((a,b)=>rankFn(b)-rankFn(a)||((dupElev(b)!=='na')-(dupElev(a)!=='na')));for(let i=1;i<grp.length;i++)out.push(grp[i].id);};
|
|
1379
|
+
for(const k in byFoot){const grp=byFoot[k];if(grp.length<2)continue;
|
|
1380
|
+
const exp=new Set(grp.map(dupElev).filter(s=>s!=='na'));
|
|
1381
|
+
if(exp.size<=1){keepBest(grp);continue;}
|
|
1382
|
+
const bySig={};for(const m of grp){const s=dupElev(m);if(s==='na')continue;(bySig[s]=bySig[s]||[]).push(m);}
|
|
1383
|
+
for(const s in bySig)keepBest(bySig[s]);}
|
|
1339
1384
|
return out;}
|
|
1385
|
+
function redundantDups(){return dedupeFootprintIds(P.members,dupScore);}
|
|
1340
1386
|
// --- merge collinear chords: same-profile, end-to-end, STRAIGHT beam runs → one member each.
|
|
1341
1387
|
// The skew read breaks a chord into collinear sub-segments at every rung; this rejoins each run.
|
|
1342
1388
|
// MIRROR of server/steel-merge.ts (the tested source of truth) — keep the two in lock-step.
|
|
@@ -1455,7 +1501,7 @@ function render(){
|
|
|
1455
1501
|
s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
|
|
1456
1502
|
if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
|
|
1457
1503
|
svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS(); updConnBtn(); updBpBtn(); updSpBtn(); updGridToggle();
|
|
1458
|
-
if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
|
|
1504
|
+
if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);refreshLegendSel();} // keep the 3D highlight + legend selection in sync; selecting a member clears any clip selection (exclusive)
|
|
1459
1505
|
try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
|
|
1460
1506
|
syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
|
|
1461
1507
|
}
|
|
@@ -1702,12 +1748,14 @@ function panel(){
|
|
|
1702
1748
|
<div class=divrow><hr></div>
|
|
1703
1749
|
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1704
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>`:''}
|
|
1705
1752
|
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
|
|
1706
1753
|
<button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
|
|
1707
1754
|
</div>`;
|
|
1708
1755
|
const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1709
1756
|
{const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
|
|
1710
1757
|
{const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
|
|
1758
|
+
{const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
|
|
1711
1759
|
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1712
1760
|
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1713
1761
|
return;
|
|
@@ -1865,7 +1913,7 @@ function panel(){
|
|
|
1865
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')}
|
|
1866
1914
|
<div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
|
|
1867
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')}
|
|
1868
|
-
<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>`:'';
|
|
1869
1917
|
// This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
|
|
1870
1918
|
// per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
|
|
1871
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);
|
|
@@ -1941,7 +1989,8 @@ function panel(){
|
|
|
1941
1989
|
else delete bpj.params[key]; // empty / invalid / ≤0 → drop the override so the engine falls back to its default
|
|
1942
1990
|
bpj.source='user';});};}; // through edit() → the param change is undoable; editing also makes the plate user-owned (survives the auto-detail "Clear")
|
|
1943
1991
|
['plateWidth','plateDepth','thickness','boltDia','weldLeg','embedment','grout'].forEach(k=>wireBp(k,false));['boltCols','boltRows'].forEach(k=>wireBp(k,true));
|
|
1944
|
-
{const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}
|
|
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
|
|
1945
1994
|
}else{
|
|
1946
1995
|
wireTos('tosA',m.ends[0]);
|
|
1947
1996
|
document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
|
|
@@ -3036,6 +3085,23 @@ const view3dApi={
|
|
|
3036
3085
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
3037
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)
|
|
3038
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
|
+
},
|
|
3039
3105
|
onInsertPlace:(pick,pending)=>{
|
|
3040
3106
|
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3041
3107
|
const conn=pending.connection;const rc=conn.recipe;
|
|
@@ -3137,9 +3203,10 @@ async function detailRequest(intent,place,note){
|
|
|
3137
3203
|
body:JSON.stringify({appId:APP_ID,project:PROJECT||undefined,instruction,intent,target:{sheet:place.sheet||undefined,ids},snapshots:snaps})});
|
|
3138
3204
|
toast(res.ok?(intent==='create'?'Insert queued for your terminal AI session':'Change queued for your terminal AI session'):'Could not queue the request');
|
|
3139
3205
|
}catch(_){toast('Could not queue the request');}}
|
|
3140
|
-
// Build the 3D legend overlay from the live scene groups (per profile).
|
|
3141
|
-
//
|
|
3142
|
-
|
|
3206
|
+
// Build the 3D legend overlay from the live scene groups (per profile). Click the BOX to show/hide (filled =
|
|
3207
|
+
// shown, hollow = hidden); click a row (its name) to SELECT the object[s] in 3D — Ctrl/Cmd adds/removes, Shift
|
|
3208
|
+
// ranges; double-click a row to isolate. leg3dClickT defers the plain row-click so a dbl-click isolates instead.
|
|
3209
|
+
let leg3dClickT=null,legendAnchor=null,legendSelAnchor=null;
|
|
3143
3210
|
// Explorer-style multi-isolate on dbl-click of a legend group row: plain = isolate just this group; Ctrl = toggle
|
|
3144
3211
|
// it in/out of the isolated set; Shift = the contiguous range from the anchor row to this one (in displayed order).
|
|
3145
3212
|
function legendIsolate(k,e){
|
|
@@ -3196,6 +3263,10 @@ function profileKeyOf(m){return (m&&m.profile||'').trim().toUpperCase();} // ma
|
|
|
3196
3263
|
function categoryOfProfile(profKey){for(const m of (P.members||[]))if(profileKeyOf(m)===profKey)return memberTypeOf(m);return 'beam';} // a profile-group's category = the type of its member(s)
|
|
3197
3264
|
let legendMode=(localStorage.getItem('floless.legendMode')==='type')?'type':'profile';
|
|
3198
3265
|
let collapsedCats=new Set((()=>{try{return JSON.parse(localStorage.getItem('floless.legendCollapsed')||'[]');}catch{return [];}})());
|
|
3266
|
+
let legendQuery=''; // transient objects-list search filter (members + connections only) — NOT persisted
|
|
3267
|
+
// While a search is active, object categories (member types + connections) render EXPANDED so a match inside a
|
|
3268
|
+
// manually-collapsed category still surfaces — WITHOUT mutating the persisted collapsedCats.
|
|
3269
|
+
function catForceOpen(cat){return !!legendQuery&&(MEMBER_TYPES.some(t=>t.k===cat)||/^conn-/.test(cat));}
|
|
3199
3270
|
function saveLegendPrefs(){try{localStorage.setItem('floless.legendMode',legendMode);localStorage.setItem('floless.legendCollapsed',JSON.stringify([...collapsedCats]));}catch{}}
|
|
3200
3271
|
// Drag a typed member row onto another type category to retype it. Pointer Events (NOT the HTML5 drag API,
|
|
3201
3272
|
// which paints a white browser ghost on Windows). A 6px threshold tells a drag from the row's click(hide) /
|
|
@@ -3232,21 +3303,27 @@ const DIM_LABEL=Object.fromEntries(DIM_CATS);
|
|
|
3232
3303
|
// edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
|
|
3233
3304
|
const DIM_CONN=[{ct:'base-plate',label:'Base-plate',cats:['base_plate','anchor_depth']},{ct:'shear-plate',label:'Shear-plate',cats:['bolt_pitch','edge_clearance','cope_size']}];
|
|
3234
3305
|
function build3DLegend(){const host=document.getElementById('m3dLegend');if(!host||!window.Steel3DView)return;
|
|
3306
|
+
if(!host._ctxWired){host._ctxWired=true;host.addEventListener('contextmenu',e=>e.preventDefault());} // right-click does nothing now (menu removed) — suppress the native OS menu so it never leaks over the dark theme
|
|
3235
3307
|
const groups=window.Steel3DView.getGroups();host.replaceChildren();
|
|
3236
3308
|
if(!groups.length){host.style.display='none';return;}
|
|
3237
|
-
const hint=document.createElement('div');hint.className='lhint';hint.textContent='click hide
|
|
3309
|
+
const hint=document.createElement('div');hint.className='lhint';hint.textContent='click to select · box = show/hide (selection) · dbl-click = isolate (selection) · Ctrl/Shift to multi-select';host.appendChild(hint);
|
|
3238
3310
|
const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
|
|
3239
3311
|
if(draggable){const dh=document.createElement('span');dh.className='drag-handle';dh.textContent='⠿';dh.dataset.tip='Drag onto another type';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);} // handle = the only drag initiator; swallow its own clicks so it never toggles the row
|
|
3240
|
-
const sw=document.createElement('span');sw.className='lsw';sw.style.
|
|
3312
|
+
const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',g.color);sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';
|
|
3241
3313
|
row.append(sw,document.createTextNode(g.label));
|
|
3242
|
-
row.
|
|
3243
|
-
|
|
3314
|
+
// Show/hide lives on the BOX; dbl-click a row isolates. When the row is part of a multi-selection, the box toggles
|
|
3315
|
+
// ALL selected together and the dbl-click isolates the whole selection. stopPropagation on the box so a dbl-click
|
|
3316
|
+
// landing on it doesn't also fire row-isolate.
|
|
3317
|
+
sw.addEventListener('click',e=>{e.stopPropagation();legendBoxToggle(row);});
|
|
3318
|
+
sw.addEventListener('dblclick',e=>e.stopPropagation());
|
|
3319
|
+
row.addEventListener('dblclick',e=>{e.preventDefault();clearTimeout(leg3dClickT);if(row.classList.contains('lsel'))legendIsolateSel();else legendIsolate(g.key,e);});
|
|
3320
|
+
row.addEventListener('click',e=>legendRowClick(e,row)); // click the name to SELECT (Ctrl/Cmd add · Shift range); plain click is deferred so a dbl-click isolates instead
|
|
3244
3321
|
if(draggable)wireRowDrag(row,g);
|
|
3245
3322
|
host.appendChild(row);return row;};
|
|
3246
3323
|
// A collapsible legend category: chevron (collapse) + tri-state master on/off (■/□/◪) + label + count.
|
|
3247
3324
|
// getState()→'on'|'off'|'mixed' drives the master glyph; onToggle() runs the master action (refresh follows).
|
|
3248
3325
|
const buildCatHeader=(cat,label,count,opts)=>{opts=opts||{};const hdr=document.createElement('div');hdr.className='cat-hdr'+(opts.empty?' empty':'')+(opts.sub?' sub':'');hdr.dataset.cat=cat;hdr._getState=opts.getState;
|
|
3249
|
-
const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:
|
|
3326
|
+
const catOpen=!collapsedCats.has(cat)||catForceOpen(cat);const chev=Object.assign(document.createElement('span'),{className:'cat-chevron',textContent:catOpen?'▼':'▶'});
|
|
3250
3327
|
const tog=Object.assign(document.createElement('span'),{className:'cat-tog'});tog.dataset.tip=opts.toggleTitle||('Show / hide all '+label.toLowerCase());if(opts.empty||!opts.onToggle)tog.style.display='none';
|
|
3251
3328
|
const lab=Object.assign(document.createElement('span'),{className:'cat-label',textContent:label});
|
|
3252
3329
|
const cnt=Object.assign(document.createElement('span'),{className:'cat-count',textContent:'('+count+')'});
|
|
@@ -3266,12 +3343,28 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3266
3343
|
mode.appendChild(b);}
|
|
3267
3344
|
host.insertBefore(mode,host.firstChild);
|
|
3268
3345
|
}
|
|
3346
|
+
if(members.length||conns.length){ // SEARCH box — narrows object rows (members + connections); shown whenever there are objects to filter
|
|
3347
|
+
const sb=document.createElement('div');sb.className='lsearch'+(legendQuery?' has':'');
|
|
3348
|
+
const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');
|
|
3349
|
+
const NS='http://www.w3.org/2000/svg',svg=document.createElementNS(NS,'svg'); // magnifier built via DOM (no innerHTML), stroked with currentColor so it inherits --mut
|
|
3350
|
+
svg.setAttribute('viewBox','0 0 16 16');svg.setAttribute('width','12');svg.setAttribute('height','12');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.6');svg.setAttribute('stroke-linecap','round');
|
|
3351
|
+
const cir=document.createElementNS(NS,'circle');cir.setAttribute('cx','7');cir.setAttribute('cy','7');cir.setAttribute('r','4.5');
|
|
3352
|
+
const lin=document.createElementNS(NS,'line');lin.setAttribute('x1','10.6');lin.setAttribute('y1','10.6');lin.setAttribute('x2','14');lin.setAttribute('y2','14');
|
|
3353
|
+
svg.append(cir,lin);ico.append(svg);
|
|
3354
|
+
const inp=document.createElement('input');inp.id='legSearch';inp.type='text';inp.placeholder='Search objects…';inp.autocomplete='off';inp.value=legendQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search objects in the list');
|
|
3355
|
+
const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
|
|
3356
|
+
inp.addEventListener('input',()=>onLegendSearchInput(inp.value));
|
|
3357
|
+
inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';onLegendSearchInput('');}else{inp.blur();}}});
|
|
3358
|
+
clr.addEventListener('click',()=>{if(!inp.value&&!legendQuery)return;inp.value='';onLegendSearchInput('');inp.focus();});
|
|
3359
|
+
sb.append(ico,inp,clr);
|
|
3360
|
+
host.insertBefore(sb,hint);
|
|
3361
|
+
}
|
|
3269
3362
|
if(legendMode==='type'&&members.length){ // group the profile-rows under their member-type categories
|
|
3270
3363
|
const byCat=new Map(MEMBER_TYPES.map(t=>[t.k,[]]));
|
|
3271
3364
|
for(const g of members){(byCat.get(categoryOfProfile(g.key))||byCat.get('beam')).push(g);}
|
|
3272
3365
|
for(const {k,label} of MEMBER_TYPES){const gs=byCat.get(k)||[],keys=gs.map(g=>g.key);
|
|
3273
3366
|
host.appendChild(buildCatHeader(k,label,gs.length,{empty:!gs.length,getState:()=>grpState(keys),onToggle:()=>grpToggle(keys),toggleTitle:'Show / hide all '+label.toLowerCase()+'s'}));
|
|
3274
|
-
if(!collapsedCats.has(k))for(const g of gs)addRow(g,true,true);}
|
|
3367
|
+
if(!collapsedCats.has(k)||catForceOpen(k))for(const g of gs)addRow(g,true,true);}
|
|
3275
3368
|
} else for(const g of members)addRow(g);
|
|
3276
3369
|
if(conns.length){ // group connection PARTS under their joint (Phase 2): each part-kind a row, hidden per-id
|
|
3277
3370
|
if(members.length){host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));}
|
|
@@ -3290,10 +3383,11 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3290
3383
|
getState:()=>{const h=hiddenSet(),n=allIds.filter(id=>h.has(id)).length;return n===0?'on':(n===allIds.length?'off':'mixed');},
|
|
3291
3384
|
onToggle:()=>{const h=hiddenSet();window.Steel3DView.setIdsHidden(allIds,allIds.every(id=>!h.has(id)));}, // all-on → hide all; else show all
|
|
3292
3385
|
toggleTitle:'Show / hide the '+label.toLowerCase()+' connection'}));
|
|
3293
|
-
if(!collapsedCats.has(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
|
|
3386
|
+
if(!collapsedCats.has(ck)||catForceOpen(ck))for(const [grp,ids] of pk){const m=meta.get(grp)||{label:grp,color:'#94a3b8'};
|
|
3294
3387
|
const row=document.createElement('div');row.className='lrow typed';row.dataset.connkey=ck+':'+grp;row._ids=ids;
|
|
3295
|
-
const sw=document.createElement('span');sw.className='lsw';sw.style.
|
|
3296
|
-
|
|
3388
|
+
const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',m.color);sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';row.append(sw,document.createTextNode(m.label));
|
|
3389
|
+
sw.addEventListener('click',e=>{e.stopPropagation();legendBoxToggle(row);});sw.addEventListener('dblclick',e=>e.stopPropagation()); // box toggles this connection's parts (or the whole selection when this row is selected)
|
|
3390
|
+
row.addEventListener('click',e=>legendRowClick(e,row)); // click the row to select these connection parts (Ctrl/Shift multi)
|
|
3297
3391
|
host.appendChild(row);}
|
|
3298
3392
|
}
|
|
3299
3393
|
}
|
|
@@ -3303,11 +3397,12 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3303
3397
|
// isolate), and a gentler off-state — off is a normal resting choice here, not a hidden-part warning.
|
|
3304
3398
|
const ov=C.dim_overlays||{};
|
|
3305
3399
|
const addDimRow=(cat,label,sub)=>{const row=document.createElement('div');row.className='lrow dim typed'+(sub?' sub':'');row.dataset.dim=cat;
|
|
3306
|
-
const sw=document.createElement('span');sw.className='lsw';
|
|
3400
|
+
const sw=document.createElement('span');sw.className='lsw';sw.setAttribute('role','checkbox');sw.dataset.tip='Show / hide';sw.setAttribute('aria-checked',String(ov[cat]!==false));
|
|
3307
3401
|
row.append(sw,document.createTextNode(label));
|
|
3308
3402
|
row.classList.toggle('dimoff',ov[cat]===false);
|
|
3309
|
-
// toggle the overlay; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
|
|
3310
|
-
|
|
3403
|
+
// toggle the overlay from the BOX; persist DIRECTLY (model-global, like dims3d — never via edit(), which would snapshot a per-plan undo)
|
|
3404
|
+
sw.addEventListener('click',e=>{e.stopPropagation();const on=C.dim_overlays[cat]!==false;C.dim_overlays[cat]=!on;row.classList.toggle('dimoff',on);sw.setAttribute('aria-checked',String(!on));scheduleSave();refreshOverlayDims3d();});
|
|
3405
|
+
sw.addEventListener('dblclick',e=>e.stopPropagation());
|
|
3311
3406
|
host.appendChild(row);};
|
|
3312
3407
|
if(members.length||conns.length)host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
|
|
3313
3408
|
const dimState=()=>{const on=DIM_CATS.filter(([k])=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===DIM_CATS.length?'on':'mixed');};
|
|
@@ -3315,7 +3410,7 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3315
3410
|
const dcState=cats=>{const on=cats.filter(k=>C.dim_overlays[k]!==false).length;return on===0?'off':(on===cats.length?'on':'mixed');};
|
|
3316
3411
|
const dcToggle=cats=>{const anyOn=cats.some(k=>C.dim_overlays[k]!==false);for(const k of cats)C.dim_overlays[k]=!anyOn;scheduleSave();refreshOverlayDims3d();build3DLegend();};
|
|
3317
3412
|
host.appendChild(buildCatHeader('dims','Dimensions',DIM_CATS.length,{getState:dimState,onToggle:dimToggle,toggleTitle:'Show / hide all dimension overlays'}));
|
|
3318
|
-
if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click
|
|
3413
|
+
if(!collapsedCats.has('dims')){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'click the box to show / hide'}));
|
|
3319
3414
|
for(const dc of DIM_CONN){const ck='dims-'+dc.ct; // middle category: overlays grouped by connection
|
|
3320
3415
|
host.appendChild(buildCatHeader(ck,dc.label,dc.cats.length,{sub:true,getState:()=>dcState(dc.cats),onToggle:()=>dcToggle(dc.cats),toggleTitle:'Show / hide all '+dc.label.toLowerCase()+' dimensions'}));
|
|
3321
3416
|
if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
|
|
@@ -3326,10 +3421,10 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3326
3421
|
if(typeof P!=='undefined'&&P&&P.grid){
|
|
3327
3422
|
host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
|
|
3328
3423
|
const grow=document.createElement('div');grow.className='lrow dim';grow.dataset.tip='Show / hide the structural grid (2D + 3D)';
|
|
3329
|
-
const gsw=document.createElement('span');gsw.className='lsw';gsw.style.
|
|
3424
|
+
const gsw=document.createElement('span');gsw.className='lsw';gsw.style.setProperty('--sw','#64748b');gsw.setAttribute('role','checkbox');gsw.dataset.tip='Show / hide';gsw.setAttribute('aria-checked',String(gridOn()));
|
|
3330
3425
|
grow.append(gsw,document.createTextNode('Grid lines'));
|
|
3331
3426
|
grow.classList.toggle('dimoff',!gridOn());
|
|
3332
|
-
|
|
3427
|
+
gsw.addEventListener('click',e=>{e.stopPropagation();gridSetVisible(!gridOn());});gsw.addEventListener('dblclick',e=>e.stopPropagation());
|
|
3333
3428
|
host.appendChild(grow);
|
|
3334
3429
|
}
|
|
3335
3430
|
// CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
|
|
@@ -3343,22 +3438,22 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3343
3438
|
if(collapsedCats.has('clip')){/* collapsed → no rows */}
|
|
3344
3439
|
else if(!clips.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lhint',textContent:'(no clips)'}));}
|
|
3345
3440
|
else for(const c of clips){
|
|
3346
|
-
//
|
|
3347
|
-
const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':''); //
|
|
3348
|
-
const sw=document.createElement('span');sw.className='lsw';sw.style.
|
|
3441
|
+
// Box = ENABLE / disable (filled = cutting, hollow = off); label = SELECT (click) / RENAME (dbl-click); × = DELETE.
|
|
3442
|
+
const row=document.createElement('div');row.className='lrow clip typed'+(c.selected?' sel':'')+(c.enabled?'':' off'); // disabled → .off hollows the box + dims the row, like a hidden part
|
|
3443
|
+
const sw=document.createElement('span');sw.className='lsw';sw.style.setProperty('--sw',c.kind==='box'?'#93c5fd':'#3b82f6');sw.setAttribute('role','checkbox');sw.setAttribute('aria-checked',String(!!c.enabled));sw.dataset.tip='Enable / disable this clip'; // box = lighter blue, plane = brand blue
|
|
3349
3444
|
const lab=document.createElement('span');lab.className='clab';lab.textContent=c.label;lab.dataset.tip='Click to select · double-click to rename';
|
|
3350
|
-
const tog=document.createElement('button');tog.className='cpill'+(c.enabled?' on':'');tog.textContent=c.enabled?'On':'Off';tog.dataset.tip='Enable / disable this clip';
|
|
3351
3445
|
const x=document.createElement('span');x.className='lx';x.textContent='×';x.dataset.tip='Delete this clip';
|
|
3352
|
-
row.append(sw,lab,
|
|
3353
|
-
sw.addEventListener('click',e=>{e.stopPropagation();
|
|
3446
|
+
row.append(sw,lab,x);
|
|
3447
|
+
sw.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);}); // box toggles enable; selecting (which reveals the 3D drag handles) is the label's job
|
|
3448
|
+
sw.addEventListener('dblclick',e=>e.stopPropagation());
|
|
3354
3449
|
let clipClickT=null;
|
|
3355
3450
|
lab.addEventListener('click',e=>{e.stopPropagation();clearTimeout(clipClickT);const ev={ctrlKey:e.ctrlKey,metaKey:e.metaKey,shiftKey:e.shiftKey};clipClickT=setTimeout(()=>clipSelect(c.id,ev),200);}); // deferred so a double-click (rename) can cancel the select
|
|
3356
3451
|
lab.addEventListener('dblclick',e=>{e.stopPropagation();e.preventDefault();clearTimeout(clipClickT);startClipRename(c,lab);});
|
|
3357
|
-
tog.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.toggleClip(c.id);});
|
|
3358
3452
|
x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
|
|
3359
3453
|
host.appendChild(row);
|
|
3360
3454
|
}
|
|
3361
|
-
|
|
3455
|
+
{const rst=Object.assign(document.createElement('div'),{className:'lreset',id:'m3dLegendReset',textContent:'Show all'});rst.setAttribute('role','button');rst.dataset.tip='Restore every hidden / isolated object in this panel';rst.addEventListener('click',legendReset);host.insertBefore(rst,host.firstChild);} // Show-all reset — panel's first child (above the mode toggle); visibility set by updateLegendReset (via refresh3DLegend)
|
|
3456
|
+
host.style.display='flex';refresh3DLegend();applyLegendFilter();refreshLegendSel();}
|
|
3362
3457
|
// The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
|
|
3363
3458
|
// "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
|
|
3364
3459
|
function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
|
|
@@ -3380,10 +3475,81 @@ function updateWorkBtn(){const b=document.getElementById('m3dWork'),ck=document.
|
|
|
3380
3475
|
function updateCatTog(hdr){const tog=hdr&&hdr.querySelector('.cat-tog');if(!tog||!hdr._getState||tog.style.display==='none')return;
|
|
3381
3476
|
const state=hdr._getState();tog.dataset.state=state;tog.textContent=state==='on'?'■':(state==='off'?'□':'◪');}
|
|
3382
3477
|
function refresh3DLegend(){if(!window.Steel3DView)return;const st=window.Steel3DView.groupState(),hidden=new Set(st.hidden),solo=new Set(st.solo);
|
|
3383
|
-
document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;
|
|
3478
|
+
document.querySelectorAll('#m3dLegend .lrow[data-key]').forEach(r=>{const k=r.dataset.key;const off=hidden.has(k)||(solo.size>0&&!solo.has(k));r.classList.toggle('off',off);r.classList.toggle('solo',solo.size>0&&solo.has(k));const sw=r.querySelector('.lsw');if(sw)sw.setAttribute('aria-checked',String(!off));}); // PROFILE rows (data-key); .off = hidden or outside the isolated set → box hollows; .solo = inside it (Explorer-style highlight)
|
|
3384
3479
|
const ch=new Set(window.Steel3DView.connHiddenIds?window.Steel3DView.connHiddenIds():[]); // per-part connection hide
|
|
3385
|
-
document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];
|
|
3386
|
-
document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);} // refresh the type-category master toggles
|
|
3480
|
+
document.querySelectorAll('#m3dLegend .lrow[data-connkey]').forEach(r=>{const ids=r._ids||[];const off=ids.length>0&&ids.every(id=>ch.has(id));r.classList.toggle('off',off);const sw=r.querySelector('.lsw');if(sw)sw.setAttribute('aria-checked',String(!off));});
|
|
3481
|
+
document.querySelectorAll('#m3dLegend .cat-hdr').forEach(updateCatTog);updateLegendReset();} // refresh the type-category master toggles + the show-all reset bar's visibility
|
|
3482
|
+
// ── Objects-list SELECTION — click a row (its name) to select its object[s] in 3D; Ctrl/Cmd add/remove, Shift range ──
|
|
3483
|
+
// Plain click is deferred (leg3dClickT) so a dbl-click can isolate instead; a modified click selects immediately
|
|
3484
|
+
// (so rapid Ctrl-clicking several rows doesn't lose one to the shared timer). Box clicks stopPropagation, so they never reach here.
|
|
3485
|
+
function legendRowClick(e,row){if(row&&row._dragging)return;const mods={ctrl:e.ctrlKey||e.metaKey,shift:e.shiftKey};clearTimeout(leg3dClickT);if(mods.ctrl||mods.shift)legendSelect(row,mods);else leg3dClickT=setTimeout(()=>legendSelect(row,mods),220);}
|
|
3486
|
+
function legendSelect(row,mods){
|
|
3487
|
+
if(!row)return;const ids=legRowIds(row);if(!ids.length)return;
|
|
3488
|
+
if(mods&&mods.shift&&legendSelAnchor&&document.body.contains(legendSelAnchor)){ // Shift → union every VISIBLE object row from the anchor to here (a search-hidden row can't be range-selected)
|
|
3489
|
+
const rows=[...document.querySelectorAll('#m3dLegend .lrow[data-key]:not(.qhide),#m3dLegend .lrow[data-connkey]:not(.qhide)')];
|
|
3490
|
+
const i0=rows.indexOf(legendSelAnchor),i1=rows.indexOf(row);
|
|
3491
|
+
if(i0>=0&&i1>=0){const next=new Set();rows.slice(Math.min(i0,i1),Math.max(i0,i1)+1).forEach(r=>legRowIds(r).forEach(id=>next.add(id)));selIds=next;selDimIds.clear();sel3dDimIds.clear();render();return;}
|
|
3492
|
+
}
|
|
3493
|
+
if(mods&&mods.ctrl){const next=new Set(selIds);const all=ids.every(id=>next.has(id));ids.forEach(id=>all?next.delete(id):next.add(id));selIds=next;} // Ctrl → toggle this group in/out of the selection
|
|
3494
|
+
else selIds=new Set(ids); // plain → replace the selection
|
|
3495
|
+
legendSelAnchor=row;selDimIds.clear();sel3dDimIds.clear();render();
|
|
3496
|
+
}
|
|
3497
|
+
// A row lights up (.lsel) when EVERY object it represents is selected — so a legend click that selects the whole group shows it. Synced from render()'s 3D block + build3DLegend.
|
|
3498
|
+
function refreshLegendSel(){const host=document.getElementById('m3dLegend');if(!host||host.style.display==='none')return;
|
|
3499
|
+
host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]').forEach(r=>{const ids=legRowIds(r);r.classList.toggle('lsel',ids.length>0&&ids.every(id=>selIds.has(id)));});}
|
|
3500
|
+
// Click a row's BOX → show/hide. If that row is part of the current selection, the box acts on EVERY selected row
|
|
3501
|
+
// (the clicked row's current state drives the direction). Members toggle by group key, connections by part id.
|
|
3502
|
+
function legendBoxToggle(row){if(!row||!window.Steel3DView)return;
|
|
3503
|
+
const rows=row.classList.contains('lsel')?[...document.querySelectorAll('#m3dLegend .lrow.lsel')]:[row];
|
|
3504
|
+
const willHide=!row.classList.contains('off'); // one direction for all: hide if the clicked box was shown, else show
|
|
3505
|
+
const keys=[],ids=[];for(const r of rows){if(r.dataset.connkey)ids.push(...(r._ids||[]));else if(r.dataset.key)keys.push(r.dataset.key);}
|
|
3506
|
+
if(keys.length&&window.Steel3DView.setGroupsHidden)window.Steel3DView.setGroupsHidden(keys,willHide);
|
|
3507
|
+
if(ids.length&&window.Steel3DView.setIdsHidden)window.Steel3DView.setIdsHidden(ids,willHide);
|
|
3508
|
+
refresh3DLegend();}
|
|
3509
|
+
// Dbl-click a SELECTED row → isolate the whole selection (all selected profile groups, Explorer-style solo). Only
|
|
3510
|
+
// reachable from a member row's dbl-click (connection rows have no dbl-click), so the double-clicked member's own
|
|
3511
|
+
// key is always present — no empty-keys path.
|
|
3512
|
+
function legendIsolateSel(){if(!window.Steel3DView)return;
|
|
3513
|
+
const keys=[...document.querySelectorAll('#m3dLegend .lrow.lsel[data-key]')].map(r=>r.dataset.key);
|
|
3514
|
+
if(keys.length){window.Steel3DView.setSoloGroups(keys);refresh3DLegend();}}
|
|
3515
|
+
// "Show all" reset — a panel-local door onto showAllGroups() (which already clears box-hides, solo, isolate AND
|
|
3516
|
+
// per-connection hides in one call, then refreshes). Does NOT clear the search filter — the search box's own × owns that.
|
|
3517
|
+
function legendReset(){if(!window.Steel3DView)return;window.Steel3DView.showAllGroups();if(window.Steel3DView.clearIsolation)window.Steel3DView.clearIsolation();}
|
|
3518
|
+
// Show the reset bar only when 3D visibility is non-default (something hidden / solo'd / isolated). Indifferent to search.
|
|
3519
|
+
function updateLegendReset(){const b=document.getElementById('m3dLegendReset');if(!b||!window.Steel3DView)return;
|
|
3520
|
+
const st=window.Steel3DView.groupState();
|
|
3521
|
+
const filtered=(st.hidden&&st.hidden.length>0)||(st.solo&&st.solo.length>0)||(window.Steel3DView.isIsolated&&window.Steel3DView.isIsolated())||(window.Steel3DView.connHiddenIds&&window.Steel3DView.connHiddenIds().length>0);
|
|
3522
|
+
b.classList.toggle('show',filtered);}
|
|
3523
|
+
// ── Objects-list SEARCH ──────────────────────────────────────────────────────────────────────────────────────
|
|
3524
|
+
// Search narrows the MEMBER + CONNECTION rows only (never Dimensions/Grid/Clip, never the 3D scene). Crossing
|
|
3525
|
+
// empty↔non-empty rebuilds once (so collapsed categories force-expand and their matches can surface); refining
|
|
3526
|
+
// within an active query is a cheap show/hide pass that keeps the input focused.
|
|
3527
|
+
function onLegendSearchInput(q){
|
|
3528
|
+
const prev=legendQuery;legendQuery=q;
|
|
3529
|
+
if((!!prev)!==(!!q)){build3DLegend();const inp=document.getElementById('legSearch');if(inp){inp.focus();try{inp.setSelectionRange(inp.value.length,inp.value.length);}catch(_){}}}
|
|
3530
|
+
else applyLegendFilter();
|
|
3531
|
+
}
|
|
3532
|
+
// Show/hide object rows by label; hide object categories left with no visible child; toggle the "no matches" line.
|
|
3533
|
+
function applyLegendFilter(){
|
|
3534
|
+
const host=document.getElementById('m3dLegend');if(!host)return;
|
|
3535
|
+
const q=(legendQuery||'').trim().toLowerCase();
|
|
3536
|
+
const old=host.querySelector('.lsempty');if(old)old.remove();
|
|
3537
|
+
const rows=[...host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]')];
|
|
3538
|
+
if(!q){rows.forEach(r=>r.classList.remove('qhide'));host.querySelectorAll('.cat-hdr.qhide').forEach(h=>h.classList.remove('qhide'));return;}
|
|
3539
|
+
let any=false;
|
|
3540
|
+
rows.forEach(r=>{const hit=(r.textContent||'').toLowerCase().includes(q);r.classList.toggle('qhide',!hit);if(hit)any=true;});
|
|
3541
|
+
host.querySelectorAll('.cat-hdr').forEach(h=>{const cat=h.dataset.cat||'';
|
|
3542
|
+
if(!(MEMBER_TYPES.some(t=>t.k===cat)||/^conn-/.test(cat)))return; // leave the Dimensions/Clip headers untouched
|
|
3543
|
+
let vis=false;
|
|
3544
|
+
for(let n=h.nextElementSibling;n&&!n.classList.contains('cat-hdr')&&!n.classList.contains('lsec')&&!n.classList.contains('ldiv');n=n.nextElementSibling){
|
|
3545
|
+
if((n.matches('.lrow[data-key]')||n.matches('.lrow[data-connkey]'))&&!n.classList.contains('qhide')){vis=true;break;}}
|
|
3546
|
+
h.classList.toggle('qhide',!vis);});
|
|
3547
|
+
if(!any){const hint=host.querySelector('.lhint');const e=Object.assign(document.createElement('div'),{className:'lsempty',textContent:'No objects match “'+legendQuery.trim()+'”.'});
|
|
3548
|
+
if(hint&&hint.nextSibling)host.insertBefore(e,hint.nextSibling);else host.appendChild(e);}
|
|
3549
|
+
}
|
|
3550
|
+
// Resolve a row to the member/connection ids it represents. A member row's data-key is a profile key; a connection
|
|
3551
|
+
// row carries its part ids on row._ids. Used by legend click-to-select + the box/isolate SELECTION actions below.
|
|
3552
|
+
function legRowIds(row){if(!row)return [];if(row.dataset.connkey)return (row._ids||[]).slice();const k=row.dataset.key;if(!k)return [];return (P.members||[]).filter(m=>profileKeyOf(m)===k).map(m=>m.id);}
|
|
3387
3553
|
let bar3dWired=false;
|
|
3388
3554
|
function seg3dActive(sel,attr,val){document.querySelectorAll(sel+' button').forEach(b=>b.classList.toggle('on',b.getAttribute(attr)===val));}
|
|
3389
3555
|
// Reflect the live projection / display mode into the Camera + Display dropdowns: tick the active menu item AND label the trigger button, so the current mode shows without opening the menu.
|
|
@@ -3884,10 +4050,9 @@ function _wt(profile){if(!profile)return null;
|
|
|
3884
4050
|
if(WT){const h=Object.entries(WT).find(([k])=>k.toUpperCase()===profile.toUpperCase());if(h&&h[1]!=null)return h[1];}
|
|
3885
4051
|
return _nominalPlf(profile);} // fall back to the lb/ft encoded in a standard designation
|
|
3886
4052
|
const _isMf=p=>!!p&&/(^|[^A-Z])MF($|[^A-Z])/i.test(p);
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
const out=new Set();for(const k in g){const grp=g[k];if(grp.length<2)continue;grp.sort((a,b)=>rank(b)-rank(a));for(let i=1;i<grp.length;i++)out.add(grp[i].id);}return out;}
|
|
4053
|
+
// Elevation-aware coincident dedupe for the browser confidence panel — same footprint+wildcard-elevation logic as
|
|
4054
|
+
// redundantDups / the server, so the in-browser confidence report and the server score agree on stacked members.
|
|
4055
|
+
function _confDupIds(members){return new Set(dedupeFootprintIds(members,m=>{let s=0;if(m.profile&&!_isMf(m.profile))s++;if(m.profile&&m.profile.trim()!=='')s++;return s;}));}
|
|
3891
4056
|
function _elevAssumed(m){if(m.role==='column')return !(m.col&&m.col.tosDef===false);const en=m.ends||[];if(!en.length)return true;return en.some(e=>e.tosDef!==false);}
|
|
3892
4057
|
function _scoreMember(m,dup){const plf=_wt(m.profile);const F=[];
|
|
3893
4058
|
if(plf==null){F.push({key:'profile',label:'Profile',state:'fail',detail:(!m.profile||!m.profile.trim())?'no profile assigned':_isMf(m.profile)?('unresolved mark "'+m.profile+'" — not an AISC size'):('"'+m.profile+'" not in the AISC weight table')});return {band:'rfi',factors:F};}
|