@floless/app 0.79.0 → 0.81.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
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.81.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.81.0" : void 0 });
|
|
53107
53107
|
}
|
|
53108
53108
|
|
|
53109
53109
|
// workflow-update.ts
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"joints": { "type": "array", "items": { "$ref": "#/$defs/joint" }, "description": "Placed connections (base plates, …) the connection engine expands into real 3D parts (plate/bolt/weld/cut) bound to their members. A joint may cite a `connections[]` row via `conn` for its type/detail identity; `params` carries the geometry settings (engine defaults when absent)." },
|
|
43
43
|
"dims3d": { "type": "array", "items": { "$ref": "#/$defs/dim3" }, "description": "Draft-only 3D dimensions (editor annotations, model-global). World-scene mm. NOT baked into the lock / 3D scene / IFC / BOM." },
|
|
44
44
|
"detail_placements": { "type": "array", "items": { "$ref": "#/$defs/detailPlacement" }, "description": "Draft-only placed 2D detail images (Slice 4 — insert a vectored detail into the model). Flat image planes the editor renders client-side against the model; metadata only — the image bytes live in custom_details. Model-global. NOT baked into the lock / 3D scene / IFC / BOM." },
|
|
45
|
+
"views": { "type": "array", "description": "Draft-only saved views (Views Organizer — up to 10 per model): each a named snapshot of the 3D viewpoint (camera + projection + display mode + clip boxes/planes + work area + the objects-list pane's persistent visibility/isolate state). Editor state, model-global. NOT baked into the lock / 3D scene / IFC / BOM.", "items": { "type": "object", "additionalProperties": true, "required": ["id", "name"], "properties": { "id": { "type": "string" }, "name": { "type": "string" }, "order": { "type": "number" }, "camera": { "type": "object", "additionalProperties": true }, "projection": { "enum": ["persp", "ortho"] }, "mode": { "enum": ["solid", "wire", "xray"] }, "clips": { "type": "object", "additionalProperties": true }, "objects": { "type": "object", "additionalProperties": true } } } },
|
|
45
46
|
"prop_labels": {
|
|
46
47
|
"type": "object",
|
|
47
48
|
"additionalProperties": true,
|
|
@@ -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)';
|
|
@@ -26,17 +26,24 @@ let boxSel = null, rubber = null; // LEFT-drag-on-empty rubber-band mul
|
|
|
26
26
|
let hoverId = null, hoverChip = null, hoverRAF = 0, lastHoverXY = null; // hover readout + cursor
|
|
27
27
|
let epGroup = null; // yellow/magenta endpoint markers (start/end direction dots)
|
|
28
28
|
let epGeom = null, epMatStart = null, epMatEnd = null; // shared across all endpoint dots (no per-rebuild churn)
|
|
29
|
-
let epRing = null, epPreview = null; // white ring on the active end node +
|
|
29
|
+
let epRing = null, epPreview = null, epMovedLine = null; // white ring on the active end node + member rubber (epPreview) + the "moved" leader (epMovedLine) while dragging an end
|
|
30
30
|
let hoverEp = null, dragEp = null; // {id,end} of the hovered / dragged end node
|
|
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
|
|
37
41
|
let propLabelHost = null; // fixed-position container for the right-click property labels (positioned each frame)
|
|
38
42
|
const propLabelPool = []; // reused multi-line <div> label chips, one per labelled member
|
|
39
43
|
let propLabelSpec = null; // { labels:[{id, lines:[...]}], placement } pushed from the editor (owns the text); this view owns projection/placement
|
|
44
|
+
let selLenLabelHost = null; // fixed-position container for the on-select member-length labels (positioned each frame)
|
|
45
|
+
const selLenLabelPool = []; // reused <div> length chips, one per selected member
|
|
46
|
+
let selLenLabelSpec = null; // [{id, text}] pushed from the editor when "Show member length" is on (⋯ Display)
|
|
40
47
|
const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
|
|
41
48
|
let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
|
|
42
49
|
let displayMode = 'solid'; // solid | wire | xray
|
|
@@ -133,6 +140,9 @@ function init(canvas, theApi) {
|
|
|
133
140
|
// rubber preview line from the fixed end to the dragged end node
|
|
134
141
|
epPreview = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineBasicMaterial({ color: 0x3b82f6 }));
|
|
135
142
|
epPreview.material.depthTest = false; epPreview.renderOrder = 997; epPreview.visible = false; scene.add(epPreview);
|
|
143
|
+
// the "moved" leader while dragging an end: a cyan dashed line from where the end started to the cursor (the 3D echo of the 2D Move rubber). World-space dashes → computeLineDistances() after each setFromPoints.
|
|
144
|
+
epMovedLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3()]), new THREE.LineDashedMaterial({ color: 0x22d3ee, dashSize: 55, gapSize: 35 }));
|
|
145
|
+
epMovedLine.material.depthTest = false; epMovedLine.renderOrder = 998; epMovedLine.visible = false; scene.add(epMovedLine);
|
|
136
146
|
raycaster = new THREE.Raycaster();
|
|
137
147
|
// Snap marker: a camera-facing cyan RETICLE (crosshair ring + per-type inner glyph), not a bare dot —
|
|
138
148
|
// a solid sphere vanished against the dark bg + coloured steel. Cyan #22d3ee matches the 2D snap marker;
|
|
@@ -161,6 +171,9 @@ function init(canvas, theApi) {
|
|
|
161
171
|
document.body.appendChild(memberLabelHost);
|
|
162
172
|
propLabelHost = document.createElement('div'); // right-click property labels — own host so they never clobber the mark/id pool
|
|
163
173
|
propLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
|
|
174
|
+
selLenLabelHost = document.createElement('div'); // ⋯ Display → Show member length: on-select length chips (cyan dimension read)
|
|
175
|
+
selLenLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
|
|
176
|
+
document.body.appendChild(selLenLabelHost);
|
|
164
177
|
document.body.appendChild(propLabelHost);
|
|
165
178
|
// persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
|
|
166
179
|
hoverChip = document.createElement('div');
|
|
@@ -207,6 +220,7 @@ function loop() {
|
|
|
207
220
|
positionOverlayLabels();
|
|
208
221
|
positionMemberLabels();
|
|
209
222
|
positionPropLabels();
|
|
223
|
+
positionSelLenLabels();
|
|
210
224
|
renderer.render(scene, camera);
|
|
211
225
|
if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
|
|
212
226
|
const saved = renderer.clippingPlanes;
|
|
@@ -631,6 +645,57 @@ function setInsertMode(on, pending) {
|
|
|
631
645
|
if (api && api.onInsertModeChange) api.onInsertModeChange(insertMode);
|
|
632
646
|
}
|
|
633
647
|
function insertModeOn() { return insertMode; }
|
|
648
|
+
|
|
649
|
+
// Mode B: arm a "trim column to base" pick on `colId`. The level datums are derived from the OTHER members'
|
|
650
|
+
// rendered top/bottom (world mm, so no display→mm conversion), each labelled by its member mark, so the pick
|
|
651
|
+
// snaps the base to a real framing level. A one-shot left-click hands the picked base elevation to the editor
|
|
652
|
+
// via api.onBasePick. Mirrors setInsertMode.
|
|
653
|
+
function setBasePickMode(colId) {
|
|
654
|
+
basePickCol = colId || null;
|
|
655
|
+
basePickLevels = null;
|
|
656
|
+
if (basePickCol) {
|
|
657
|
+
setInsertMode(false); if (api && api.disarmTransform) api.disarmTransform(); if (api && api.disarmAdd) api.disarmAdd(); setClipMode(null);
|
|
658
|
+
root.updateMatrixWorld(true);
|
|
659
|
+
basePickLevels = [];
|
|
660
|
+
const _b = new THREE.Box3();
|
|
661
|
+
for (const [id, m] of meshById) {
|
|
662
|
+
// MEMBER meshes only (their id is a plain mark): a ':' marks a connection part (`joint:part`) or a
|
|
663
|
+
// detail (`det:…`), so those are excluded — the base must snap to framing levels, not the moving
|
|
664
|
+
// base-plate/anchor/weld hardware of the very connection being re-seated.
|
|
665
|
+
if (id === basePickCol || !m.visible || String(id).includes(':')) continue;
|
|
666
|
+
_b.setFromObject(m); if (_b.isEmpty()) continue;
|
|
667
|
+
basePickLevels.push({ z: Math.round(_b.max.z), label: id + ' top' }, { z: Math.round(_b.min.z), label: id + ' base' });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
if (canvasEl) canvasEl.style.cursor = basePickCol ? 'crosshair' : 'default';
|
|
671
|
+
if (!basePickCol) { if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; }
|
|
672
|
+
if (api && api.onBasePickModeChange) api.onBasePickModeChange(basePickModeOn());
|
|
673
|
+
}
|
|
674
|
+
function basePickModeOn() { return !!basePickCol; }
|
|
675
|
+
// A point along the target column's VERTICAL axis at the cursor, elevation snapped to the level datums.
|
|
676
|
+
// Intersect the pick ray with a vertical plane through the column axis facing the camera — so a click
|
|
677
|
+
// anywhere near the (thin) column reads as an elevation on its reference line. Returns { z, snapped, label, x, y }.
|
|
678
|
+
function basePickPoint(cx, cy) {
|
|
679
|
+
const col = meshById.get(basePickCol); if (!col) return null;
|
|
680
|
+
camera.updateMatrixWorld(); root.updateMatrixWorld(true);
|
|
681
|
+
const box = new THREE.Box3().setFromObject(col);
|
|
682
|
+
const ax = (box.min.x + box.max.x) / 2, ay = (box.min.y + box.max.y) / 2;
|
|
683
|
+
const rect = canvasEl.getBoundingClientRect(); if (!rect.width || !rect.height) return null;
|
|
684
|
+
const ndc = new THREE.Vector2(((cx - rect.left) / rect.width) * 2 - 1, -((cy - rect.top) / rect.height) * 2 + 1);
|
|
685
|
+
raycaster.setFromCamera(ndc, camera);
|
|
686
|
+
const toCam = new THREE.Vector3(camera.position.x - ax, camera.position.y - ay, 0);
|
|
687
|
+
if (toCam.lengthSq() < 1e-6) toCam.set(1, 0, 0);
|
|
688
|
+
toCam.normalize();
|
|
689
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(toCam, new THREE.Vector3(ax, ay, 0));
|
|
690
|
+
const g = new THREE.Vector3();
|
|
691
|
+
if (!raycaster.ray.intersectPlane(plane, g)) return null;
|
|
692
|
+
// The plane is infinite, so gate the pick to clicks NEAR the column axis (else an accidental empty-canvas
|
|
693
|
+
// click would commit a trim). g lies on the plane through the axis; its horizontal offset from the axis is
|
|
694
|
+
// how far to the side the cursor is.
|
|
695
|
+
if (Math.hypot(g.x - ax, g.y - ay) > pxToWorldAt(BASE_PICK_HIT_PX, g)) return null;
|
|
696
|
+
const s = snapElevation(g.z, basePickLevels, pxToWorldAt(ELEV_SNAP_PX, g)); // screen-scaled tol so the snap window is ~constant on-screen
|
|
697
|
+
return { z: s.z, snapped: s.snapped, label: s.label, x: ax, y: ay };
|
|
698
|
+
}
|
|
634
699
|
// The placement raycast: a member face under the cursor gives the point + its basis + the member id to
|
|
635
700
|
// anchor to; empty space drops the detail on the ground plane (z=0) with a default vertical orientation.
|
|
636
701
|
function insertPick(cx, cy) {
|
|
@@ -735,6 +800,51 @@ function positionPropLabels() {
|
|
|
735
800
|
}
|
|
736
801
|
}
|
|
737
802
|
|
|
803
|
+
// ---- On-select member-length labels (⋯ Display → Show member length): the editor owns the TEXT (true 3D
|
|
804
|
+
// length, formatted) and pushes [{id, text}]; this view owns the anchor (member midpoint) + projection. Cyan
|
|
805
|
+
// chips = a live geometric read, distinct from the brand-blue property chips. Mirrors the property-label pool. ----
|
|
806
|
+
function setSelLenLabels(labels) {
|
|
807
|
+
selLenLabelSpec = (labels && Array.isArray(labels) && labels.length) ? labels : null;
|
|
808
|
+
syncSelLenLabels();
|
|
809
|
+
}
|
|
810
|
+
function syncSelLenLabels() {
|
|
811
|
+
if (!selLenLabelHost || !api) return;
|
|
812
|
+
const labels = selLenLabelSpec || [];
|
|
813
|
+
while (selLenLabelPool.length < labels.length) {
|
|
814
|
+
const el = document.createElement('div');
|
|
815
|
+
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:var(--panel,#0f172a);color:#e2e8f0;border:1px solid #22d3ee;border-radius:4px;padding:1px 6px;font:11px system-ui;white-space:nowrap;box-shadow:0 1px 4px rgba(0,0,0,.4)'; // cyan border = dimension read (matches the 2D .mlenchip)
|
|
816
|
+
selLenLabelHost.appendChild(el); selLenLabelPool.push(el);
|
|
817
|
+
}
|
|
818
|
+
const byId = new Map(members().map((m) => [m.id, m]));
|
|
819
|
+
const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
|
|
820
|
+
for (let i = 0; i < selLenLabelPool.length; i++) {
|
|
821
|
+
const el = selLenLabelPool[i], L = labels[i], m = L ? byId.get(L.id) : null;
|
|
822
|
+
if (!m || !Array.isArray(m.wp) || m.wp.length < 2 || !L.text) { el.style.display = 'none'; el._mid = null; el._memberId = null; continue; }
|
|
823
|
+
const g = memberGeometry(m, ppf, dtos), a = g.line[0], b = g.line[1];
|
|
824
|
+
el._mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
|
|
825
|
+
el._memberId = m.id;
|
|
826
|
+
el.textContent = L.text;
|
|
827
|
+
el.style.display = 'block';
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
function positionSelLenLabels() {
|
|
831
|
+
if (!selLenLabelHost) return;
|
|
832
|
+
if (!selLenLabelSpec || (canvasEl && canvasEl.style.display === 'none')) { selLenLabelHost.style.display = 'none'; return; }
|
|
833
|
+
selLenLabelHost.style.display = 'block';
|
|
834
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
835
|
+
for (const el of selLenLabelPool) {
|
|
836
|
+
if (el.style.display === 'none' || !el._mid) continue;
|
|
837
|
+
const mesh = meshById.get(el._memberId);
|
|
838
|
+
if (mesh && mesh.visible === false) { el.style.visibility = 'hidden'; continue; } // member legend-hidden / isolated away → hide its length too
|
|
839
|
+
if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; } // sectioned away by a clip
|
|
840
|
+
const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
|
|
841
|
+
if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; }
|
|
842
|
+
el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
|
|
843
|
+
el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
|
|
844
|
+
el.style.visibility = 'visible';
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
738
848
|
// ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
|
|
739
849
|
// them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
|
|
740
850
|
// wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
|
|
@@ -968,6 +1078,23 @@ function setSoloGroups(keys) { soloGroups = new Set((keys || []).filter((k) => k
|
|
|
968
1078
|
function soloToggle(k) { setSoloGroups(soloGroups.size === 1 && soloGroups.has(k) ? [] : [k]); } // plain dbl-click: isolate just this group (or clear if it's already the only one)
|
|
969
1079
|
function showAllGroups() { groupHidden.clear(); soloGroups.clear(); isolatedIds = null; connHidden.clear(); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); if (api && api.onIsolateChange) api.onIsolateChange(false); }
|
|
970
1080
|
function groupState() { return { hidden: [...groupHidden], solo: [...soloGroups] }; }
|
|
1081
|
+
// One serialisable snapshot of the objects-list pane's PERSISTENT visibility state — group hide/solo +
|
|
1082
|
+
// isolate-selected + per-part legend hides. This is what a saved view (Views Organizer) captures; it
|
|
1083
|
+
// deliberately excludes the transient legend search (`legendQuery`, editor-side, never persisted) and the
|
|
1084
|
+
// live selection. Versioned + read/applied wholesale so it stays robust as the pane's filtering grows.
|
|
1085
|
+
function objectsPaneState() { return { v: 1, hidden: [...groupHidden], solo: [...soloGroups], isolated: isolatedIds ? [...isolatedIds] : null, connHidden: [...connHidden] }; }
|
|
1086
|
+
// Restore an objectsPaneState() blob — sanitised to arrays (a partial/stale draft can't desync the scene
|
|
1087
|
+
// from the legend, same discipline as the editor's dim_overlays restore), then re-apply visibility with the
|
|
1088
|
+
// exact refresh tail showAllGroups uses.
|
|
1089
|
+
function applyObjectsPaneState(s) {
|
|
1090
|
+
if (!s || typeof s !== 'object') return;
|
|
1091
|
+
groupHidden.clear(); if (Array.isArray(s.hidden)) for (const k of s.hidden) groupHidden.add(k);
|
|
1092
|
+
soloGroups = new Set(Array.isArray(s.solo) ? s.solo : []);
|
|
1093
|
+
isolatedIds = (Array.isArray(s.isolated) && s.isolated.length) ? new Set(s.isolated) : null;
|
|
1094
|
+
connHidden = new Set(Array.isArray(s.connHidden) ? s.connHidden : []);
|
|
1095
|
+
applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims();
|
|
1096
|
+
if (api && api.onIsolateChange) api.onIsolateChange(isolatedIds !== null);
|
|
1097
|
+
}
|
|
971
1098
|
// Tekla "isolate selected": show ONLY the currently-selected parts (a snapshot taken now — distinct from
|
|
972
1099
|
// soloToggle, which isolates a whole PROFILE group from the legend). clearIsolation / showAllGroups restore all.
|
|
973
1100
|
function isolateSelected() { if (!selIds.size) return false; isolatedIds = new Set(selIds); applyGroupVisibility(); rebuildEndpoints(); refreshOverlayDims(); refreshDims(); updateStatusChip(); if (api && api.onIsolateChange) api.onIsolateChange(true); return true; }
|
|
@@ -1306,6 +1433,26 @@ function workAreaSetWhole(on) {
|
|
|
1306
1433
|
}
|
|
1307
1434
|
function clearWorkArea() { if (api && api.beginClipEdit && workArea) api.beginClipEdit(); workArea = null; applyClips(); renderWorkArea(); refreshWorkAreaVis(); if (api && api.onWorkAreaChange) api.onWorkAreaChange(null); }
|
|
1308
1435
|
function workAreaState() { return workArea ? { on: workArea.enabled, whole: !!workArea.whole } : null; }
|
|
1436
|
+
// The camera viewpoint as plain data — position + orbit target + projection + the ortho frustum half-height
|
|
1437
|
+
// and zoom. A saved view restores it via setCameraState. (perspCam/orthoCam share position; orthoBaseH+zoom
|
|
1438
|
+
// size the ortho frustum.)
|
|
1439
|
+
function cameraState() { return { v: 1, pos: camera.position.toArray(), target: controls.target.toArray(), proj: projection(), orthoH: orthoBaseH, zoom: orthoCam.zoom }; }
|
|
1440
|
+
function setCameraState(s) {
|
|
1441
|
+
if (!s || !Array.isArray(s.pos) || !Array.isArray(s.target)) return;
|
|
1442
|
+
setProjection(s.proj === 'ortho' ? 'ortho' : 'persp'); // switch the active camera first (no-op if unchanged)
|
|
1443
|
+
controls.target.fromArray(s.target);
|
|
1444
|
+
camera.position.fromArray(s.pos);
|
|
1445
|
+
if (camera === orthoCam) { if (s.orthoH > 0) orthoBaseH = s.orthoH; orthoCam.zoom = s.zoom > 0 ? s.zoom : 1; reframeOrtho(); }
|
|
1446
|
+
// keep near/far clearing the whole scene from the restored spot (mirror fitCamera's sizing so a far-out
|
|
1447
|
+
// saved view doesn't clip the model behind the far plane).
|
|
1448
|
+
const sph = sceneBox.getBoundingSphere(new THREE.Sphere());
|
|
1449
|
+
const dist = camera.position.distanceTo(controls.target) || 1;
|
|
1450
|
+
const near = Math.max(dist / 2000, 0.5);
|
|
1451
|
+
const far = Math.max(dist + (sph.radius || dist) * 4, camera.position.distanceTo(sph.center) + (sph.radius || dist)) * 1.02;
|
|
1452
|
+
perspCam.near = near; perspCam.far = far; perspCam.updateProjectionMatrix();
|
|
1453
|
+
orthoCam.near = near; orthoCam.far = far; orthoCam.updateProjectionMatrix();
|
|
1454
|
+
controls.update();
|
|
1455
|
+
}
|
|
1309
1456
|
|
|
1310
1457
|
function frameAll() { fitCamera(sceneBox); }
|
|
1311
1458
|
const VIEWS = { top: [0, 0, 1], bottom: [0, 0, -1], front: [0, -1, 0], back: [0, 1, 0], right: [1, 0, 0], left: [-1, 0, 0], iso: [0.55, -0.8, 0.5] };
|
|
@@ -1348,6 +1495,7 @@ function onKey(e) {
|
|
|
1348
1495
|
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
1496
|
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
1497
|
if (insertMode && e.key === 'Escape') { e.preventDefault(); setInsertMode(false); if (api && api.toast) api.toast('Insert cancelled'); return; } // Esc disarms the detail-placement pick
|
|
1498
|
+
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
1499
|
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
1500
|
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
1501
|
if (e.key === 'Escape' && !dimMode3d && !cmActive() && ascendConn()) { e.preventDefault(); return; } // Esc ascends the connection drill: part → whole → nothing
|
|
@@ -2341,6 +2489,7 @@ function onDown(e) {
|
|
|
2341
2489
|
return; }
|
|
2342
2490
|
if (addActive()) { e.stopPropagation(); controls.enabled = true; drClick(e); return; } // Add-member armed (editor state) → two plane picks draw a member
|
|
2343
2491
|
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
|
|
2492
|
+
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
2493
|
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
2494
|
if (clipMode === 'plane') { e.stopPropagation(); addClipPlaneAtScreen(e.clientX, e.clientY); return; } // armed: left-click a face → place a clip plane (stays armed)
|
|
2346
2495
|
if (clipMode === 'box') { e.stopPropagation(); onClipBoxClick(e); return; } // armed: 2-corner clip-box draw on the floor plane
|
|
@@ -2410,6 +2559,7 @@ function startEndpointGrab(ep, e) {
|
|
|
2410
2559
|
epDrag: true, id: ep.id, end: ep.end, ppf,
|
|
2411
2560
|
planeZ: g.line[ep.end][2], fixed: g.line[1 - ep.end],
|
|
2412
2561
|
candidates: allCandidates(ep.id).filter((c) => candAllowed3d(c.type)), // member + grid snap targets, filtered by the running-snaps (⋯ menu → Snapping)
|
|
2562
|
+
orig: g.line[ep.end].slice(), // where this end started — the "moved" distance reference (the dragged end stays on planeZ, so orig[2] === planeZ)
|
|
2413
2563
|
newPt: [g.line[ep.end][0], g.line[ep.end][1]],
|
|
2414
2564
|
};
|
|
2415
2565
|
dragEp = { id: ep.id, end: ep.end };
|
|
@@ -2418,18 +2568,24 @@ function startEndpointGrab(ep, e) {
|
|
|
2418
2568
|
function onMoveEndpoint(e) {
|
|
2419
2569
|
if (!dragging && Math.hypot(e.clientX - downXY[0], e.clientY - downXY[1]) <= DRAG_TOL_PX) return;
|
|
2420
2570
|
dragging = pending;
|
|
2421
|
-
const hit = rayToPlane(e.clientX, e.clientY, pending.planeZ);
|
|
2571
|
+
const hit = rayToPlane(e.clientX, e.clientY, pending.planeZ);
|
|
2572
|
+
if (!hit) { epMovedLine.visible = false; epPreview.visible = false; marker.visible = false; readout.style.display = 'none'; return; } // ray parallel to the work-plane (edge-on) or canvas collapsed — park the overlay + readout, don't freeze them at a stale point
|
|
2422
2573
|
const r = snapPoint([hit[0], hit[1], pending.planeZ], pending.candidates, toScreen, SNAP_TOL_PX);
|
|
2423
2574
|
const np = r.candidate ? r.snapped : [hit[0], hit[1], pending.planeZ];
|
|
2424
2575
|
pending.newPt = [np[0], np[1]];
|
|
2425
2576
|
const dot = epGroup.children.find((c) => c.userData.epId === pending.id && c.userData.epEnd === pending.end);
|
|
2426
2577
|
if (dot) dot.position.set(np[0], np[1], pending.planeZ);
|
|
2427
|
-
const f = pending.fixed;
|
|
2578
|
+
const f = pending.fixed, o = pending.orig;
|
|
2428
2579
|
epPreview.geometry.setFromPoints([new THREE.Vector3(f[0], f[1], f[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
|
|
2429
2580
|
epPreview.visible = true;
|
|
2581
|
+
// "moved" leader: cyan dashed line from where the end started (o) to the new point — the 3D echo of the 2D Move rubber
|
|
2582
|
+
epMovedLine.geometry.setFromPoints([new THREE.Vector3(o[0], o[1], o[2]), new THREE.Vector3(np[0], np[1], pending.planeZ)]);
|
|
2583
|
+
epMovedLine.computeLineDistances(); epMovedLine.visible = true;
|
|
2430
2584
|
if (r.candidate) showMarker(np, r.candidate.type); else marker.visible = false;
|
|
2431
|
-
const len = Math.hypot(np[0] - f[0], np[1] - f[1]) / FT_MM;
|
|
2432
|
-
|
|
2585
|
+
const len = Math.hypot(np[0] - f[0], np[1] - f[1], pending.planeZ - f[2]) / FT_MM; // true 3D member length (matches the inspector's Length)
|
|
2586
|
+
const moved = Math.hypot(np[0] - o[0], np[1] - o[1]) / FT_MM; // in-plane distance the end has travelled ("same as Move")
|
|
2587
|
+
readout._dist.textContent = len.toFixed(2) + ' ft'; // primary: the member's live length
|
|
2588
|
+
readout._type.textContent = ' · moved ' + moved.toFixed(2) + ' ft' + (r.candidate ? ' · ' + r.candidate.type : ''); // secondary: how far the end moved (+ snap)
|
|
2433
2589
|
readout.style.left = (e.clientX + 14) + 'px'; readout.style.top = (e.clientY + 14) + 'px'; readout.style.display = 'block';
|
|
2434
2590
|
}
|
|
2435
2591
|
|
|
@@ -2542,7 +2698,7 @@ function connsInRect(x0, y0, x1, y1, windowMode) {
|
|
|
2542
2698
|
|
|
2543
2699
|
function onUp(e) {
|
|
2544
2700
|
if (e.button === 2) rightDownXY = null; // end the click-vs-drag test (rightMoved keeps the verdict for the contextmenu that follows)
|
|
2545
|
-
if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; clearCopyGhost(); dragEp = null; // always clear overlays
|
|
2701
|
+
if (marker) marker.visible = false; if (readout) readout.style.display = 'none'; if (rubber) rubber.style.display = 'none'; if (epPreview) epPreview.visible = false; if (epMovedLine) epMovedLine.visible = false; clearCopyGhost(); dragEp = null; // always clear overlays
|
|
2546
2702
|
if (!renderer || !canvasEl || canvasEl.style.display === 'none') { downXY = null; boxSel = pending = dragging = null; if (controls) controls.enabled = true; return; } // 3D hidden mid-gesture → drop stale gesture state (no resume on re-show)
|
|
2547
2703
|
const bs = boxSel; boxSel = null;
|
|
2548
2704
|
if (bs) { // empty-space gesture: drag = box-select, click = clear selection
|
|
@@ -2620,6 +2776,17 @@ function onHoverMove(e) {
|
|
|
2620
2776
|
hoverRAF = requestAnimationFrame(() => {
|
|
2621
2777
|
hoverRAF = 0;
|
|
2622
2778
|
if (!lastHoverXY || pending || dragging || boxSel) return;
|
|
2779
|
+
if (basePickCol) { // Mode B armed: reticle on the column axis + a live elevation readout
|
|
2780
|
+
const p = basePickPoint(lastHoverXY[0], lastHoverXY[1]);
|
|
2781
|
+
if (p) {
|
|
2782
|
+
showMarker([p.x, p.y, p.z], p.snapped ? 'end' : 'dot');
|
|
2783
|
+
canvasEl.style.cursor = p.snapped ? 'none' : 'crosshair';
|
|
2784
|
+
readout._dist.textContent = api.fmtLen ? api.fmtLen(p.z) : (p.z / 304.8).toFixed(2) + ' ft';
|
|
2785
|
+
readout._type.textContent = p.snapped ? ' · ' + (p.label || 'level') : ' ↕';
|
|
2786
|
+
readout.style.left = (lastHoverXY[0] + 14) + 'px'; readout.style.top = (lastHoverXY[1] + 14) + 'px'; readout.style.display = 'block';
|
|
2787
|
+
} else { marker.visible = false; readout.style.display = 'none'; canvasEl.style.cursor = 'crosshair'; }
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2623
2790
|
if (wpMode) { // armed set-plane pick: crosshair (+ snap marker for the 3pt flow)
|
|
2624
2791
|
canvasEl.style.cursor = 'crosshair';
|
|
2625
2792
|
if (wpMode === '3pt') { const r = dimPointAt({ clientX: lastHoverXY[0], clientY: lastHoverXY[1], altKey: dimLastAlt });
|
|
@@ -2724,6 +2891,8 @@ function dispose() {
|
|
|
2724
2891
|
memberLabelHost = null; memberLabelPool.length = 0; labelsOnFlag = false; insertMode = false; insertPending = null;
|
|
2725
2892
|
if (propLabelHost && propLabelHost.parentNode) propLabelHost.parentNode.removeChild(propLabelHost);
|
|
2726
2893
|
propLabelHost = null; propLabelPool.length = 0; propLabelSpec = null;
|
|
2894
|
+
if (selLenLabelHost && selLenLabelHost.parentNode) selLenLabelHost.parentNode.removeChild(selLenLabelHost);
|
|
2895
|
+
selLenLabelHost = null; selLenLabelPool.length = 0; selLenLabelSpec = null;
|
|
2727
2896
|
for (const w of [cube, triad]) { // both mini-widgets own a WebGL context — leak one and re-init eventually hits the browser's context cap
|
|
2728
2897
|
if (!w) continue;
|
|
2729
2898
|
w.scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); const mm = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []); for (const m of mm) { if (m.map) m.map.dispose(); m.dispose(); } });
|
|
@@ -2735,8 +2904,8 @@ function dispose() {
|
|
|
2735
2904
|
if (overlayDimsGroup) { if (scene) scene.remove(overlayDimsGroup); for (const c of overlayDimsGroup.children) { c.geometry.dispose(); c.material.dispose(); } } // derived dim-overlay lines
|
|
2736
2905
|
overlayLabelPool.length = 0; dimParts = [];
|
|
2737
2906
|
if (dimPreviewLine && scene) scene.remove(dimPreviewLine);
|
|
2738
|
-
for (const o of [epRing, epPreview, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
2739
|
-
epGeom = epMatStart = epMatEnd = epRing = epPreview = refGroup = null;
|
|
2907
|
+
for (const o of [epRing, epPreview, epMovedLine, refGroup, dimPreviewLine]) if (o) { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }
|
|
2908
|
+
epGeom = epMatStart = epMatEnd = epRing = epPreview = epMovedLine = refGroup = null;
|
|
2740
2909
|
dims3dGroup = dimPreviewLine = overlayDimsGroup = null;
|
|
2741
2910
|
clearStructGrid();
|
|
2742
2911
|
for (const tex of gridTexCache.values()) tex.dispose();
|
|
@@ -2805,16 +2974,21 @@ window.Steel3DView = {
|
|
|
2805
2974
|
setProjection, projection, setDisplayMode, mode: () => displayMode, frameAll, frameSelection, applyView,
|
|
2806
2975
|
setRefLine, refLine: () => refLineOn,
|
|
2807
2976
|
setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
|
|
2977
|
+
setBasePickMode, basePickMode: basePickModeOn, // arm/query the Mode B column-base pick (level-snapped elevation)
|
|
2808
2978
|
selectWholeConn, clearConnSel, ascendConn, connContext, connEnvelopeOn: () => !!connEnvelope, // Connection Components (Slice A): whole-select / drill / ascend + test probes
|
|
2809
2979
|
setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
|
|
2810
2980
|
syncMemberLabels, // editor calls after a mark/id edit to refresh labels
|
|
2811
2981
|
setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
|
|
2982
|
+
setSelLenLabels, // on-select member-length labels: editor pushes [{id,text}] (⋯ Display "Show member length")
|
|
2812
2983
|
propLabelTexts: () => propLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent), // visible property-label chips — for tests
|
|
2984
|
+
selLenLabelTexts: () => selLenLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent), // visible on-select length chips — for tests
|
|
2985
|
+
epScreen: (id) => { const m = members().find((x) => x.id === id); if (!m || !canvasEl) return null; const g = memberGeometry(m, api.ptPerFt(), api.defaultTosMm()); const rect = canvasEl.getBoundingClientRect(); const proj = (p) => { const v = new THREE.Vector3(p[0], p[1], p[2]).project(camera); return { x: rect.left + (v.x * 0.5 + 0.5) * rect.width, y: rect.top + (-v.y * 0.5 + 0.5) * rect.height }; }; return { a: proj(g.line[0]), b: proj(g.line[1]) }; }, // project a member's endpoints to viewport px — test probe for endpoint-drag tests
|
|
2813
2986
|
refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
|
|
2814
2987
|
gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
|
|
2815
2988
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
2816
2989
|
setClipMode, clipMode: clipModeOn, addClipBox, toggleClip, removeClip, clearClips, getClips, renameClip, selectClip, setSelectedClips, selectedClips, deleteSelectedClips, clipState, setClipState,
|
|
2817
2990
|
isolateSelected, clearIsolation, isIsolated,
|
|
2991
|
+
cameraState, setCameraState, objectsPaneState, applyObjectsPaneState, // saved-view snapshot surface (Views Organizer)
|
|
2818
2992
|
workAreaSetAll, workAreaFromSelection, workAreaToggle, workAreaSetWhole, clearWorkArea, workAreaState,
|
|
2819
2993
|
armWorkPlanePick, setWorkPlanePrincipal, clearWorkPlane, toggleWorkPlaneVisible, workPlaneInfo,
|
|
2820
2994
|
cmEscape, cmHasBase, cmClear3d, setCmAxis, cmLastClient, cmHudApply,
|
|
@@ -206,6 +206,9 @@
|
|
|
206
206
|
.cmarrow{fill:#22d3ee;pointer-events:none}
|
|
207
207
|
.cmchip{fill:var(--panel);stroke:#22d3ee;opacity:.85;pointer-events:none} /* .85: reads "in progress", full opacity stays reserved for committed dims */
|
|
208
208
|
.cmtx{fill:var(--text);text-anchor:middle;dominant-baseline:central;opacity:.85;pointer-events:none;font-family:system-ui}
|
|
209
|
+
.mlenlabels{pointer-events:none}
|
|
210
|
+
rect.mlenchip{fill:var(--panel);stroke:#22d3ee;stroke-width:1;vector-effect:non-scaling-stroke;opacity:.9} /* on-select member-length chips: a cyan dimension read (NOT the brand-blue property chips) */
|
|
211
|
+
text.mlentx{fill:#e2e8f0;text-anchor:middle;dominant-baseline:central;font-family:system-ui;opacity:.9}
|
|
209
212
|
#cmHud{position:fixed;z-index:70;display:none;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--brand);border-radius:8px;padding:6px 8px;box-shadow:0 6px 20px rgba(0,0,0,.55);font:12px system-ui;color:var(--mut)}
|
|
210
213
|
#cmHud.err{border-color:#fca5a5}
|
|
211
214
|
#cmHud input{width:110px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 7px;font:12px system-ui}
|
|
@@ -399,7 +402,24 @@
|
|
|
399
402
|
#m3dLegend .lrow.clip .lx{margin-left:0} /* the label's flex:1 already pushes On/Off + × to the right */
|
|
400
403
|
#m3dLegend .lrow.clip.sel{border-left:2px solid var(--brand);background:rgba(59,130,246,.16);padding-left:2px}
|
|
401
404
|
#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 */
|
|
402
|
-
|
|
405
|
+
/* Panel shell (Objects | Views | Favourites tabs). The OUTER panel no longer scrolls — it caps the height and
|
|
406
|
+
lays out [tab strip][active body]; each tab body is its own scroll container (themed by the global * rule). */
|
|
407
|
+
#m3dLegend{position:absolute;left:12px;bottom:64px;display:none;flex-direction:column;max-height:52vh;min-width:210px;max-width:min(340px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:0;z-index:6;box-shadow:0 4px 14px rgba(0,0,0,.45);font-size:12px}
|
|
408
|
+
/* Tab strip — the .seg-group segmented look, full-width across the panel top; sticky so it never scrolls away. */
|
|
409
|
+
#m3dTabs{display:flex;flex:none;border-bottom:1px solid var(--line);padding:7px 8px 6px;gap:0}
|
|
410
|
+
#m3dTabs .m3dtab{flex:1;background:transparent;border:1px solid #475569;border-right-width:0;border-radius:0;color:var(--mut);font-size:11px;padding:5px 4px;cursor:pointer;box-shadow:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
411
|
+
#m3dTabs .m3dtab:first-child{border-top-left-radius:6px;border-bottom-left-radius:6px}
|
|
412
|
+
#m3dTabs .m3dtab:last-child{border-right-width:1px;border-top-right-radius:6px;border-bottom-right-radius:6px}
|
|
413
|
+
#m3dTabs .m3dtab:hover{color:var(--text);background:#334155}
|
|
414
|
+
#m3dTabs .m3dtab.on{background:var(--brand);border-color:var(--brand);color:#fff}
|
|
415
|
+
/* Tab bodies — only the active one shows; each scrolls independently. The Objects body carries the legend's own padding
|
|
416
|
+
(its children own their spacing), Views/Favourites get uniform padding. gap:1px keeps the old dense legend rhythm. */
|
|
417
|
+
#m3dLegend .m3dbody{display:none;flex-direction:column;gap:1px;overflow:auto;min-height:0}
|
|
418
|
+
#m3dLegend .m3dbody.on{display:flex;flex:1 1 auto} /* the active body fills the capped panel and scrolls (min-height:0 lets it shrink below content) */
|
|
419
|
+
#m3dLegendBody{padding:8px 10px}
|
|
420
|
+
#m3dViewsBody,#m3dFavBody{padding:8px 10px}
|
|
421
|
+
/* Favourites placeholder (a later slice wires its content). */
|
|
422
|
+
#m3dFavBody .favsoon{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
|
|
403
423
|
#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 */
|
|
404
424
|
#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 */
|
|
405
425
|
#m3dLegend .lrow:hover{background:#33415580}
|
|
@@ -453,6 +473,58 @@
|
|
|
453
473
|
#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
474
|
#m3dLegend .lreset.show{display:flex}
|
|
455
475
|
#m3dLegend .lreset:hover{border-color:var(--brand);background:#1a2740}
|
|
476
|
+
/* ── Views tab (Views Organizer) ─────────────────────────────────────────────────────────────────────────── */
|
|
477
|
+
/* Header row: primary "Save current view" split button + a 🔍 search toggle, all on one line. */
|
|
478
|
+
#vwHeadRow{display:flex;align-items:stretch;gap:6px;margin-bottom:6px;flex:none}
|
|
479
|
+
#vwSaveSplit{display:inline-flex;flex:1;min-width:0;border:1px solid var(--brand);border-radius:6px;overflow:hidden}
|
|
480
|
+
#vwSaveSplit button{border:0;border-radius:0;background:var(--brand);color:#fff;font-size:12px;box-shadow:none;padding:5px 8px}
|
|
481
|
+
#vwSaveSplit button:hover:not(:disabled){background:#2f6fe0}
|
|
482
|
+
#vwSaveBtn{flex:1;min-width:0;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
483
|
+
#vwSaveMore{flex:none;width:24px;padding:0;border-left:1px solid rgba(255,255,255,.28)!important}
|
|
484
|
+
#vwSaveSplit button:disabled{opacity:.45;cursor:default;background:var(--brand)}
|
|
485
|
+
/* the split, disabled at the cap — a muted, non-branded look so it reads clearly as unavailable */
|
|
486
|
+
#vwSaveSplit.capped{border-color:var(--line)}
|
|
487
|
+
#vwSaveSplit.capped button{background:#334155;color:var(--mut)}
|
|
488
|
+
#vwSaveSplit.capped #vwSaveMore{border-left-color:rgba(148,163,184,.28)!important}
|
|
489
|
+
#vwSearchTog{flex:none;width:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;color:var(--mut)}
|
|
490
|
+
#vwSearchTog.on{color:var(--brand);border-color:var(--brand)}
|
|
491
|
+
#vwSearchTog svg{display:block}
|
|
492
|
+
/* the cap counter ("8/10") + the disabled reason line — visible, quiet, inline (not tooltip-only) */
|
|
493
|
+
#vwCount{flex:none;font-size:10px;color:var(--mut);text-align:right;font-variant-numeric:tabular-nums;margin-bottom:4px;display:none}
|
|
494
|
+
#vwCap{flex:none;font-size:10px;color:#fbbf24;line-height:1.4;margin-bottom:6px;display:none}
|
|
495
|
+
#vwCap.show{display:block} #vwCount.show{display:block}
|
|
496
|
+
/* search box — reuses the objects-list .lsearch recipe; hidden until the 🔍 toggle opens it */
|
|
497
|
+
#vwSearch{display:none;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}
|
|
498
|
+
#vwSearch.show{display:flex}
|
|
499
|
+
#vwSearch:focus-within{border-color:var(--brand)}
|
|
500
|
+
#vwSearch .lsico{color:var(--mut);flex:none;display:inline-flex;align-items:center}
|
|
501
|
+
#vwSearch 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}
|
|
502
|
+
#vwSearch input::placeholder{color:var(--mut)}
|
|
503
|
+
#vwSearch .lsx{color:var(--mut);font-size:14px;line-height:1;padding:0 3px;border-radius:4px;cursor:pointer;flex:none;visibility:hidden}
|
|
504
|
+
#vwSearch.has .lsx{visibility:visible}
|
|
505
|
+
#vwSearch .lsx:hover{color:#fecaca;background:#7f1d1d}
|
|
506
|
+
/* a view row — mirrors the objects-list .lrow anatomy: [drag] [glyph] name … [✕][⋯]. Active = brand left bar + tint. */
|
|
507
|
+
#m3dViewsBody .vwrow{display:flex;align-items:center;gap:6px;padding:3px 4px;border-radius:5px;cursor:pointer;user-select:none;white-space:nowrap}
|
|
508
|
+
#m3dViewsBody .vwrow:hover{background:#33415580}
|
|
509
|
+
#m3dViewsBody .vwrow.active{box-shadow:inset 2px 0 0 var(--brand);background:rgba(59,130,246,.16);padding-left:2px} /* shift content 2px so the inset brand bar doesn't crowd the leading drag-handle (matches the .lrow.clip.sel precedent) */
|
|
510
|
+
#m3dViewsBody .vwrow.active .vwname{color:var(--text)}
|
|
511
|
+
#m3dViewsBody .vwrow.flash{background:rgba(59,130,246,.12)}
|
|
512
|
+
#m3dViewsBody .vwrow.drop-target{outline:1px solid var(--brand);background:rgba(59,130,246,.18)}
|
|
513
|
+
#m3dViewsBody .vwrow .drag-handle{font-size:11px;color:var(--mut);cursor:grab;opacity:0;transition:opacity .1s;flex:none;padding:0 1px;line-height:1}
|
|
514
|
+
#m3dViewsBody .vwrow:hover .drag-handle{opacity:1}
|
|
515
|
+
#m3dViewsBody .vwglyph{flex:none;width:14px;text-align:center;color:var(--mut);font-size:12px;line-height:1} /* orientation glyph — a recognition aid, muted */
|
|
516
|
+
#m3dViewsBody .vwname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;color:var(--text)}
|
|
517
|
+
#m3dViewsBody .vwrow input.vwedit{flex:1;min-width:0;font:12px system-ui;background:#0b1220;color:var(--text);border:1px solid var(--brand);border-radius:4px;padding:0 4px;outline:none}
|
|
518
|
+
#m3dViewsBody .vwrow .vx{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;visibility:hidden;font-size:13px;line-height:1}
|
|
519
|
+
#m3dViewsBody .vwrow:hover .vx{visibility:visible}
|
|
520
|
+
#m3dViewsBody .vwrow .vx:hover{color:#fecaca;background:#7f1d1d}
|
|
521
|
+
#m3dViewsBody .vwrow .vdots{flex:none;color:var(--mut);padding:0 3px;border-radius:4px;font-size:14px;line-height:1;cursor:pointer}
|
|
522
|
+
#m3dViewsBody .vwrow .vdots:hover{color:var(--text);background:#334155}
|
|
523
|
+
#m3dViewsBody .vwempty{color:var(--mut);font-size:11px;line-height:1.5;padding:12px 4px;text-align:center}
|
|
524
|
+
#m3dViewsBody .vwnores{color:var(--mut);font-size:11px;padding:10px 4px;text-align:center}
|
|
525
|
+
#m3dViewsBody .vwnores .pilllink{margin-left:4px}
|
|
526
|
+
/* the per-row ⋯ popup — reuses the .m3dmenu skin; positioned at the cursor (fixed) */
|
|
527
|
+
#vwRowMenu{position:fixed;left:0;top:0;z-index:45;min-width:160px}
|
|
456
528
|
#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 */
|
|
457
529
|
/* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
|
|
458
530
|
(pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
|
|
@@ -522,6 +594,7 @@
|
|
|
522
594
|
<button id=dimToggleB data-tip="Show or hide all placed dimensions on the plan">Hide dimensions</button>
|
|
523
595
|
<button id=calloutToggleB data-tip="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
|
|
524
596
|
<button id=gridToggleB data-tip="Show or hide the grid lines in 2D and 3D">Hide grid</button>
|
|
597
|
+
<button id=lenToggleB data-tip="Show each selected member's length on the canvas">Show member length</button>
|
|
525
598
|
<button id=gridEditB data-tip="Grid lines — a plan reference with structural bay spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
|
|
526
599
|
</div>
|
|
527
600
|
<button class=msec-hdr data-sec=detailing aria-expanded=false data-tip="Connection details, plates, frames, and inserted detail images">Detailing<span class=chev aria-hidden=true>▸</span></button>
|
|
@@ -585,6 +658,7 @@
|
|
|
585
658
|
</div>
|
|
586
659
|
</div>
|
|
587
660
|
<button id=m3dIso data-tip="Isolate selected — hide everything else (Esc to exit)" style="display:none">Isolate</button>
|
|
661
|
+
<button id=m3dViewsBtn data-tip="Saved views — save, switch, and manage named viewpoints">Views</button>
|
|
588
662
|
<span class=tb-sep></span>
|
|
589
663
|
<!-- Display toggles: reference lines + mark labels (grouped into a menu, like Plane / Work area).
|
|
590
664
|
The Dimension tool moved to the header so it lives in one place across 2D and 3D. -->
|
|
@@ -633,7 +707,16 @@
|
|
|
633
707
|
</div>
|
|
634
708
|
</div>
|
|
635
709
|
</div>
|
|
636
|
-
<div id=m3dLegend
|
|
710
|
+
<div id=m3dLegend>
|
|
711
|
+
<div id=m3dTabs role=tablist aria-label="3D panel tabs">
|
|
712
|
+
<button class=m3dtab role=tab data-tab=objects aria-selected=true data-tip="Objects — show / hide, isolate, and search the model's parts">Objects</button>
|
|
713
|
+
<button class=m3dtab role=tab data-tab=views aria-selected=false data-tip="Saved views — save, switch, and manage named viewpoints">Views</button>
|
|
714
|
+
<button class=m3dtab role=tab data-tab=fav aria-selected=false data-tip="Favourite connections — save and re-apply detailed connections">Favourites</button>
|
|
715
|
+
</div>
|
|
716
|
+
<div id=m3dLegendBody class="m3dbody on" role=tabpanel aria-label=Objects></div>
|
|
717
|
+
<div id=m3dViewsBody class=m3dbody role=tabpanel aria-label=Views></div>
|
|
718
|
+
<div id=m3dFavBody class=m3dbody role=tabpanel aria-label=Favourites><div class=favsoon>Favourite connections — coming soon</div></div>
|
|
719
|
+
</div>
|
|
637
720
|
<div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
|
|
638
721
|
<div id=m3dAxes></div>
|
|
639
722
|
<div id=zoombar>
|
|
@@ -785,6 +868,7 @@ async function boot() {
|
|
|
785
868
|
if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
|
|
786
869
|
if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
|
|
787
870
|
if(!C.dim_overlays||typeof C.dim_overlays!=='object'||Array.isArray(C.dim_overlays))C.dim_overlays={bolt_pitch:true,edge_clearance:true,cope_size:true,base_plate:true,anchor_depth:true}; // model-global legend DIMENSIONS toggles — all on by default
|
|
871
|
+
if(!Array.isArray(C.views))C.views=[]; // model-global saved views (Views Organizer): ensure an array in boot()'s OUTER scope (like dims3d/detail_placements above); the full sanitizeViews() runs inside main() where that helper lives (mirrors prop_labels)
|
|
788
872
|
main(); // (C.prop_labels is normalised inside main(), right after the PROP_DEFS/sanitizePropLabels registry is defined — see ~"normalise the contract's incoming value" — since that helper isn't in scope out here in boot())
|
|
789
873
|
// SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
|
|
790
874
|
// We open our own EventSource to the same /api/events endpoint as the main app.
|
|
@@ -864,6 +948,7 @@ let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
|
|
|
864
948
|
let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
|
|
865
949
|
let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
|
|
866
950
|
let calloutsVisible=true; // Workstream B: show/hide the clickable callout bubbles (details + section/elevation/detail-ref) on the plan in select mode
|
|
951
|
+
let showSelLen=(()=>{try{return localStorage.getItem('steel:selLen:v1')==='1';}catch(_){return false;}})(); // ⋯ Display → Show member length: paint each selected member's length on the canvas (opt-in, default off)
|
|
867
952
|
let gridMode=false, gridPick=false; // Grid lines: panel-takeover editing mode + armed pick-origin click
|
|
868
953
|
let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
|
|
869
954
|
let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
|
|
@@ -941,6 +1026,16 @@ function retargetTos(m,srcDef,dstDef){const d=dstDef-srcDef;ensureMeta(m);
|
|
|
941
1026
|
// best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
|
|
942
1027
|
// 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
|
|
943
1028
|
let defaultTOS=198, addProfile='';
|
|
1029
|
+
let basePickColId=null; // Mode B: the column whose base an armed 3D level-pick retargets
|
|
1030
|
+
const MIN_COL_STUB_IN=12; // never trim a column below a 1' stub (keeps bos < tos)
|
|
1031
|
+
// Mode B: arm the 3D column-base pick on `colId` (a level-snapped elevation → col.bos). 3D-view only.
|
|
1032
|
+
function armBaseTrim(colId){
|
|
1033
|
+
if(!view3d){toast('Open the 3D view to pick the column base point');return;}
|
|
1034
|
+
if(!(window.Steel3DView&&window.Steel3DView.setBasePickMode))return;
|
|
1035
|
+
basePickColId=colId;
|
|
1036
|
+
window.Steel3DView.setBasePickMode(colId);
|
|
1037
|
+
toast('Click a point along '+colId+' — the base snaps to framing levels · Esc cancels');
|
|
1038
|
+
}
|
|
944
1039
|
function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
945
1040
|
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
1041
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
@@ -1010,8 +1105,14 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
1010
1105
|
else if(state==='dirty'){el.classList.add('dirty');el.textContent='Saving…';}
|
|
1011
1106
|
else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
|
|
1012
1107
|
else el.textContent=msg||'Auto-save on';}
|
|
1108
|
+
// Saved views (Views Organizer) — normalise an incoming array to clean, known-key entries. Robust to a
|
|
1109
|
+
// partial/stale server or localStorage draft (same discipline as dim_overlays): drops non-objects, mints a
|
|
1110
|
+
// missing id/name, clamps the display mode/projection to known values, keeps the opaque camera/clips/objects
|
|
1111
|
+
// snapshots as-is (activateView applies each defensively — a malformed sub-snapshot is caught, not fatal —
|
|
1112
|
+
// since the engine's setClipState does NOT re-sanitise its input), and caps the list at 10.
|
|
1113
|
+
function sanitizeViews(arr){if(!Array.isArray(arr))return [];const out=[];for(const v of arr){if(!v||typeof v!=='object')continue;const id=(typeof v.id==='string'&&v.id)?v.id:('v'+Math.random().toString(36).slice(2,9));const name=(typeof v.name==='string'&&v.name.trim())?v.name.slice(0,80):'View';out.push({v:1,id,name,order:typeof v.order==='number'?v.order:out.length,camera:(v.camera&&typeof v.camera==='object')?v.camera:null,projection:v.projection==='ortho'?'ortho':'persp',mode:['solid','wire','xray'].includes(v.mode)?v.mode:'solid',clips:(v.clips&&typeof v.clips==='object')?v.clips:null,objects:(v.objects&&typeof v.objects==='object')?v.objects:null});if(out.length>=10)break;}return out;}
|
|
1013
1114
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
1014
|
-
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, prop_labels:C.prop_labels, joints:C.joints, detail_placements:C.detail_placements,
|
|
1115
|
+
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, prop_labels:C.prop_labels, joints:C.joints, detail_placements:C.detail_placements, views:C.views,
|
|
1015
1116
|
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null,grid:p.grid||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
1016
1117
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
1017
1118
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
@@ -1047,6 +1148,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
1047
1148
|
if(d.dim_overlays&&typeof d.dim_overlays==='object'&&!Array.isArray(d.dim_overlays)){const o=d.dim_overlays;C.dim_overlays={bolt_pitch:o.bolt_pitch!==false,edge_clearance:o.edge_clearance!==false,cope_size:o.cope_size!==false,base_plate:o.base_plate!==false,anchor_depth:o.anchor_depth!==false};} // restore the legend DIMENSIONS toggles — sanitised to the known boolean keys (a corrupt/partial draft can't desync the legend from what's drawn; on unless explicitly false)
|
|
1048
1149
|
if('prop_labels' in d)C.prop_labels=sanitizePropLabels(d.prop_labels); // restore canvas property-label display state (sanitised to known keys/placement)
|
|
1049
1150
|
if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
|
|
1151
|
+
if(Array.isArray(d.views))C.views=sanitizeViews(d.views); // restore saved views (Views Organizer) from the local draft — sanitised to clean, capped entries
|
|
1050
1152
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
1051
1153
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
1052
1154
|
function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
|
|
@@ -1489,8 +1591,9 @@ function render(){
|
|
|
1489
1591
|
s+=`<circle class=numbg ${d} cx="${x}" cy="${y}" r="${R}"/><text class=numtx ${d} x="${x}" y="${y}" style="font-size:${F}px">${it.idx+1}</text>`;});}}
|
|
1490
1592
|
s+=renderDims();
|
|
1491
1593
|
s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
|
|
1594
|
+
s+=renderSelLenLabels(); // on-select member-length chips (2D), gated by the ⋯ Display "Show member length" toggle
|
|
1492
1595
|
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)
|
|
1493
|
-
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();
|
|
1596
|
+
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(); updLenToggle();
|
|
1494
1597
|
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)
|
|
1495
1598
|
try{updateConnCrumb();}catch(_){} // Connection Component breadcrumb follows the selection (3D-only; hidden at root)
|
|
1496
1599
|
syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
|
|
@@ -1520,6 +1623,9 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
|
|
|
1520
1623
|
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.ax)-w/2);r.setAttribute('y',cy-h/2);});
|
|
1521
1624
|
plG.querySelectorAll('text.pltx').forEach(t=>{const show=(+t.dataset.mlen)*zoom>=PLABEL_MIN_PX;t.style.display=show?'':'none';t.setAttribute('y',(+t.dataset.ay)+(+t.dataset.off)/zoom);t.style.fontSize=(11/zoom)+'px';});
|
|
1522
1625
|
propLabelsHidden=hid;updatePropHint();}} // hid counts hidden chip ROWS (>0 ⇒ note shows) — the note only needs the boolean
|
|
1626
|
+
{const mlG=svg.querySelector('g.mlenlabels');if(mlG){ // on-select length chips: same zoom-rescale + threshold as the property chips
|
|
1627
|
+
mlG.querySelectorAll('rect.mlenchip').forEach(r=>{const w=(+r.dataset.tw*6.4+12)/zoom,h=15/zoom,cx=(+r.dataset.mx)+(+r.dataset.nx)*(+r.dataset.base)/zoom,cy=(+r.dataset.my)+(+r.dataset.ny)*(+r.dataset.base)/zoom,show=(+r.dataset.mlen)*zoom>=PLABEL_MIN_PX;r.style.display=show?'':'none';r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',cx-w/2);r.setAttribute('y',cy-h/2);});
|
|
1628
|
+
mlG.querySelectorAll('text.mlentx').forEach(t=>{const show=(+t.dataset.mlen)*zoom>=PLABEL_MIN_PX;t.style.display=show?'':'none';t.setAttribute('x',(+t.dataset.mx)+(+t.dataset.nx)*(+t.dataset.base)/zoom);t.setAttribute('y',(+t.dataset.my)+(+t.dataset.ny)*(+t.dataset.base)/zoom);t.style.fontSize=(11/zoom)+'px';});}}
|
|
1523
1629
|
const cg=svg.querySelector('g.csglyph');if(cg&&P.frame)cg.outerHTML=axisGlyphSvg(P.frame.o,P.frame.u,false);} // glyph is sized in 1/zoom → regenerate on zoom (like the dim chips)
|
|
1524
1630
|
function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
|
|
1525
1631
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
@@ -1738,12 +1844,14 @@ function panel(){
|
|
|
1738
1844
|
<div class=divrow><hr></div>
|
|
1739
1845
|
<div class="row f" style="gap:6px;flex-wrap:wrap">
|
|
1740
1846
|
<button class=ghostw id=cmpEdit data-tip="Edit this connection's parameters on ${esc(j.main)}">✎ Edit parameters on ${esc(j.main)} →</button>
|
|
1847
|
+
${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
1848
|
<button class=ghostw id=cmpAsk data-tip="Record a request for your terminal AI to modify / replace / move this connection">Modify connection…</button>
|
|
1742
1849
|
<button class=danger id=cmpDel data-tip="Remove this whole connection">Delete connection</button>
|
|
1743
1850
|
</div>`;
|
|
1744
1851
|
const toMember=()=>{if(window.Steel3DView&&window.Steel3DView.clearConnSel)window.Steel3DView.clearConnSel();selIds=new Set([j.main]);selDimIds.clear();sel3dDimIds.clear();render();};
|
|
1745
1852
|
{const b=document.getElementById('cmpMember');if(b)b.onclick=toMember;}
|
|
1746
1853
|
{const b=document.getElementById('cmpEdit');if(b)b.onclick=toMember;}
|
|
1854
|
+
{const b=document.getElementById('cmpTrim');if(b)b.onclick=()=>armBaseTrim(j.main);}
|
|
1747
1855
|
{const b=document.getElementById('cmpAsk');if(b)b.onclick=()=>connModifyRequest(j);}
|
|
1748
1856
|
{const b=document.getElementById('cmpDel');if(b)b.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(x=>x!==j);selIds.clear();});}
|
|
1749
1857
|
return;
|
|
@@ -1901,7 +2009,7 @@ function panel(){
|
|
|
1901
2009
|
${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
2010
|
<div class=elab style="margin-top:7px;opacity:.7">Anchor kit</div>
|
|
1903
2011
|
${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>`:'';
|
|
2012
|
+
<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
2013
|
// This BEAM's shear-plate joints — one params block per detailed END (start/end). Mirrors bpSect but
|
|
1906
2014
|
// per-end (a beam can be detailed at both ends), and adds the clearance + web-side + stiffener controls.
|
|
1907
2015
|
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 +2085,8 @@ function panel(){
|
|
|
1977
2085
|
else delete bpj.params[key]; // empty / invalid / ≤0 → drop the override so the engine falls back to its default
|
|
1978
2086
|
bpj.source='user';});};}; // through edit() → the param change is undoable; editing also makes the plate user-owned (survives the auto-detail "Clear")
|
|
1979
2087
|
['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);});}
|
|
2088
|
+
{const rm=document.getElementById('bpRemove');if(rm)rm.onclick=()=>edit(()=>{C.joints=(C.joints||[]).filter(j=>j!==bpj);});}
|
|
2089
|
+
{const tb=document.getElementById('bpTrim');if(tb)tb.onclick=()=>armBaseTrim(m.id);}} // Remove is undoable; Trim arms the 3D base pick
|
|
1981
2090
|
}else{
|
|
1982
2091
|
wireTos('tosA',m.ends[0]);
|
|
1983
2092
|
document.getElementById('ntA').onchange=e=>edit(()=>{m.ends[0].note=e.target.value;});
|
|
@@ -2132,6 +2241,7 @@ function sanitizePropLabels(x){const o=(x&&typeof x==='object'&&!Array.isArray(x
|
|
|
2132
2241
|
selected_only:o.selected_only===true,
|
|
2133
2242
|
ids:Array.isArray(o.ids)?o.ids.filter(v=>typeof v==='string'):[]};}
|
|
2134
2243
|
C.prop_labels=sanitizePropLabels(C.prop_labels); // normalise the contract's incoming value now (PROP_KEYS is initialised above; runs before main's bootstrap render + restoreSaved override)
|
|
2244
|
+
C.views=sanitizeViews(C.views); // normalise the incoming saved views now — inside main() where sanitizeViews is in scope (boot() only guaranteed an array); runs before the bootstrap render + restoreSaved override, same pattern as prop_labels above
|
|
2135
2245
|
// the "Label: value" lines a member contributes for the currently-checked props (registry order; skips N/A + empty)
|
|
2136
2246
|
function propLabelLinesFor(m){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];ensureMeta(m);
|
|
2137
2247
|
const out=[];for(const def of PROP_DEFS){if(!pl.props.includes(def.key))continue;const raw=def.get(m);if(raw===undefined)continue;const t=def.fmt(raw);if(t==='')continue;out.push(def.label+': '+t);}return out;}
|
|
@@ -2152,6 +2262,23 @@ let propLabelsHidden=0;
|
|
|
2152
2262
|
// A member's labels hide when its on-screen length < this (px) — the density guard (§5.5). Kept as one
|
|
2153
2263
|
// constant so renderPropLabels (initial paint) and updateBadges (live on zoom) agree.
|
|
2154
2264
|
const PLABEL_MIN_PX=24;
|
|
2265
|
+
// On-select member-length chips (2D): when the ⋯ Display "Show member length" toggle is on, paint each selected
|
|
2266
|
+
// member's true 3D length at its midpoint, nudged perpendicular off the line (fixed relative to member direction).
|
|
2267
|
+
// Cyan dimension chips; the member being end-dragged is skipped (its live overlay covers it); coincident midpoints
|
|
2268
|
+
// fan out along the perpendicular so duplicate/overlapping members stay readable. Rescaled live by updateBadges.
|
|
2269
|
+
function renderSelLenLabels(){if(!showSelLen)return '';const selM=selArr();if(!selM.length)return '';
|
|
2270
|
+
const drg=(drag&&drag.type==='end')?drag.id:null,grp={};
|
|
2271
|
+
for(const m of selM){if(m.id===drg)continue;const a=m.wp&&m.wp[0],b=m.wp&&m.wp[1];if(!a||!b)continue;
|
|
2272
|
+
const mid=[(a[0]+b[0])/2,(a[1]+b[1])/2],k=Math.round(mid[0]/8)+','+Math.round(mid[1]/8);(grp[k]=grp[k]||[]).push({m,a,b,mid});}
|
|
2273
|
+
let s='';
|
|
2274
|
+
for(const k in grp){const arr=grp[k],n=arr.length;arr.forEach((it,j)=>{const {m,a,b,mid}=it;
|
|
2275
|
+
const lenFt=_lenFt(m);if(!(lenFt>0)||!isFinite(lenFt))return; // skip degenerate/NaN geometry — never paint an empty "len " chip
|
|
2276
|
+
const dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1,nx=-dy/dl,ny=dx/dl,base=18+(j-(n-1)/2)*16,mlen=len(a,b);
|
|
2277
|
+
const txt='len '+fmtFtIn(lenFt*12),tw=txt.length,w=(tw*6.4+12)/zoom,hh=15/zoom,cx=mid[0]+nx*base/zoom,cy=mid[1]+ny*base/zoom;
|
|
2278
|
+
const show=mlen*zoom>=PLABEL_MIN_PX,hide=show?'':';display:none',dd=`data-mx="${mid[0]}" data-my="${mid[1]}" data-nx="${nx}" data-ny="${ny}" data-base="${base}" data-mlen="${mlen}"`;
|
|
2279
|
+
s+=`<rect class=mlenchip ${dd} data-tw="${tw}" x="${cx-w/2}" y="${cy-hh/2}" width="${w}" height="${hh}" rx="${3/zoom}" style="${hide}"/>`
|
|
2280
|
+
+`<text class=mlentx ${dd} x="${cx}" y="${cy}" style="font-size:${11/zoom}px${hide}">${esc(txt)}</text>`;});}
|
|
2281
|
+
return s?`<g class="mlenlabels">${s}</g>`:'';}
|
|
2155
2282
|
function renderPropLabels(){const pl=C.prop_labels;propLabelsHidden=0;if(!pl||!pl.props.length)return '';
|
|
2156
2283
|
const ms=propLabelMembers();if(!ms.length)return '';
|
|
2157
2284
|
let hidden=0,s='';
|
|
@@ -2312,7 +2439,9 @@ function closePropPop(force){const el=document.getElementById('propPop');if(!el)
|
|
|
2312
2439
|
const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
|
|
2313
2440
|
document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
|
|
2314
2441
|
// after every render the checked labels + the popup rows stay in sync with the (possibly changed) selection/geometry
|
|
2315
|
-
function
|
|
2442
|
+
function refreshSelLen3d(){const V=window.Steel3DView;if(!V||!V.setSelLenLabels)return; // push on-select length labels to 3D (editor owns the text; 3D owns projection)
|
|
2443
|
+
V.setSelLenLabels(showSelLen?selArr().map(m=>{const ft=_lenFt(m);return (ft>0&&isFinite(ft))?{id:m.id,text:ft.toFixed(1)+' ft'}:null;}).filter(Boolean):null);} // drop degenerate/NaN lengths so 3D never shows "NaN ft"
|
|
2444
|
+
function syncPropLabelsAfterRender(){updatePropHint();refreshPropLabels3d();refreshSelLen3d();if(propPopOpen())renderPropPop();}
|
|
2316
2445
|
function refreshPropLabels(){scheduleSave();render();} // render() → renderPropLabels() (2D) + syncPropLabelsAfterRender() (hint/3D/popup)
|
|
2317
2446
|
|
|
2318
2447
|
// --- Tekla-style snap override (right-click): restrict snapping to ONE type for the current operation.
|
|
@@ -2509,6 +2638,24 @@ function cmRubSvg(a,c,dimmed){const L=Math.hypot(c[0]-a[0],c[1]-a[1]);if(L<1e-6)
|
|
|
2509
2638
|
return `<line class=cmrub${dimmed?' style="opacity:.35"':''} x1="${a[0]}" y1="${a[1]}" x2="${c[0]}" y2="${c[1]}"/>`
|
|
2510
2639
|
+`<path class=cmarrow${dimmed?' style="opacity:.35"':''} d="M ${c[0]} ${c[1]} L ${p1[0]} ${p1[1]} L ${p2[0]} ${p2[1]} Z"/>`
|
|
2511
2640
|
+(dimmed?'':`<rect class=cmchip x="${mid[0]-cw/2/zoom}" y="${mid[1]-ch/2/zoom}" width="${cw/zoom}" height="${ch/zoom}" rx="${4/zoom}"/><text class=cmtx x="${mid[0]}" y="${mid[1]}" style="font-size:${12/zoom}px">${txt}</text>`);}
|
|
2641
|
+
// Live overlay while dragging a member END (drag.type==='end'): the Move-style cyan rubber from where the end
|
|
2642
|
+
// STARTED (orig→cursor, "moved …", primary) + the member's live LENGTH chip (anchored end→cursor, "len …",
|
|
2643
|
+
// recessed). Built with DOM nodes (textContent = XSS-safe) into #epPrevG; reuses cmRubSvg's cyan classes.
|
|
2644
|
+
function epSvgEl(tag,cls,attrs,txt){const e=document.createElementNS(SVGNS,tag);if(cls)e.setAttribute('class',cls);for(const k in attrs)e.setAttribute(k,attrs[k]);if(txt!=null)e.textContent=txt;return e;}
|
|
2645
|
+
function epPrevDraw(g,orig,anchor,cur,m){while(g.firstChild)g.removeChild(g.firstChild);
|
|
2646
|
+
const chip=(cx,cy,txt,dim)=>{const w=(txt.length*7+14)/zoom,hh=17/zoom,fs='font-size:'+(12/zoom)+'px';
|
|
2647
|
+
g.appendChild(epSvgEl('rect','cmchip',dim?{x:cx-w/2,y:cy-hh/2,width:w,height:hh,rx:4/zoom,style:'opacity:.6'}:{x:cx-w/2,y:cy-hh/2,width:w,height:hh,rx:4/zoom}));
|
|
2648
|
+
g.appendChild(epSvgEl('text','cmtx',{x:cx,y:cy,style:dim?fs+';opacity:.6':fs},txt));};
|
|
2649
|
+
const L=Math.hypot(cur[0]-orig[0],cur[1]-orig[1]);
|
|
2650
|
+
if(L>1e-6){const ux=(cur[0]-orig[0])/L,uy=(cur[1]-orig[1])/L,ah=10/zoom;
|
|
2651
|
+
const p1=[cur[0]-ah*ux+ah*.45*uy,cur[1]-ah*uy-ah*.45*ux],p2=[cur[0]-ah*ux-ah*.45*uy,cur[1]-ah*uy+ah*.45*ux];
|
|
2652
|
+
g.appendChild(epSvgEl('circle','cmrub',{fill:'none',cx:orig[0],cy:orig[1],r:epR()})); // ghost at the end's start
|
|
2653
|
+
g.appendChild(epSvgEl('line','cmrub',{x1:orig[0],y1:orig[1],x2:cur[0],y2:cur[1]}));
|
|
2654
|
+
g.appendChild(epSvgEl('path','cmarrow',{d:'M '+cur[0]+' '+cur[1]+' L '+p1[0]+' '+p1[1]+' L '+p2[0]+' '+p2[1]+' Z'}));
|
|
2655
|
+
chip((orig[0]+cur[0])/2,(orig[1]+cur[1])/2,'moved '+fmtFtIn(L/FT*12),false);} // primary — steered by the cursor
|
|
2656
|
+
const dx=cur[0]-anchor[0],dy=cur[1]-anchor[1],dl=Math.hypot(dx,dy)||1,nx=-dy/dl,ny=dx/dl,poff=18/zoom; // len chip: member midpoint, nudged perpendicular off the line
|
|
2657
|
+
const lenFt=_lenFt(m);if(lenFt>0&&isFinite(lenFt))chip((anchor[0]+cur[0])/2+nx*poff,(anchor[1]+cur[1])/2+ny*poff,'len '+fmtFtIn(lenFt*12),true);} // secondary — live member length, recessed (drawn last → on top); skip if degenerate
|
|
2658
|
+
function epPrevClear(){const g=document.getElementById('epPrevG');if(g)g.remove();}
|
|
2512
2659
|
function cmClick(e){
|
|
2513
2660
|
if(!selIds.size){toast('Selection is empty — '+(cmTool==='move'?'Move':'Copy')+' ended');disarmCm();render();return;} // selection drained while armed (undo/delete) — end the tool instead of collecting doomed picks
|
|
2514
2661
|
const s=cmSnapAt(e);
|
|
@@ -2706,7 +2853,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
2706
2853
|
if(tgt){geoMode=null;setGeo();snapEndMulti(selArr(),tgt[0],tgt[1]);} // miss (empty/own line) keeps the mode armed
|
|
2707
2854
|
return;}
|
|
2708
2855
|
if(t.classList.contains('handle')){const id=t.dataset.mid||[...selIds][0],m=byId(id),h=+t.dataset.h;if(!m)return;buildSnap(id);
|
|
2709
|
-
drag={type:'end',h,id,anchor:m.wp[1-h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
|
|
2856
|
+
drag={type:'end',h,id,anchor:m.wp[1-h].slice(),orig:m.wp[h].slice(),pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // anchor = the fixed end (length ref); orig = where this end started (moved ref)
|
|
2710
2857
|
if(t.classList.contains('lblhot')){const prof=t.dataset.prof;
|
|
2711
2858
|
if(picking&&pickKind==='profile'&&selIds.size===1){const id=[...selIds][0];picking=false;edit(()=>{const m=byId(id);m.profile=prof;m.rfi=(_wt(prof)==null);if(!profs.includes(prof)){profs.push(prof);profs.sort();}});return;}
|
|
2712
2859
|
if(mode==='add'){addProfile=prof;const hi=document.getElementById('addProf');if(hi)hi.value=addProfile;const ph=document.getElementById('pickHint');if(ph){ph.classList.add('pick');setTimeout(()=>ph&&ph.classList.remove('pick'),450);}return;}
|
|
@@ -2810,8 +2957,9 @@ svg.addEventListener('pointermove',e=>{
|
|
|
2810
2957
|
const m=byId(drag.id);if(!m)return;let x=p.x,y=p.y;
|
|
2811
2958
|
if(e.shiftKey){[x,y]=orthoLock(drag.anchor[0],drag.anchor[1],x,y);snapClear();} // ortho endpoint — local frame when set, else screen H/V
|
|
2812
2959
|
else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
|
|
2813
|
-
m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);
|
|
2814
|
-
|
|
2960
|
+
m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);
|
|
2961
|
+
{let g=document.getElementById('epPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='epPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}epPrevDraw(g,drag.orig,drag.anchor,[x,y],m);}}); // live moved + length readout
|
|
2962
|
+
svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();epPrevClear();
|
|
2815
2963
|
if(drag.type==='gridline'){const wasBubble=drag.bubble,moved=drag.moved,pre=drag.pre;drag=null;gridReadoutHide();
|
|
2816
2964
|
snapOnlyClear2d(); // a grid-line drag is one discrete operation → its single-shot snap override always reverts (grid-line drags can run while the grid panel is open, so don't gate on anyToolActive())
|
|
2817
2965
|
if(!moved){if(wasBubble){setGridMode(true); // opens the panel (clears the logical selection). NO render() — replacing the bubble between the two clicks of a dbl-click would swallow the rename gesture…
|
|
@@ -2849,7 +2997,7 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
2849
2997
|
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);
|
|
2850
2998
|
if(!anyToolActive())snapOnlyClear2d(); // a select-mode drag WAS the operation → the override was single-shot
|
|
2851
2999
|
drag=null;render();});
|
|
2852
|
-
svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
|
|
3000
|
+
svg.addEventListener('pointercancel',()=>{epPrevClear();if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
|
|
2853
3001
|
svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
|
|
2854
3002
|
const at=document.elementFromPoint(e.clientX,e.clientY); // the pointer capture the drag takes retargets dblclick to the svg root — resolve the bubble by position, not e.target
|
|
2855
3003
|
const gb=at&&at.closest&&at.closest('g.gridbubg');
|
|
@@ -2865,6 +3013,8 @@ document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode(
|
|
|
2865
3013
|
document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
|
|
2866
3014
|
function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
|
|
2867
3015
|
document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
|
|
3016
|
+
function updLenToggle(){const b=document.getElementById('lenToggleB');if(b)b.textContent=showSelLen?'Hide member length':'Show member length';}
|
|
3017
|
+
document.getElementById('lenToggleB').onclick=()=>{showSelLen=!showSelLen;try{localStorage.setItem('steel:selLen:v1',showSelLen?'1':'0');}catch(_){}updLenToggle();render();}; // render() repaints the 2D chips + pushes labels to 3D
|
|
2868
3018
|
function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
|
|
2869
3019
|
document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
|
|
2870
3020
|
document.getElementById('gridEditB').onclick=()=>{setGridMode(!gridMode);render();};
|
|
@@ -3072,6 +3222,23 @@ const view3dApi={
|
|
|
3072
3222
|
beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
|
|
3073
3223
|
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
3224
|
onInsertModeChange:(on)=>{const b=document.getElementById('m3dInsert');if(b){b.classList.toggle('on',!!on);b.textContent=on?'✕ Cancel insert':'Insert…';}}, // armed → cancel target
|
|
3225
|
+
onBasePickModeChange:()=>{}, // Mode B armed state shows via the 3D crosshair + elevation readout; nothing else to reflect
|
|
3226
|
+
onBasePick:(p)=>{ // Mode B: retarget the armed column's base to the picked elevation (world mm → inches), ONE undo entry
|
|
3227
|
+
const m=byId(basePickColId); if(!m||m.role!=='column'||!m.col){toast('No column to trim');return;}
|
|
3228
|
+
const tos=(m.col.tos!=null?m.col.tos:defaultTOS); // inches (col.bos/tos are inches; memberGeometry ×25.4 → mm)
|
|
3229
|
+
if(tos==null){toast('Set the column top (TOS) first');return;}
|
|
3230
|
+
const want=p.z/25.4, cap=tos-MIN_COL_STUB_IN;
|
|
3231
|
+
const bosIn=Math.min(want, cap); // world mm → inches; clamp so the column keeps a min stub (bos<tos)
|
|
3232
|
+
const clamped=want>cap+0.02; // the pick was above the min-stub cap → we seated at the cap, not the picked level
|
|
3233
|
+
const cur=(m.col.bos!=null?m.col.bos:0);
|
|
3234
|
+
if(Math.abs(bosIn-cur)<0.02){toast('Column base already at that level');return;}
|
|
3235
|
+
const dir=bosIn<cur?'extended':'trimmed'; // lower base = longer column = extended; higher = trimmed
|
|
3236
|
+
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
|
|
3237
|
+
// Report the ACTUAL result: a snapped level when honored, else the ft-in elevation; call out the clamp so a
|
|
3238
|
+
// pick above the min stub isn't misreported as landing on the snapped level.
|
|
3239
|
+
const where=clamped?(fmtFtIn(bosIn)+' (clamped to a 1′ minimum stub)'):(p.snapped?(p.label||'a level'):fmtFtIn(bosIn));
|
|
3240
|
+
toast('Column '+m.id+' base '+dir+' to '+where+' — base plate re-seated');
|
|
3241
|
+
},
|
|
3075
3242
|
onInsertPlace:(pick,pending)=>{
|
|
3076
3243
|
if(pending&&pending.kind==='connection'&&pending.connection){
|
|
3077
3244
|
const conn=pending.connection;const rc=conn.recipe;
|
|
@@ -3261,6 +3428,230 @@ function retypeProfile(profKey,cat){if(!MTYPE_LABEL[cat])return;
|
|
|
3261
3428
|
if(!targets.length||targets.every(m=>memberTypeOf(m)===cat))return; // already that type → no-op (no empty undo step)
|
|
3262
3429
|
edit(()=>{for(const m of targets)m.memberType=cat;});
|
|
3263
3430
|
setTimeout(()=>{const r=[...document.querySelectorAll('#m3dLegend .lrow[data-key]')].find(r=>r.dataset.key===profKey);if(r){r.classList.add('flash');setTimeout(()=>r.classList.remove('flash'),400);}},250);}
|
|
3431
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
3432
|
+
// Panel tabs (Objects | Views | Favourites) + the Views Organizer (Slice V1).
|
|
3433
|
+
// The floating panel #m3dLegend hosts three tab bodies; only the active one shows. build3DLegend renders the
|
|
3434
|
+
// Objects body; renderViewsTab renders the Views body from C.views[] (per-model, auto-saved via scheduleSave).
|
|
3435
|
+
// A saved view is an opaque, versioned snapshot: {v,id,name,order,camera,projection,mode,clips,objects}.
|
|
3436
|
+
// ════════════════════════════════════════════════════════════════════════════════════════════════════════════
|
|
3437
|
+
let legendTab=(()=>{const t=localStorage.getItem('floless.legendTab');return (t==='views'||t==='fav')?t:'objects';})();
|
|
3438
|
+
let legendTabsWired=false;
|
|
3439
|
+
function saveLegendTab(){try{localStorage.setItem('floless.legendTab',legendTab);}catch{}}
|
|
3440
|
+
// Switch the active tab: light the strip button, show that body, render it on demand, persist the choice.
|
|
3441
|
+
function setLegendTab(tab){if(tab!=='objects'&&tab!=='views'&&tab!=='fav')tab='objects';legendTab=tab;saveLegendTab();
|
|
3442
|
+
document.querySelectorAll('#m3dTabs .m3dtab').forEach(b=>{const on=b.dataset.tab===tab;b.classList.toggle('on',on);b.setAttribute('aria-selected',String(on));});
|
|
3443
|
+
const bodies={objects:'m3dLegendBody',views:'m3dViewsBody',fav:'m3dFavBody'};
|
|
3444
|
+
for(const [k,id] of Object.entries(bodies)){const el=document.getElementById(id);if(el)el.classList.toggle('on',k===tab);}
|
|
3445
|
+
if(tab==='views')renderViewsTab(); // (re)render the Views list when it becomes visible
|
|
3446
|
+
updateViewsBtn();
|
|
3447
|
+
}
|
|
3448
|
+
// The 3D-toolbar Views button lights (.on) while the panel is open on the Views tab.
|
|
3449
|
+
function updateViewsBtn(){const vb=document.getElementById('m3dViewsBtn');if(!vb)return;const p=document.getElementById('m3dLegend');vb.classList.toggle('on',!!(p&&p.style.display!=='none'&&legendTab==='views'));}
|
|
3450
|
+
// Show the whole panel (used on 3D entry + by the toolbar Views button); optionally force a tab.
|
|
3451
|
+
function showLegendPanel(tab){const p=document.getElementById('m3dLegend');if(!p)return;p.style.display='flex';setLegendTab(tab||legendTab);}
|
|
3452
|
+
// Wire the tab strip once (its buttons persist across build3DLegend rebuilds).
|
|
3453
|
+
function wireLegendTabs(){if(legendTabsWired)return;legendTabsWired=true;
|
|
3454
|
+
document.querySelectorAll('#m3dTabs .m3dtab').forEach(b=>b.addEventListener('click',()=>setLegendTab(b.dataset.tab)));
|
|
3455
|
+
}
|
|
3456
|
+
// ── Views state ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
3457
|
+
const VIEWS_MAX=10;
|
|
3458
|
+
let viewsQuery=''; // transient Views-tab search filter — NOT persisted
|
|
3459
|
+
let viewsSearchOpen=false; // is the search input revealed?
|
|
3460
|
+
let activeViewId=null; // the view whose snapshot was last applied (persistent active affordance) — transient
|
|
3461
|
+
function viewsSorted(){return (C.views||[]).slice().sort((a,b)=>(a.order||0)-(b.order||0));}
|
|
3462
|
+
// Glyph: a small cube-face pictogram for the 6 direction presets (recognised by the view's name — the source of
|
|
3463
|
+
// truth that survives sanitizeViews), a generic camera otherwise. Muted, a recognition aid only.
|
|
3464
|
+
const VIEW_DIRS={top:1,bottom:1,front:1,back:1,left:1,right:1,iso:1};
|
|
3465
|
+
function viewDirOf(v){const n=((v&&v.name)||'').trim().toLowerCase();return VIEW_DIRS[n]?n:null;}
|
|
3466
|
+
function viewGlyph(v){const NS='http://www.w3.org/2000/svg',dir=viewDirOf(v);
|
|
3467
|
+
const svg=document.createElementNS(NS,'svg');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.3');svg.setAttribute('stroke-linejoin','round');svg.setAttribute('aria-hidden','true');
|
|
3468
|
+
if(dir){ // a small cube with the active face tinted — a direction pictogram (top/front/side highlighted)
|
|
3469
|
+
const cube=document.createElementNS(NS,'path');cube.setAttribute('d','M8 1.5 14 4.5 14 11 8 14.5 2 11 2 4.5 Z M8 1.5 8 8 M8 8 2 4.5 M8 8 14 4.5');cube.setAttribute('opacity','.65');svg.appendChild(cube);
|
|
3470
|
+
const face=document.createElementNS(NS,'path');face.setAttribute('fill','currentColor');face.setAttribute('stroke','none');face.setAttribute('opacity','.9');
|
|
3471
|
+
const F={top:'M8 1.5 14 4.5 8 8 2 4.5 Z',front:'M2 4.5 8 8 8 14.5 2 11 Z',back:'M8 8 14 4.5 14 11 8 14.5 Z',left:'M2 4.5 8 8 8 14.5 2 11 Z',right:'M8 8 14 4.5 14 11 8 14.5 Z',bottom:'M8 8 2 11 8 14.5 14 11 Z',iso:'M8 1.5 14 4.5 8 8 2 4.5 Z'};
|
|
3472
|
+
face.setAttribute('d',F[dir]||F.top);svg.appendChild(face);
|
|
3473
|
+
}else{ // a generic camera glyph
|
|
3474
|
+
const body=document.createElementNS(NS,'rect');body.setAttribute('x','2');body.setAttribute('y','5');body.setAttribute('width','12');body.setAttribute('height','8');body.setAttribute('rx','1.5');svg.appendChild(body);
|
|
3475
|
+
const lens=document.createElementNS(NS,'circle');lens.setAttribute('cx','8');lens.setAttribute('cy','9');lens.setAttribute('r','2.2');svg.appendChild(lens);
|
|
3476
|
+
const hump=document.createElementNS(NS,'path');hump.setAttribute('d','M5.5 5 6.5 3.5 9.5 3.5 10.5 5');svg.appendChild(hump);
|
|
3477
|
+
}
|
|
3478
|
+
return svg;}
|
|
3479
|
+
// A magnifier icon (matches the objects-list search icon) built via DOM (no innerHTML), currentColor-stroked.
|
|
3480
|
+
function magnifierSvg(){const NS='http://www.w3.org/2000/svg',svg=document.createElementNS(NS,'svg');
|
|
3481
|
+
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');
|
|
3482
|
+
const cir=document.createElementNS(NS,'circle');cir.setAttribute('cx','7');cir.setAttribute('cy','7');cir.setAttribute('r','4.5');
|
|
3483
|
+
const lin=document.createElementNS(NS,'line');lin.setAttribute('x1','10.6');lin.setAttribute('y1','10.6');lin.setAttribute('x2','14');lin.setAttribute('y2','14');
|
|
3484
|
+
svg.append(cir,lin);return svg;}
|
|
3485
|
+
// ── Save current view + New from direction ───────────────────────────────────────────────────────────────────
|
|
3486
|
+
// Capture the FULL A1 snapshot from the live editor. name defaults to "View N"; a fresh id + order-at-top.
|
|
3487
|
+
function captureView(name){const V=window.Steel3DView;
|
|
3488
|
+
return {v:1,id:'v'+Math.random().toString(36).slice(2,9),name:name||('View '+((C.views||[]).length+1)),order:0,
|
|
3489
|
+
camera:V.cameraState?V.cameraState():null,projection:V.projection?V.projection():'persp',mode:V.mode?V.mode():'solid',
|
|
3490
|
+
clips:V.clipState?V.clipState():null,objects:V.objectsPaneState?V.objectsPaneState():null};}
|
|
3491
|
+
// Push a captured view to the TOP (order 0; bump the rest), cap-guarded, then persist + re-render + inline-rename it.
|
|
3492
|
+
function addView(v,{rename=true}={}){if(!Array.isArray(C.views))C.views=[];if(C.views.length>=VIEWS_MAX)return null;
|
|
3493
|
+
for(const x of C.views)x.order=(x.order||0)+1;v.order=0;C.views.push(v);activeViewId=v.id;scheduleSave();renderViewsTab();
|
|
3494
|
+
if(rename){const row=document.querySelector('#m3dViewsBody .vwrow[data-id="'+v.id+'"]');if(row)startViewRename(v,row);}
|
|
3495
|
+
return v;}
|
|
3496
|
+
function saveCurrentView(){if((C.views||[]).length>=VIEWS_MAX)return;addView(captureView());}
|
|
3497
|
+
// New from a direction preset: apply the clean preset camera, then capture (current mode; no clips/isolate baked —
|
|
3498
|
+
// captureView reads clipState/objectsPaneState, but from a clean preset these carry the current scene; the spec's
|
|
3499
|
+
// intent is a clean viewpoint, so we snapshot right after applyView which only moved the camera).
|
|
3500
|
+
function saveViewFromDirection(dir){const V=window.Steel3DView;if(!V||!V.applyView)return;
|
|
3501
|
+
const label=dir.charAt(0).toUpperCase()+dir.slice(1);
|
|
3502
|
+
V.applyView(dir);reflectProj();reflectMode();
|
|
3503
|
+
addView(captureView(label));}
|
|
3504
|
+
// ── Activate: apply a view's snapshot, then refresh the Objects tab so restored visibility shows ────────────────
|
|
3505
|
+
function activateView(v){const V=window.Steel3DView;if(!V||!v)return;
|
|
3506
|
+
// Apply each snapshot independently: a malformed opaque sub-snapshot (especially clips — setClipState does
|
|
3507
|
+
// NOT re-sanitise its input) must not throw and strand the remaining restores + the active-row marking. Each
|
|
3508
|
+
// failure is caught + counted so the user gets one honest toast instead of a silently half-applied view.
|
|
3509
|
+
let failed=0;
|
|
3510
|
+
const step=(fn,label)=>{try{fn();}catch(e){failed++;console.warn('view restore: '+label+' failed',e);}};
|
|
3511
|
+
if(v.camera&&V.setCameraState)step(()=>V.setCameraState(v.camera),'camera'); // setCameraState also restores projection
|
|
3512
|
+
else if(V.setProjection)step(()=>V.setProjection(v.projection==='ortho'?'ortho':'persp'),'projection');
|
|
3513
|
+
if(V.setDisplayMode)step(()=>V.setDisplayMode(v.mode||'solid'),'mode'); // display mode
|
|
3514
|
+
if(v.clips&&V.setClipState)step(()=>V.setClipState(v.clips),'clips'); // clip boxes + planes + work area
|
|
3515
|
+
if(v.objects&&V.applyObjectsPaneState)step(()=>V.applyObjectsPaneState(v.objects),'objects'); // objects-pane visibility/isolate
|
|
3516
|
+
activeViewId=v.id;
|
|
3517
|
+
step(()=>build3DLegend(),'legend'); // rebuild the Objects tab so its rows reflect the restored visibility
|
|
3518
|
+
reflectProj();reflectMode();
|
|
3519
|
+
renderViewsTab(); // re-mark the active row
|
|
3520
|
+
if(failed)toast('Some of “'+v.name+'” couldn’t be restored — the saved view may be from an older model.');
|
|
3521
|
+
}
|
|
3522
|
+
// ── Rename (inline; Enter/blur commit, Esc cancels; duplicate names auto-suffixed) ──────────────────────────────
|
|
3523
|
+
function uniqueViewName(name,exceptId){let base=(name||'View').trim().slice(0,80)||'View';const taken=new Set((C.views||[]).filter(v=>v.id!==exceptId).map(v=>v.name));
|
|
3524
|
+
if(!taken.has(base))return base;let i=2;while(taken.has(base+' '+i))i++;return base+' '+i;}
|
|
3525
|
+
function startViewRename(v,row){const nameEl=row.querySelector('.vwname');if(!nameEl)return;
|
|
3526
|
+
const inp=document.createElement('input');inp.className='vwedit';inp.value=v.name;
|
|
3527
|
+
nameEl.replaceWith(inp);inp.focus();inp.select();
|
|
3528
|
+
let done=false;
|
|
3529
|
+
const finish=save=>{if(done)return;done=true;
|
|
3530
|
+
if(save){const nm=uniqueViewName(inp.value,v.id);if(nm&&nm!==v.name){v.name=nm;scheduleSave();}}
|
|
3531
|
+
renderViewsTab();}; // commit or cancel → re-render restores the row (with the possibly-updated name/glyph)
|
|
3532
|
+
inp.addEventListener('keydown',ev=>{ev.stopPropagation();if(ev.key==='Enter'){ev.preventDefault();finish(true);}else if(ev.key==='Escape'){ev.preventDefault();finish(false);}});
|
|
3533
|
+
inp.addEventListener('blur',()=>finish(true));
|
|
3534
|
+
}
|
|
3535
|
+
// ── Row ⋯ menu: Rename / Duplicate / Update to current ──────────────────────────────────────────────────────────
|
|
3536
|
+
function updateViewToCurrent(v){const snap=captureView(v.name);snap.id=v.id;snap.order=v.order;v.camera=snap.camera;v.projection=snap.projection;v.mode=snap.mode;v.clips=snap.clips;v.objects=snap.objects;activeViewId=v.id;scheduleSave();renderViewsTab();toast('Updated “'+v.name+'” to the current view');}
|
|
3537
|
+
function duplicateView(v){if((C.views||[]).length>=VIEWS_MAX){toast(VIEWS_MAX+' of '+VIEWS_MAX+' views — delete one to duplicate');return;}
|
|
3538
|
+
const copy=JSON.parse(JSON.stringify(v));copy.id='v'+Math.random().toString(36).slice(2,9);copy.name=uniqueViewName(v.name+' copy');
|
|
3539
|
+
// insert right after the source in order
|
|
3540
|
+
for(const x of C.views)if((x.order||0)>(v.order||0))x.order=(x.order||0)+1;copy.order=(v.order||0)+1;C.views.push(copy);activeViewId=copy.id;scheduleSave();renderViewsTab();}
|
|
3541
|
+
function openViewRowMenu(v,anchorEl){closeViewRowMenu();
|
|
3542
|
+
const m=document.createElement('div');m.id='vwRowMenu';m.className='m3dmenu open';m.setAttribute('role','menu');
|
|
3543
|
+
const mk=(label,fn)=>{const b=document.createElement('button');b.type='button';b.textContent=label;b.setAttribute('role','menuitem');b.addEventListener('click',()=>{closeViewRowMenu();fn();});return b;};
|
|
3544
|
+
m.append(mk('Rename',()=>{const row=document.querySelector('#m3dViewsBody .vwrow[data-id="'+v.id+'"]');if(row)startViewRename(v,row);}),
|
|
3545
|
+
mk('Duplicate',()=>duplicateView(v)),
|
|
3546
|
+
mk('Update to current',()=>updateViewToCurrent(v)));
|
|
3547
|
+
document.body.appendChild(m);
|
|
3548
|
+
const r=anchorEl.getBoundingClientRect();const mw=m.offsetWidth||160,mh=m.offsetHeight||110;
|
|
3549
|
+
let x=r.right-mw,y=r.bottom+4;if(x<6)x=6;if(y+mh>innerHeight-6)y=r.top-mh-4;if(y<6)y=6;
|
|
3550
|
+
m.style.left=x+'px';m.style.top=y+'px';
|
|
3551
|
+
setTimeout(()=>document.addEventListener('mousedown',viewRowMenuOutside,true),0);
|
|
3552
|
+
}
|
|
3553
|
+
function viewRowMenuOutside(e){const m=document.getElementById('vwRowMenu');if(m&&!m.contains(e.target))closeViewRowMenu();}
|
|
3554
|
+
function closeViewRowMenu(){const m=document.getElementById('vwRowMenu');if(m)m.remove();document.removeEventListener('mousedown',viewRowMenuOutside,true);}
|
|
3555
|
+
// ── Delete (immediate + undo toast that re-inserts at the original order) ────────────────────────────────────────
|
|
3556
|
+
function deleteView(v){const idx=(C.views||[]).findIndex(x=>x.id===v.id);if(idx<0)return;const removed=C.views[idx];const order=removed.order;
|
|
3557
|
+
C.views.splice(idx,1);if(activeViewId===v.id)activeViewId=null;scheduleSave();renderViewsTab();
|
|
3558
|
+
// Undo takes precedence over the soft 10-cap: restoring a view the user just deleted is them reversing their
|
|
3559
|
+
// own action, so re-add unconditionally (a transient 11th is fine — the cap only gates NEW saves). Refusing
|
|
3560
|
+
// here would make the Undo affordance silently destroy data it promised to restore.
|
|
3561
|
+
undoToast('Deleted “'+removed.name+'”',()=>{if(!Array.isArray(C.views))C.views=[];removed.order=order;C.views.push(removed);scheduleSave();renderViewsTab();});
|
|
3562
|
+
}
|
|
3563
|
+
// ── Reorder (pointer-drag a row; the drag-handle is the initiator — same recipe as the objects-list rows) ─────────
|
|
3564
|
+
function wireViewRowDrag(row,v){const handle=row.querySelector('.drag-handle');if(!handle)return;
|
|
3565
|
+
handle.addEventListener('pointerdown',e=>{e.preventDefault();e.stopPropagation();
|
|
3566
|
+
const sx=e.clientX,sy=e.clientY;let started=false,target=null;
|
|
3567
|
+
const move=ev=>{if(!started){if(Math.hypot(ev.clientX-sx,ev.clientY-sy)<6)return;started=true;row._dragging=true;}
|
|
3568
|
+
const elp=document.elementFromPoint(ev.clientX,ev.clientY);const over=elp&&elp.closest?elp.closest('#m3dViewsBody .vwrow'):null;
|
|
3569
|
+
if(over!==target){if(target)target.classList.remove('drop-target');target=(over&&over!==row)?over:null;if(target)target.classList.add('drop-target');}};
|
|
3570
|
+
const up=()=>{document.removeEventListener('pointermove',move);document.removeEventListener('pointerup',up);
|
|
3571
|
+
if(target)target.classList.remove('drop-target');
|
|
3572
|
+
const dropId=started&&target?target.dataset.id:null;setTimeout(()=>{row._dragging=false;},0);
|
|
3573
|
+
if(dropId&&dropId!==v.id)reorderView(v.id,dropId);};
|
|
3574
|
+
document.addEventListener('pointermove',move);document.addEventListener('pointerup',up);});}
|
|
3575
|
+
// Move `dragId` to just before `beforeId` (its slot), then re-number order 0..n so it persists densely.
|
|
3576
|
+
function reorderView(dragId,beforeId){const list=viewsSorted();const from=list.findIndex(v=>v.id===dragId),to=list.findIndex(v=>v.id===beforeId);if(from<0||to<0||from===to)return;
|
|
3577
|
+
const [moved]=list.splice(from,1);list.splice(to,0,moved);list.forEach((v,i)=>v.order=i);scheduleSave();renderViewsTab();
|
|
3578
|
+
setTimeout(()=>{const r=document.querySelector('#m3dViewsBody .vwrow[data-id="'+dragId+'"]');if(r){r.classList.add('flash');setTimeout(()=>r.classList.remove('flash'),400);}},0);
|
|
3579
|
+
}
|
|
3580
|
+
// ── Search (an always-present 🔍 toggle; the input opens on demand; filters by name) ──────────────────────────────
|
|
3581
|
+
function toggleViewsSearch(){viewsSearchOpen=!viewsSearchOpen;if(!viewsSearchOpen){viewsQuery='';}renderViewsTab();
|
|
3582
|
+
if(viewsSearchOpen){const inp=document.getElementById('vwSearchInput');if(inp)inp.focus();}}
|
|
3583
|
+
// ── Render the Views tab body from C.views[] ────────────────────────────────────────────────────────────────────
|
|
3584
|
+
function renderViewsTab(){const host=document.getElementById('m3dViewsBody');if(!host)return;host.replaceChildren();
|
|
3585
|
+
const all=viewsSorted();const atMax=all.length>=VIEWS_MAX;
|
|
3586
|
+
// Header row: Save-current-view split (main + ▾) · search toggle
|
|
3587
|
+
const head=document.createElement('div');head.id='vwHeadRow';
|
|
3588
|
+
const split=document.createElement('div');split.id='vwSaveSplit';if(atMax)split.classList.add('capped');
|
|
3589
|
+
const saveBtn=document.createElement('button');saveBtn.type='button';saveBtn.id='vwSaveBtn';saveBtn.textContent='+ Save current view';saveBtn.dataset.tip=atMax?(VIEWS_MAX+' of '+VIEWS_MAX+' views — delete one to add'):'Save the current camera, display mode, clips and object visibility as a named view';saveBtn.disabled=atMax;saveBtn.addEventListener('click',saveCurrentView);
|
|
3590
|
+
const moreBtn=document.createElement('button');moreBtn.type='button';moreBtn.id='vwSaveMore';moreBtn.textContent='▾';moreBtn.setAttribute('aria-haspopup','menu');moreBtn.dataset.tip='New view from a direction (Top, Front, Iso, …)';moreBtn.disabled=atMax;moreBtn.addEventListener('click',e=>{e.stopPropagation();openNewFromDirectionMenu(moreBtn);});
|
|
3591
|
+
split.append(saveBtn,moreBtn);
|
|
3592
|
+
const searchTog=document.createElement('button');searchTog.type='button';searchTog.id='vwSearchTog';searchTog.setAttribute('aria-label','Search views');searchTog.dataset.tip='Search views by name';if(viewsSearchOpen)searchTog.classList.add('on');searchTog.appendChild(magnifierSvg());searchTog.addEventListener('click',toggleViewsSearch);
|
|
3593
|
+
head.append(split,searchTog);host.appendChild(head);
|
|
3594
|
+
// Cap counter (N/10 when N≥8) + the disabled reason (at 10)
|
|
3595
|
+
const cnt=document.createElement('div');cnt.id='vwCount';cnt.textContent=all.length+'/'+VIEWS_MAX;if(all.length>=8)cnt.classList.add('show');host.appendChild(cnt);
|
|
3596
|
+
if(atMax){const cap=document.createElement('div');cap.id='vwCap';cap.className='show';cap.textContent=VIEWS_MAX+' of '+VIEWS_MAX+' views — delete one to add.';host.appendChild(cap);}
|
|
3597
|
+
// Search box (revealed on demand)
|
|
3598
|
+
if(viewsSearchOpen){const sb=document.createElement('div');sb.id='vwSearch';sb.className='show'+(viewsQuery?' has':'');
|
|
3599
|
+
const ico=Object.assign(document.createElement('span'),{className:'lsico'});ico.setAttribute('aria-hidden','true');ico.appendChild(magnifierSvg());
|
|
3600
|
+
const inp=document.createElement('input');inp.id='vwSearchInput';inp.type='text';inp.placeholder='Search views…';inp.autocomplete='off';inp.value=viewsQuery;inp.setAttribute('role','searchbox');inp.setAttribute('aria-label','Search saved views');
|
|
3601
|
+
const clr=Object.assign(document.createElement('span'),{className:'lsx',textContent:'×'});clr.dataset.tip='Clear';
|
|
3602
|
+
inp.addEventListener('input',()=>{viewsQuery=inp.value;applyViewsFilter();});
|
|
3603
|
+
inp.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();if(inp.value){inp.value='';viewsQuery='';applyViewsFilter();}else{toggleViewsSearch();}}});
|
|
3604
|
+
clr.addEventListener('click',()=>{if(!inp.value&&!viewsQuery)return;inp.value='';viewsQuery='';applyViewsFilter();inp.focus();});
|
|
3605
|
+
sb.append(ico,inp,clr);host.appendChild(sb);}
|
|
3606
|
+
// Body: empty state, or the list
|
|
3607
|
+
if(!all.length){const e=Object.assign(document.createElement('div'),{className:'vwempty'});e.textContent='No saved views yet — frame the model and Save current view.';host.appendChild(e);return;}
|
|
3608
|
+
const list=document.createElement('div');list.id='vwList';host.appendChild(list);
|
|
3609
|
+
for(const v of all)list.appendChild(buildViewRow(v));
|
|
3610
|
+
applyViewsFilter();
|
|
3611
|
+
}
|
|
3612
|
+
function buildViewRow(v){const row=document.createElement('div');row.className='vwrow'+(activeViewId===v.id?' active':'');row.dataset.id=v.id;
|
|
3613
|
+
const dh=Object.assign(document.createElement('span'),{className:'drag-handle',textContent:'⠿'});dh.dataset.tip='Drag to reorder';['click','dblclick'].forEach(ev=>dh.addEventListener(ev,e=>e.stopPropagation()));row.appendChild(dh);
|
|
3614
|
+
const glyph=Object.assign(document.createElement('span'),{className:'vwglyph'});glyph.appendChild(viewGlyph(v));row.appendChild(glyph);
|
|
3615
|
+
const name=Object.assign(document.createElement('span'),{className:'vwname',textContent:v.name});name.dataset.tip='Click to activate · double-click to rename';row.appendChild(name);
|
|
3616
|
+
const x=Object.assign(document.createElement('span'),{className:'vx',textContent:'×'});x.dataset.tip='Delete this view';x.addEventListener('click',e=>{e.stopPropagation();deleteView(v);});row.appendChild(x);
|
|
3617
|
+
const dots=Object.assign(document.createElement('span'),{className:'vdots',textContent:'⋯'});dots.dataset.tip='More — Rename, Duplicate, Update to current';dots.setAttribute('role','button');dots.addEventListener('click',e=>{e.stopPropagation();openViewRowMenu(v,dots);});row.appendChild(dots);
|
|
3618
|
+
// single-click activates (deferred so a double-click renames instead); the drag-handle swallows its own clicks
|
|
3619
|
+
let clickT=null;
|
|
3620
|
+
row.addEventListener('click',e=>{if(row._dragging)return;if(e.target===dots||e.target===x)return;clearTimeout(clickT);clickT=setTimeout(()=>{if(!row._dragging)activateView(v);},200);});
|
|
3621
|
+
name.addEventListener('dblclick',e=>{e.preventDefault();e.stopPropagation();clearTimeout(clickT);startViewRename(v,row);});
|
|
3622
|
+
wireViewRowDrag(row,v);
|
|
3623
|
+
return row;}
|
|
3624
|
+
// Filter rows by name; no-results shows a muted line + a Clear affordance (Save stays enabled — it's in the header).
|
|
3625
|
+
function applyViewsFilter(){const host=document.getElementById('m3dViewsBody');if(!host)return;const list=document.getElementById('vwList');
|
|
3626
|
+
const old=host.querySelector('.vwnores');if(old)old.remove();
|
|
3627
|
+
const sb=document.getElementById('vwSearch');if(sb)sb.classList.toggle('has',!!viewsQuery);
|
|
3628
|
+
if(!list)return;const q=(viewsQuery||'').trim().toLowerCase();const rows=[...list.querySelectorAll('.vwrow')];
|
|
3629
|
+
if(!q){rows.forEach(r=>r.style.display='');return;}
|
|
3630
|
+
let any=false;rows.forEach(r=>{const nm=(r.querySelector('.vwname')||{}).textContent||'';const hit=nm.toLowerCase().includes(q);r.style.display=hit?'':'none';if(hit)any=true;});
|
|
3631
|
+
if(!any){const e=document.createElement('div');e.className='vwnores';e.append(document.createTextNode('No views match “'+viewsQuery.trim()+'”.'));
|
|
3632
|
+
const clr=document.createElement('button');clr.type='button';clr.className='pilllink';clr.textContent='Clear';clr.addEventListener('click',()=>{viewsQuery='';const inp=document.getElementById('vwSearchInput');if(inp)inp.value='';applyViewsFilter();if(inp)inp.focus();});
|
|
3633
|
+
e.appendChild(clr);host.appendChild(e);}
|
|
3634
|
+
}
|
|
3635
|
+
// The ▾ "New from direction" menu — reuses the .m3dmenu skin, opened under the ▾ button.
|
|
3636
|
+
function openNewFromDirectionMenu(anchorEl){closeViewRowMenu();
|
|
3637
|
+
const m=document.createElement('div');m.id='vwRowMenu';m.className='m3dmenu open';m.setAttribute('role','menu');
|
|
3638
|
+
const DIRS=[['top','Top'],['front','Front'],['back','Back'],['left','Left'],['right','Right'],['iso','Iso']];
|
|
3639
|
+
const lab=Object.assign(document.createElement('div'),{className:'mlabel',textContent:'New from direction'});m.appendChild(lab);
|
|
3640
|
+
for(const [dir,label] of DIRS){const b=document.createElement('button');b.type='button';b.textContent=label;b.setAttribute('role','menuitem');b.addEventListener('click',()=>{closeViewRowMenu();saveViewFromDirection(dir);});m.appendChild(b);}
|
|
3641
|
+
document.body.appendChild(m);
|
|
3642
|
+
const r=anchorEl.getBoundingClientRect();const mw=m.offsetWidth||160,mh=m.offsetHeight||210;
|
|
3643
|
+
let x=r.right-mw,y=r.bottom+4;if(x<6)x=6;if(y+mh>innerHeight-6)y=r.top-mh-4;if(y<6)y=6;
|
|
3644
|
+
m.style.left=x+'px';m.style.top=y+'px';
|
|
3645
|
+
setTimeout(()=>document.addEventListener('mousedown',viewRowMenuOutside,true),0);
|
|
3646
|
+
}
|
|
3647
|
+
// A themed undo toast (extends toast(): an inline "Undo" action + a ~5s window). Baseline tokens only.
|
|
3648
|
+
function undoToast(msg,onUndo){let t=document.getElementById('undoToast');
|
|
3649
|
+
if(!t){t=document.createElement('div');t.id='undoToast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;align-items:center;gap:12px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s';document.body.appendChild(t);}
|
|
3650
|
+
t.replaceChildren();t.appendChild(document.createTextNode(msg));
|
|
3651
|
+
const btn=document.createElement('button');btn.type='button';btn.textContent='Undo';btn.style.cssText='background:transparent;border:0;color:var(--brand);cursor:pointer;font:600 13px system-ui;padding:0;box-shadow:none';
|
|
3652
|
+
btn.addEventListener('click',()=>{clearTimeout(t._h);t.style.opacity='0';try{onUndo();}catch(e){console.error(e);}});
|
|
3653
|
+
t.appendChild(btn);t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},5000);
|
|
3654
|
+
}
|
|
3264
3655
|
// Connection categories ARE the joints (Phase 2): every part of a joint — including its own nuts/washers/welds
|
|
3265
3656
|
// — files under that connection. A part's connection = the joint its id prefixes (e.g. "bp-c1:weld" → bp-c1 →
|
|
3266
3657
|
// base-plate). Shared part-kinds are split per-connection by hiding the actual part IDS (setIdsHidden), since
|
|
@@ -3272,10 +3663,14 @@ const DIM_LABEL=Object.fromEntries(DIM_CATS);
|
|
|
3272
3663
|
// Dimension overlays grouped by the connection they annotate (a middle category under Dimensions). bolt_pitch/
|
|
3273
3664
|
// edge_clearance/cope_size come off the shear-plate fin plate + cope; base_plate/anchor_depth off the base plate.
|
|
3274
3665
|
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']}];
|
|
3275
|
-
|
|
3276
|
-
|
|
3666
|
+
// Renders the OBJECTS tab body (#m3dLegendBody) — NOT the whole panel. The panel shell + tab strip persist across
|
|
3667
|
+
// rebuilds; only this body's children are replaced. Every existing #m3dLegend .lrow/.cat-hdr selector still matches
|
|
3668
|
+
// because the body is a descendant of #m3dLegend. Panel/tab visibility is owned by showLegendPanel/setLegendTab.
|
|
3669
|
+
function build3DLegend(){const host=document.getElementById('m3dLegendBody');if(!host||!window.Steel3DView)return;
|
|
3670
|
+
const panelEl=document.getElementById('m3dLegend');
|
|
3671
|
+
if(panelEl&&!panelEl._ctxWired){panelEl._ctxWired=true;panelEl.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
|
|
3277
3672
|
const groups=window.Steel3DView.getGroups();host.replaceChildren();
|
|
3278
|
-
if(!groups.length){host.
|
|
3673
|
+
if(!groups.length){host.appendChild(Object.assign(document.createElement('div'),{className:'lsempty',textContent:'No objects in the model yet.'}));return;} // empty Objects tab — the panel stays open (Views/Favourites are independent of scene parts)
|
|
3279
3674
|
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);
|
|
3280
3675
|
const addRow=(g,indent,draggable)=>{const row=document.createElement('div');row.className='lrow'+(indent?' typed':'');row.dataset.key=g.key;
|
|
3281
3676
|
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
|
|
@@ -3422,8 +3817,8 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
|
|
|
3422
3817
|
x.addEventListener('click',e=>{e.stopPropagation();window.Steel3DView.removeClip(c.id);});
|
|
3423
3818
|
host.appendChild(row);
|
|
3424
3819
|
}
|
|
3425
|
-
{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 —
|
|
3426
|
-
|
|
3820
|
+
{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 — body's first child (above the mode toggle); visibility set by updateLegendReset (via refresh3DLegend)
|
|
3821
|
+
refresh3DLegend();applyLegendFilter();refreshLegendSel();} // the Objects body's visibility is owned by the active-tab class, not set here
|
|
3427
3822
|
// The contextual Isolate / Show all toolbar button: visible when something's selected OR while isolated (so
|
|
3428
3823
|
// "Show all" stays reachable after the selection is cleared). Updated on selection change + via onIsolateChange.
|
|
3429
3824
|
function updateIsolateBtn(){const b=document.getElementById('m3dIso');if(!b||!window.Steel3DView||!window.Steel3DView.isIsolated)return;
|
|
@@ -3501,7 +3896,7 @@ function onLegendSearchInput(q){
|
|
|
3501
3896
|
}
|
|
3502
3897
|
// Show/hide object rows by label; hide object categories left with no visible child; toggle the "no matches" line.
|
|
3503
3898
|
function applyLegendFilter(){
|
|
3504
|
-
const host=document.getElementById('
|
|
3899
|
+
const host=document.getElementById('m3dLegendBody');if(!host)return; // the objects rows/.lhint now live in the tab BODY (not the outer #m3dLegend panel) — scope reads AND the no-match insertBefore/appendChild here so the structure mutation targets the real parent
|
|
3505
3900
|
const q=(legendQuery||'').trim().toLowerCase();
|
|
3506
3901
|
const old=host.querySelector('.lsempty');if(old)old.remove();
|
|
3507
3902
|
const rows=[...host.querySelectorAll('.lrow[data-key],.lrow[data-connkey]')];
|
|
@@ -3676,6 +4071,8 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
|
|
|
3676
4071
|
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&ci$('connImportModal').style.display==='flex'){e.stopPropagation();ciClose();}},true);
|
|
3677
4072
|
}
|
|
3678
4073
|
document.getElementById('m3dIso').onclick=()=>{if(d3.isIsolated())d3.clearIsolation();else d3.isolateSelected();}; // onIsolateChange refreshes the button label/visibility
|
|
4074
|
+
// Views toolbar button: open the panel on the Views tab (or, if it's already open on Views, toggle it closed — a natural toolbar toggle).
|
|
4075
|
+
{const vb=document.getElementById('m3dViewsBtn');if(vb)vb.onclick=()=>{const p=document.getElementById('m3dLegend');const openOnViews=p&&p.style.display!=='none'&&legendTab==='views';if(openOnViews){p.style.display='none';vb.classList.remove('on');}else{wireLegendTabs();showLegendPanel('views');}updateViewsBtn();};}
|
|
3679
4076
|
// Work area: the ▢ Work area button opens a menu (Set to all objects / Define from selection / Show work area).
|
|
3680
4077
|
const workBtn=document.getElementById('m3dWork'),workMenu=document.getElementById('m3dWorkMenu');
|
|
3681
4078
|
function workMenuOutside(e){if(!workMenu.contains(e.target)&&e.target!==workBtn)workMenuClose();}
|
|
@@ -3719,7 +4116,7 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
|
|
|
3719
4116
|
document.getElementById('m3dCube').style.display=on?'block':'none';
|
|
3720
4117
|
document.getElementById('m3dAxes').style.display=on?'block':'none';
|
|
3721
4118
|
document.getElementById('snapBar').classList.toggle('s3d',on); // in 3D the snap bar shifts clear of the world-axis triad (bottom-right); see #snapBar.s3d
|
|
3722
|
-
if(!on)document.getElementById('m3dLegend').style.display='none'; //
|
|
4119
|
+
if(!on)document.getElementById('m3dLegend').style.display='none'; // panel is shown by setView (showLegendPanel) when entering 3D
|
|
3723
4120
|
}
|
|
3724
4121
|
async function setView(on){
|
|
3725
4122
|
if(on){
|
|
@@ -3733,7 +4130,7 @@ async function setView(on){
|
|
|
3733
4130
|
window.Steel3DView.show();
|
|
3734
4131
|
await window.Steel3DView.rebuild(true); // fit the camera on entering 3D
|
|
3735
4132
|
window.Steel3DView.setSelection(selIds);
|
|
3736
|
-
wire3DBar();build3DLegend();
|
|
4133
|
+
wire3DBar();wireLegendTabs();build3DLegend();showLegendPanel(); // build the Objects body, then open the panel on the last-used tab
|
|
3737
4134
|
reflectProj();reflectMode(); // reflect persisted projection + display mode into the Camera/Display dropdown triggers
|
|
3738
4135
|
}catch(e){ // a failed open must not strand the UI in 3D with a blank canvas
|
|
3739
4136
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
|