@floless/app 0.67.0 → 0.68.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
|
@@ -53022,7 +53022,7 @@ function appVersion() {
|
|
|
53022
53022
|
return resolveVersion({
|
|
53023
53023
|
isSea: isSea2(),
|
|
53024
53024
|
sqVersionXml: readSqVersionXml(),
|
|
53025
|
-
define: true ? "0.
|
|
53025
|
+
define: true ? "0.68.0" : void 0,
|
|
53026
53026
|
pkgVersion: readPkgVersion()
|
|
53027
53027
|
});
|
|
53028
53028
|
}
|
|
@@ -53032,7 +53032,7 @@ function resolveChannel(s) {
|
|
|
53032
53032
|
return "dev";
|
|
53033
53033
|
}
|
|
53034
53034
|
function appChannel() {
|
|
53035
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
53035
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.68.0" : void 0 });
|
|
53036
53036
|
}
|
|
53037
53037
|
|
|
53038
53038
|
// workflow-update.ts
|
|
@@ -53893,6 +53893,7 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53893
53893
|
const baked = { ...contract };
|
|
53894
53894
|
delete baked.dims3d;
|
|
53895
53895
|
delete baked.detail_placements;
|
|
53896
|
+
delete baked.prop_labels;
|
|
53896
53897
|
if (Array.isArray(contract.plans)) {
|
|
53897
53898
|
baked.plans = contract.plans.map((p) => {
|
|
53898
53899
|
if (p && typeof p === "object") {
|
|
@@ -41,7 +41,18 @@
|
|
|
41
41
|
"connections": { "type": "array", "items": { "$ref": "#/$defs/connection" }, "description": "Vendor-neutral connection library: each row maps a connection type → the firm's design detail# → per-platform component ids. Member ends/columns reference a row by id via `ends[].conn` / `col.conn` (legacy `note`/`detail` = fallback when `conn` is empty)." },
|
|
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
|
-
"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." }
|
|
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
|
+
"prop_labels": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"additionalProperties": true,
|
|
48
|
+
"description": "Draft-only display state (like dim_overlays): which member properties render as text-chip labels on the 2D/3D canvas, the anchor placement, and scope. Model-global. NOT baked into the lock / 3D scene / IFC / BOM.",
|
|
49
|
+
"properties": {
|
|
50
|
+
"props": { "type": "array", "items": { "type": "string" }, "description": "Checked property registry keys to show as labels (e.g. 'profile', 'tos_start')." },
|
|
51
|
+
"placement": { "enum": ["start", "mid", "end"], "description": "Anchor point of the label stack on each member." },
|
|
52
|
+
"selected_only": { "type": "boolean", "description": "When true, labels pin to `ids` (captured at check time); otherwise every member on the plan with a value is labeled." },
|
|
53
|
+
"ids": { "type": "array", "items": { "type": "string" }, "description": "Member ids the labels pin to, used only when selected_only is true." }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
},
|
|
46
57
|
"$defs": {
|
|
47
58
|
"detailPlacement": {
|
|
@@ -34,6 +34,9 @@ let insertPending = null; // {name,url} — the detail the editor queue
|
|
|
34
34
|
let labelsOnFlag = false; // member mark/id overlay labels toggle (a readability aid)
|
|
35
35
|
let memberLabelHost = null; // fixed-position container for the member labels (positioned each frame)
|
|
36
36
|
const memberLabelPool = []; // reused <div> labels, one per member, positioned in the loop
|
|
37
|
+
let propLabelHost = null; // fixed-position container for the right-click property labels (positioned each frame)
|
|
38
|
+
const propLabelPool = []; // reused multi-line <div> label chips, one per labelled member
|
|
39
|
+
let propLabelSpec = null; // { labels:[{id, lines:[...]}], placement } pushed from the editor (owns the text); this view owns projection/placement
|
|
37
40
|
const EP_PX = 4; // end-dot radius in screen px (screen-constant via pxToWorld)
|
|
38
41
|
let sceneBox = new THREE.Box3(); // current model bounds (Fit / ViewCube)
|
|
39
42
|
let displayMode = 'solid'; // solid | wire | xray
|
|
@@ -156,6 +159,9 @@ function init(canvas, theApi) {
|
|
|
156
159
|
memberLabelHost = document.createElement('div'); // member mark/id labels (Slice 4) — below the dim labels
|
|
157
160
|
memberLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
|
|
158
161
|
document.body.appendChild(memberLabelHost);
|
|
162
|
+
propLabelHost = document.createElement('div'); // right-click property labels — own host so they never clobber the mark/id pool
|
|
163
|
+
propLabelHost.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:56;display:none';
|
|
164
|
+
document.body.appendChild(propLabelHost);
|
|
159
165
|
// persistent hover/selection status chip, bottom-centre of the canvas (mirrors the viewer's readout)
|
|
160
166
|
hoverChip = document.createElement('div');
|
|
161
167
|
hoverChip.style.cssText = 'position:fixed;display:none;pointer-events:none;z-index:55;background:var(--panel,#0f172a);color:var(--mut,#94a3b8);border:1px solid var(--line,#334155);border-radius:6px;padding:5px 12px;font:12px system-ui;white-space:nowrap;max-width:60vw;overflow:hidden;text-overflow:ellipsis;box-shadow:0 2px 8px rgba(0,0,0,.45)';
|
|
@@ -200,6 +206,7 @@ function loop() {
|
|
|
200
206
|
positionDimLabels();
|
|
201
207
|
positionOverlayLabels();
|
|
202
208
|
positionMemberLabels();
|
|
209
|
+
positionPropLabels();
|
|
203
210
|
renderer.render(scene, camera);
|
|
204
211
|
if (overlayScene && overlayScene.children.length) { // 2nd pass with clipping OFF → the clip/work-area gizmos are never sectioned by any clip
|
|
205
212
|
const saved = renderer.clippingPlanes;
|
|
@@ -553,6 +560,7 @@ function buildFromScene(sc) {
|
|
|
553
560
|
buildStructGrid();
|
|
554
561
|
buildRefLines();
|
|
555
562
|
syncMemberLabels(); // refresh the member-label pool from the (possibly edited) member set
|
|
563
|
+
syncPropLabels(); // re-anchor the property labels too (member geometry may have moved)
|
|
556
564
|
if (isolatedIds) { isolatedIds = new Set([...isolatedIds].filter((id) => meshById.has(id))); if (!isolatedIds.size) { isolatedIds = null; if (api && api.onIsolateChange) api.onIsolateChange(false); } } // drop ids an edit removed; if none survive, exit isolation + refresh the host's Show-all button
|
|
557
565
|
if (connHidden.size) connHidden = new Set([...connHidden].filter((id) => meshById.has(id))); // drop per-part hides whose mesh an edit removed
|
|
558
566
|
applyGroupVisibility(); applyDisplayMode();
|
|
@@ -661,6 +669,54 @@ function positionMemberLabels() {
|
|
|
661
669
|
}
|
|
662
670
|
}
|
|
663
671
|
|
|
672
|
+
// ---- Right-click property labels: the editor owns the TEXT (it has the property registry + formatters +
|
|
673
|
+
// scope) and hands us { labels:[{id, lines}], placement }; this view owns the ANCHOR (member start/mid/end
|
|
674
|
+
// via memberGeometry) + projection. Mirrors the member-label pool exactly, but stacks multiple lines per chip
|
|
675
|
+
// and lives in its own host so the mark/id "Labels" toggle and these can be on at once without fighting. ----
|
|
676
|
+
function setPropLabels(spec) {
|
|
677
|
+
propLabelSpec = (spec && Array.isArray(spec.labels) && spec.labels.length) ? spec : null;
|
|
678
|
+
syncPropLabels();
|
|
679
|
+
}
|
|
680
|
+
function syncPropLabels() {
|
|
681
|
+
if (!propLabelHost || !api) return;
|
|
682
|
+
const labels = propLabelSpec ? propLabelSpec.labels : [];
|
|
683
|
+
const place = propLabelSpec ? propLabelSpec.placement : 'mid';
|
|
684
|
+
while (propLabelPool.length < labels.length) {
|
|
685
|
+
const el = document.createElement('div');
|
|
686
|
+
el.style.cssText = 'position:absolute;transform:translate(-50%,-50%);pointer-events:none;background:#0b1220;color:#e2e8f0;border:1px solid var(--brand,#3b82f6);border-radius:4px;padding:2px 6px;font:11px system-ui;line-height:1.35;text-align:center;white-space:pre;box-shadow:0 0 0 1px rgba(0,0,0,.6),0 2px 8px rgba(0,0,0,.5)'; // multi-line via white-space:pre; brand-blue accent matches the 2D chips
|
|
687
|
+
propLabelHost.appendChild(el); propLabelPool.push(el);
|
|
688
|
+
}
|
|
689
|
+
const byId = new Map(members().map((m) => [m.id, m]));
|
|
690
|
+
const ppf = api.ptPerFt(), dtos = api.defaultTosMm();
|
|
691
|
+
for (let i = 0; i < propLabelPool.length; i++) {
|
|
692
|
+
const el = propLabelPool[i], L = labels[i], m = L ? byId.get(L.id) : null;
|
|
693
|
+
if (!m || !Array.isArray(m.wp) || m.wp.length < 2 || !L.lines || !L.lines.length) { el.style.display = 'none'; el._mid = null; el._memberId = null; continue; }
|
|
694
|
+
const g = memberGeometry(m, ppf, dtos), a = g.line[0], b = g.line[1];
|
|
695
|
+
el._mid = place === 'start' ? a : place === 'end' ? b : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
|
|
696
|
+
el._memberId = m.id;
|
|
697
|
+
el.textContent = L.lines.join('\n');
|
|
698
|
+
el.style.display = 'block';
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
// Project each label's anchor → screen px (called from the render loop, like the dim + member labels).
|
|
702
|
+
function positionPropLabels() {
|
|
703
|
+
if (!propLabelHost) return;
|
|
704
|
+
if (!propLabelSpec || (canvasEl && canvasEl.style.display === 'none')) { propLabelHost.style.display = 'none'; return; }
|
|
705
|
+
propLabelHost.style.display = 'block';
|
|
706
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
707
|
+
for (const el of propLabelPool) {
|
|
708
|
+
if (el.style.display === 'none' || !el._mid) continue;
|
|
709
|
+
const mesh = meshById.get(el._memberId);
|
|
710
|
+
if (mesh && mesh.visible === false) { el.style.visibility = 'hidden'; continue; } // member legend-hidden or isolated away → hide its labels too
|
|
711
|
+
if (isPointClipped(el._mid)) { el.style.visibility = 'hidden'; continue; } // sectioned away by a clip
|
|
712
|
+
const v = new THREE.Vector3(el._mid[0], el._mid[1], el._mid[2]).project(camera);
|
|
713
|
+
if (v.z > 1 || v.x < -1 || v.x > 1 || v.y < -1 || v.y > 1) { el.style.visibility = 'hidden'; continue; } // behind / off-screen
|
|
714
|
+
el.style.left = (rect.left + (v.x * 0.5 + 0.5) * rect.width) + 'px';
|
|
715
|
+
el.style.top = (rect.top + (-v.y * 0.5 + 0.5) * rect.height) + 'px';
|
|
716
|
+
el.style.visibility = 'visible';
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
664
720
|
// ---- structural grid (Tekla-style): dashed lines per Z level live in the SCENE (so clips section
|
|
665
721
|
// them, like Tekla); the label bubbles + level tags are sprites in the UNCLIPPED overlay pass, so the
|
|
666
722
|
// wayfinding survives sectioning/work-area (the UX review's clip-regression guard). Data comes from
|
|
@@ -2479,6 +2535,7 @@ function hide() {
|
|
|
2479
2535
|
cmClear3d(); drClear3d(); // and any 3D Move/Copy or draw draft/ghosts
|
|
2480
2536
|
if (dimLabelHost) dimLabelHost.style.display = 'none'; // the loop that hides labels is about to stop — hide synchronously so none are stranded
|
|
2481
2537
|
if (memberLabelHost) memberLabelHost.style.display = 'none'; // member labels ride the same about-to-stop loop — hide them too, or they strand over the 2D UI
|
|
2538
|
+
if (propLabelHost) propLabelHost.style.display = 'none'; // property labels ride the same loop — hide synchronously so none strand over the 2D UI
|
|
2482
2539
|
clearCopyGhost(); // drop a stale Ctrl+drag copy ghost on the way out
|
|
2483
2540
|
if (canvasEl) canvasEl.style.display = 'none'; if (hoverChip) hoverChip.style.display = 'none'; if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
|
|
2484
2541
|
}
|
|
@@ -2497,6 +2554,8 @@ function dispose() {
|
|
|
2497
2554
|
dimLabelHost = null; dimLabelPool.length = 0;
|
|
2498
2555
|
if (memberLabelHost && memberLabelHost.parentNode) memberLabelHost.parentNode.removeChild(memberLabelHost);
|
|
2499
2556
|
memberLabelHost = null; memberLabelPool.length = 0; labelsOnFlag = false; insertMode = false; insertPending = null;
|
|
2557
|
+
if (propLabelHost && propLabelHost.parentNode) propLabelHost.parentNode.removeChild(propLabelHost);
|
|
2558
|
+
propLabelHost = null; propLabelPool.length = 0; propLabelSpec = null;
|
|
2500
2559
|
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
|
|
2501
2560
|
if (!w) continue;
|
|
2502
2561
|
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(); } });
|
|
@@ -2580,6 +2639,8 @@ window.Steel3DView = {
|
|
|
2580
2639
|
setInsertMode, insertMode: insertModeOn, // arm/query the detail-placement pick (Slice 4)
|
|
2581
2640
|
setLabelsOn, labelsOn: () => labelsOnFlag, // member mark/id label overlay toggle
|
|
2582
2641
|
syncMemberLabels, // editor calls after a mark/id edit to refresh labels
|
|
2642
|
+
setPropLabels, // right-click property labels: editor pushes { labels:[{id,lines}], placement }
|
|
2643
|
+
propLabelTexts: () => propLabelPool.filter((el) => el.style.display !== 'none' && el.style.visibility !== 'hidden').map((el) => el.textContent), // visible property-label chips — for tests
|
|
2583
2644
|
refreshGrid: buildStructGrid, // grid edited in the panel → re-render without a full rebuild
|
|
2584
2645
|
gridInfo: () => ({ lines: structGridGroup ? 1 : 0, labels: gridLabelGroup ? gridLabelGroup.children.length : 0 }), // test helper
|
|
2585
2646
|
toggleGroup, setGroupsHidden, setIdsHidden, connHiddenIds: () => [...connHidden], soloToggle, setSoloGroups, showAllGroups, groupState, getGroups,
|
|
@@ -229,6 +229,11 @@
|
|
|
229
229
|
text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
|
|
230
230
|
line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
|
|
231
231
|
circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
|
|
232
|
+
/* property-label chips (right-click → Properties popup): dark chip + brand-blue accent, distinct from the cyan dimension chips.
|
|
233
|
+
pointer-events:none on the whole group — labels are passive annotations; clicks/marquee/drag pass through to the members underneath. */
|
|
234
|
+
g.pllabels{pointer-events:none}
|
|
235
|
+
rect.plchip{fill:#0b1220;stroke:var(--brand);stroke-width:1;vector-effect:non-scaling-stroke}
|
|
236
|
+
text.pltx{fill:#e2e8f0;font-family:system-ui;text-anchor:middle;dominant-baseline:central}
|
|
232
237
|
/* structural grid layer (under the members): dash-dot slate lines + label bubbles at both ends */
|
|
233
238
|
line.gridln{stroke:#64748b;stroke-width:1.3;stroke-dasharray:12 4 2 4;vector-effect:non-scaling-stroke;pointer-events:none;opacity:.85}
|
|
234
239
|
line.gridhit{stroke:transparent;stroke-width:9;vector-effect:non-scaling-stroke;pointer-events:stroke}
|
|
@@ -289,6 +294,34 @@
|
|
|
289
294
|
.m3dmenu .wpprow button{width:auto;flex:none;padding:4px 8px;border:1px solid var(--line);border-radius:5px}
|
|
290
295
|
.m3dmenu .wpprow input{width:74px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 6px;font:12px system-ui}
|
|
291
296
|
.m3dmenu .wpprow input:focus{outline:none;border-color:var(--brand)}
|
|
297
|
+
/* right-click property-labels popup — floating panel, built from the same tokens as .m3dmenu / .mpanel (no new vocabulary) */
|
|
298
|
+
#propPop{position:fixed;left:0;top:0;z-index:46;width:280px;max-height:60vh;display:none;flex-direction:column;background:var(--panel);border:1px solid #475569;border-radius:10px;box-shadow:0 16px 48px rgba(0,0,0,.6);font-size:12px;color:var(--text)}
|
|
299
|
+
#propPop.open{display:flex}
|
|
300
|
+
#propPop .pph{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line)}
|
|
301
|
+
#propPop .pph b{font-size:12px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
302
|
+
#propPop .pph .pcount{color:var(--mut);font-size:10px;white-space:nowrap}
|
|
303
|
+
#propPop .pph button{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:4px;font-size:11px;padding:1px 6px;cursor:pointer;line-height:1.5}
|
|
304
|
+
#propPop .pph button:hover{border-color:var(--brand);color:var(--text)}
|
|
305
|
+
#propPop .pph button.pin.on{border-color:var(--brand);color:var(--brand)}
|
|
306
|
+
#propPop .ppsearch{padding:8px 10px 4px}
|
|
307
|
+
#propPop .ppsearch input{width:100%;height:26px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:0 8px;font:12px system-ui;box-sizing:border-box}
|
|
308
|
+
#propPop .ppsearch input:focus{outline:none;border-color:var(--brand)}
|
|
309
|
+
#propPop .ppmeta{color:var(--mut);font-size:10px;padding:2px 10px 0}
|
|
310
|
+
#propPop .ppscope{color:var(--mut);font-size:10px;padding:2px 10px 6px;font-style:italic}
|
|
311
|
+
#propPop .pplist{overflow:auto;flex:1;padding:2px 0;min-height:40px;border-top:1px solid var(--line)}
|
|
312
|
+
#propPop .pprow{display:flex;align-items:center;gap:8px;min-height:32px;padding:2px 10px;cursor:pointer}
|
|
313
|
+
#propPop .pprow:hover{background:#334155}
|
|
314
|
+
#propPop .pprow.dis{opacity:.5;cursor:default}
|
|
315
|
+
#propPop .pprow.dis:hover{background:transparent}
|
|
316
|
+
#propPop .pprow input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto} /* width:auto beats the editor's global input{width:100%} that would otherwise stretch the checkbox across the row */
|
|
317
|
+
#propPop .pprow .pn{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
318
|
+
#propPop .pprow .pv{color:var(--mut);font-variant-numeric:tabular-nums;max-width:44%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
|
|
319
|
+
#propPop .pprow .pv.varies{font-style:italic}
|
|
320
|
+
#propPop .ppempty{color:var(--mut);font-size:11px;padding:12px 10px;text-align:center}
|
|
321
|
+
#propPop .ppfoot{border-top:1px solid var(--line);padding:8px 10px;display:flex;flex-direction:column;gap:6px}
|
|
322
|
+
#propPop .ppfoot .seg2{margin-top:0}
|
|
323
|
+
#propPop .ppfoot label{display:flex;align-items:center;gap:7px;color:var(--text);cursor:pointer;font-size:11px}
|
|
324
|
+
#propPop .ppfoot label input{margin:0;accent-color:var(--brand);cursor:pointer;flex:none;width:auto}
|
|
292
325
|
/* thin cluster divider in the 3D toolbar */
|
|
293
326
|
#m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
|
|
294
327
|
/* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
|
|
@@ -607,7 +640,7 @@ async function boot() {
|
|
|
607
640
|
if(!Array.isArray(C.dims3d))C.dims3d=[]; // model-global draft-only 3D dimensions
|
|
608
641
|
if(!Array.isArray(C.detail_placements))C.detail_placements=[]; // model-global draft-only placed detail images (Slice 4)
|
|
609
642
|
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
|
|
610
|
-
main();
|
|
643
|
+
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())
|
|
611
644
|
// SSE: listen for external contract writebacks (e.g. the terminal AI PUT a revision).
|
|
612
645
|
// We open our own EventSource to the same /api/events endpoint as the main app.
|
|
613
646
|
// Uses the same parse format: es.onmessage → JSON.parse(e.data) → {type, ...}.
|
|
@@ -832,7 +865,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
832
865
|
else if(state==='err'){el.classList.add('err');el.textContent='Save failed';}
|
|
833
866
|
else el.textContent=msg||'Auto-save on';}
|
|
834
867
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
835
|
-
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence, dims3d:C.dims3d, dim_overlays:C.dim_overlays, joints:C.joints, detail_placements:C.detail_placements,
|
|
868
|
+
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,
|
|
836
869
|
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);}}
|
|
837
870
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
838
871
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
@@ -866,6 +899,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
866
899
|
if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
|
|
867
900
|
if(Array.isArray(d.detail_placements))C.detail_placements=d.detail_placements; // restore placed details
|
|
868
901
|
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)
|
|
902
|
+
if('prop_labels' in d)C.prop_labels=sanitizePropLabels(d.prop_labels); // restore canvas property-label display state (sanitised to known keys/placement)
|
|
869
903
|
if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
|
|
870
904
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
871
905
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
@@ -1291,9 +1325,11 @@ function render(){
|
|
|
1291
1325
|
for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
|
|
1292
1326
|
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>`;});}}
|
|
1293
1327
|
s+=renderDims();
|
|
1328
|
+
s+=renderPropLabels(); // right-click property-label chips (2D); 3D labels ride the div-overlay pool
|
|
1294
1329
|
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)
|
|
1295
1330
|
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();
|
|
1296
1331
|
if(view3d&&window.Steel3DView){window.Steel3DView.setSelection(selIds);updateIsolateBtn();if(selIds.size&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length)window.Steel3DView.setSelectedClips([]);} // keep the 3D highlight in sync; selecting a member clears any clip selection (exclusive)
|
|
1332
|
+
syncPropLabelsAfterRender(); // corner-note + push labels to 3D + refresh the popup rows against the (possibly changed) selection
|
|
1297
1333
|
}
|
|
1298
1334
|
function updDup(){const n=redundantDups().length;
|
|
1299
1335
|
document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
|
|
@@ -1311,6 +1347,15 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
|
|
|
1311
1347
|
svg.querySelectorAll('text.gridtx').forEach(t=>t.style.fontSize=(12/zoom)+'px');
|
|
1312
1348
|
svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
|
|
1313
1349
|
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
|
|
1350
|
+
// property-label chips: recompute size + stacked screen offset from the baked anchor (ax,ay) + per-row screen
|
|
1351
|
+
// offset (off) so they stay constant on screen while zooming (like dim chips) — AND re-apply the zoom
|
|
1352
|
+
// threshold live (data-mlen × zoom), so zooming reveals/hides labels as members cross ~24px on screen.
|
|
1353
|
+
{const plG=svg.querySelector('g.pllabels');if(plG){let hid=0;
|
|
1354
|
+
plG.querySelectorAll('rect.plchip').forEach(r=>{const w=(+r.dataset.tw*6.4+12)/zoom,h=15/zoom,cy=(+r.dataset.ay)+(+r.dataset.off)/zoom;
|
|
1355
|
+
const show=(+r.dataset.mlen)*zoom>=PLABEL_MIN_PX;r.style.display=show?'':'none';if(!show)hid++;
|
|
1356
|
+
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.ax)-w/2);r.setAttribute('y',cy-h/2);});
|
|
1357
|
+
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';});
|
|
1358
|
+
propLabelsHidden=hid;updatePropHint();}} // hid counts hidden chip ROWS (>0 ⇒ note shows) — the note only needs the boolean
|
|
1314
1359
|
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)
|
|
1315
1360
|
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]);});}
|
|
1316
1361
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
@@ -1740,6 +1785,160 @@ function snapMark(x,y,kind){kind=kind||lastSnapKind;snapClear();
|
|
|
1740
1785
|
else{const r=7/zoom;el=document.createElementNS(ns,'path');el.setAttribute('d',`M ${x-r} ${y-r} L ${x+r} ${y-r} L ${x-r} ${y+r} L ${x+r} ${y+r} Z`);} // on-line ("nearest") = HOURGLASS/clepsydra (AutoCAD Nearest glyph); circle is reserved for a future Center snap
|
|
1741
1786
|
el.id='snapMark';el.setAttribute('class','snapmk');svg.appendChild(el);document.body.classList.add('snapping');} // marker up → hide the native crosshair (see .snapping CSS)
|
|
1742
1787
|
function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();const g=document.getElementById('snapExtLine');if(g)g.remove();document.body.classList.remove('snapping');}
|
|
1788
|
+
// ===== Right-click property labels (Properties popup → text-chip labels on the 2D/3D canvas) =====
|
|
1789
|
+
// One registry drives BOTH the popup rows and the rendered labels: {key,label,get(m),fmt}. get() returns
|
|
1790
|
+
// the raw value, or `undefined` when the property doesn't apply to that member's role (shown as "—",
|
|
1791
|
+
// checkbox disabled). fmt() turns a raw value into the display string ('' = nothing to label). Values
|
|
1792
|
+
// go through the same imperial formatters the side pane uses; the contract stays canonical metric.
|
|
1793
|
+
function _effTos(o){return o?(o.tosDef!==false?defaultTOS:o.tos):undefined;} // beam end / column top → effective TOS (inches), default-aware
|
|
1794
|
+
function _lenFt(m){return len(m.wp[0],m.wp[1])/FT;}
|
|
1795
|
+
const PROP_DEFS=[
|
|
1796
|
+
{key:'id', label:'Mark', get:m=>m.id, fmt:v=>v==null?'':String(v)},
|
|
1797
|
+
{key:'profile', label:'Profile', get:m=>m.profile, fmt:v=>v==null?'':String(v)},
|
|
1798
|
+
{key:'type', label:'Type', get:m=>MTYPE_LABEL[memberTypeOf(m)], fmt:v=>v==null?'':String(v)},
|
|
1799
|
+
{key:'role', label:'Role', get:m=>m.role==='column'?'Column':'Beam', fmt:v=>v||''},
|
|
1800
|
+
{key:'length', label:'Length', get:m=>_lenFt(m), fmt:v=>v==null?'':v.toFixed(1)+' ft'},
|
|
1801
|
+
{key:'weight', label:'Weight', get:m=>{const w=_wt(m.profile);return w==null?undefined:_lenFt(m)*w;}, fmt:v=>v==null?'':Math.round(v).toLocaleString()+' lb'},
|
|
1802
|
+
{key:'position', label:'Position', get:m=>m.role==='column'?undefined:posDefault(m), fmt:v=>v?v[0].toUpperCase()+v.slice(1):''},
|
|
1803
|
+
{key:'verified', label:'Verified', get:m=>m.verified===true?'Yes':'No', fmt:v=>v||''},
|
|
1804
|
+
{key:'tos_start', label:'TOS (start)', get:m=>m.role==='column'?undefined:_effTos(m.ends&&m.ends[0]), fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1805
|
+
{key:'tos_end', label:'TOS (end)', get:m=>m.role==='column'?undefined:_effTos(m.ends&&m.ends[1]), fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1806
|
+
{key:'col_tos', label:'Top (TOS)', get:m=>m.role==='column'?_effTos(m.col):undefined, fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1807
|
+
{key:'col_bos', label:'Bottom (BOS)', get:m=>m.role==='column'?(m.col?m.col.bos:undefined):undefined, fmt:v=>v==null?'':fmtFtIn(v)},
|
|
1808
|
+
{key:'note_start',label:'Note (start)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[0])?(m.ends[0].note||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1809
|
+
{key:'note_end', label:'Note (end)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[1])?(m.ends[1].note||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1810
|
+
{key:'col_note', label:'Note', get:m=>m.role==='column'?(m.col?(m.col.note||''):undefined):undefined, fmt:v=>v==null?'':String(v)},
|
|
1811
|
+
{key:'detail_start',label:'Detail (start)',get:m=>m.role==='column'?undefined:((m.ends&&m.ends[0])?(m.ends[0].detail||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1812
|
+
{key:'detail_end',label:'Detail (end)', get:m=>m.role==='column'?undefined:((m.ends&&m.ends[1])?(m.ends[1].detail||''):undefined), fmt:v=>v==null?'':String(v)},
|
|
1813
|
+
{key:'col_detail',label:'Detail', get:m=>m.role==='column'?(m.col?(m.col.detail||''):undefined):undefined, fmt:v=>v==null?'':String(v)},
|
|
1814
|
+
];
|
|
1815
|
+
const PROP_KEYS=new Set(PROP_DEFS.map(d=>d.key));
|
|
1816
|
+
// normalise persisted/incoming prop_labels to known keys/placement so a corrupt draft can't desync the UI
|
|
1817
|
+
function sanitizePropLabels(x){const o=(x&&typeof x==='object'&&!Array.isArray(x))?x:{};
|
|
1818
|
+
return {props:Array.isArray(o.props)?o.props.filter(k=>PROP_KEYS.has(k)):[],
|
|
1819
|
+
placement:['start','mid','end'].includes(o.placement)?o.placement:'mid',
|
|
1820
|
+
selected_only:o.selected_only===true,
|
|
1821
|
+
ids:Array.isArray(o.ids)?o.ids.filter(v=>typeof v==='string'):[]};}
|
|
1822
|
+
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)
|
|
1823
|
+
// the "Label: value" lines a member contributes for the currently-checked props (registry order; skips N/A + empty)
|
|
1824
|
+
function propLabelLinesFor(m){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];ensureMeta(m);
|
|
1825
|
+
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;}
|
|
1826
|
+
// members that actually get a label, honouring the plan-wide-vs-selected-only scope
|
|
1827
|
+
function propLabelMembers(){const pl=C.prop_labels;if(!pl||!pl.props.length)return [];
|
|
1828
|
+
let base=P.members;if(pl.selected_only){const ids=new Set(pl.ids||[]);base=P.members.filter(m=>ids.has(m.id));}
|
|
1829
|
+
return base.filter(m=>propLabelLinesFor(m).length>0);}
|
|
1830
|
+
// one popup row's aggregate across the current selection: a shared value, "Varies", or N/A (disabled)
|
|
1831
|
+
function propAggRow(def,arr){let anyApplicable=false,anyNonEmpty=false;const vals=[];
|
|
1832
|
+
for(const m of arr){ensureMeta(m);const raw=def.get(m);if(raw===undefined)continue;anyApplicable=true;const t=def.fmt(raw);vals.push(t);if(t!=='')anyNonEmpty=true;}
|
|
1833
|
+
if(!anyApplicable||!anyNonEmpty)return {state:'na'};
|
|
1834
|
+
const nonEmpty=[...new Set(vals.filter(t=>t!==''))];
|
|
1835
|
+
if(nonEmpty.length===1&&vals.every(t=>t===nonEmpty[0]))return {state:'val',text:nonEmpty[0]};
|
|
1836
|
+
return {state:'varies'};}
|
|
1837
|
+
|
|
1838
|
+
// ---- 2D SVG chips (rendered inside render(); zoom-constant via data-ax/ay/off, rescaled in updateBadges) ----
|
|
1839
|
+
let propLabelsHidden=0;
|
|
1840
|
+
// A member's labels hide when its on-screen length < this (px) — the density guard (§5.5). Kept as one
|
|
1841
|
+
// constant so renderPropLabels (initial paint) and updateBadges (live on zoom) agree.
|
|
1842
|
+
const PLABEL_MIN_PX=24;
|
|
1843
|
+
function renderPropLabels(){const pl=C.prop_labels;propLabelsHidden=0;if(!pl||!pl.props.length)return '';
|
|
1844
|
+
const ms=propLabelMembers();if(!ms.length)return '';
|
|
1845
|
+
let hidden=0,s='';
|
|
1846
|
+
// Emit chips for EVERY labelled member (carrying its display length in data-mlen); the zoom threshold
|
|
1847
|
+
// only sets each chip's initial visibility. updateBadges re-applies it live as the user zooms — so
|
|
1848
|
+
// zooming in reveals a hidden label and zooming out hides it (they're in the DOM either way).
|
|
1849
|
+
for(const m of ms){const lines=propLabelLinesFor(m);if(!lines.length)continue;
|
|
1850
|
+
const a=m.wp[0],b=m.wp[1],mlen=len(a,b);const show=mlen*zoom>=PLABEL_MIN_PX;if(!show)hidden++;
|
|
1851
|
+
const anc=pl.placement==='start'?a:pl.placement==='end'?b:[(a[0]+b[0])/2,(a[1]+b[1])/2];
|
|
1852
|
+
const n=lines.length,hide=show?'':';display:none';
|
|
1853
|
+
lines.forEach((txt,i)=>{const off=(i-(n-1)/2)*15,tw=txt.length,w=(tw*6.4+12)/zoom,h=15/zoom,cy=anc[1]+off/zoom;
|
|
1854
|
+
s+=`<rect class=plchip data-ax="${anc[0]}" data-ay="${anc[1]}" data-off="${off}" data-tw="${tw}" data-mlen="${mlen}" x="${anc[0]-w/2}" y="${cy-h/2}" width="${w}" height="${h}" rx="${3/zoom}" style="${hide}"/>`
|
|
1855
|
+
+`<text class=pltx data-ax="${anc[0]}" data-ay="${anc[1]}" data-off="${off}" data-mlen="${mlen}" x="${anc[0]}" y="${cy}" style="font-size:${11/zoom}px${hide}">${esc(txt)}</text>`;});}
|
|
1856
|
+
propLabelsHidden=hidden;
|
|
1857
|
+
return `<g class="pllabels">${s}</g>`;}
|
|
1858
|
+
// fixed-screen corner note when the zoom threshold is hiding labels (an HTML pill, outside the zoomed SVG)
|
|
1859
|
+
function updatePropHint(){let el=document.getElementById('plHint');
|
|
1860
|
+
const show=!view3d&&propLabelsHidden>0;
|
|
1861
|
+
if(!show){if(el)el.style.display='none';return;}
|
|
1862
|
+
if(!el){el=document.createElement('div');el.id='plHint';el.style.cssText='position:fixed;z-index:20;pointer-events:none;background:var(--panel);color:var(--mut);border:1px solid var(--line);border-radius:6px;padding:3px 9px;font:11px system-ui;box-shadow:0 2px 8px rgba(0,0,0,.4)';document.body.appendChild(el);}
|
|
1863
|
+
const st=document.getElementById('stage').getBoundingClientRect();
|
|
1864
|
+
el.textContent='Labels hidden — zoom in';el.style.display='block';
|
|
1865
|
+
el.style.left=(st.left+st.width/2-70)+'px';el.style.top=(st.bottom-30)+'px';}
|
|
1866
|
+
|
|
1867
|
+
// ---- 3D bridge: hand the label spec to the 3D view (it owns projection/placement over member geometry) ----
|
|
1868
|
+
function refreshPropLabels3d(){const V=window.Steel3DView;if(!V||!V.setPropLabels)return;
|
|
1869
|
+
const pl=C.prop_labels;
|
|
1870
|
+
if(!pl||!pl.props.length){V.setPropLabels(null);return;}
|
|
1871
|
+
V.setPropLabels({labels:propLabelMembers().map(m=>({id:m.id,lines:propLabelLinesFor(m)})),placement:pl.placement});}
|
|
1872
|
+
|
|
1873
|
+
// ---- The floating Properties popup ----
|
|
1874
|
+
let propPopPinned=false;
|
|
1875
|
+
function propPopOpen(){const el=document.getElementById('propPop');return !!(el&&el.classList.contains('open'));}
|
|
1876
|
+
function propPopEl(){let el=document.getElementById('propPop');if(el)return el;
|
|
1877
|
+
el=document.createElement('div');el.id='propPop';el.setAttribute('role','dialog');el.setAttribute('aria-label','Member properties');
|
|
1878
|
+
el.innerHTML=`<div class=pph><b id=ppTitle>Properties</b><span class=pcount id=ppLabeled></span>`
|
|
1879
|
+
+`<button class=pin id=ppPin title="Pin — keep open across selections">📌</button>`
|
|
1880
|
+
+`<button id=ppClear title="Uncheck every label">Clear all</button>`
|
|
1881
|
+
+`<button id=ppClose title="Close (Esc)">✕</button></div>`
|
|
1882
|
+
+`<div class=ppsearch><input id=ppSearch placeholder="Search properties…" autocomplete=off aria-label="Search properties"></div>`
|
|
1883
|
+
+`<div class=ppmeta id=ppMeta></div><div class=ppscope id=ppScope></div>`
|
|
1884
|
+
+`<div class=pplist id=ppList></div>`
|
|
1885
|
+
+`<div class=ppfoot><div class=seg2 id=ppPlace><button data-pl=start>Start</button><button data-pl=mid>Middle</button><button data-pl=end>End</button></div>`
|
|
1886
|
+
+`<label><input type=checkbox id=ppSel>Selected only</label></div>`;
|
|
1887
|
+
document.body.appendChild(el);
|
|
1888
|
+
const list=el.querySelector('#ppList');
|
|
1889
|
+
list.addEventListener('click',e=>{const row=e.target.closest('.pprow');if(!row||row.classList.contains('dis'))return;if(e.target.tagName==='INPUT')return;togglePropKey(row.dataset.k);});
|
|
1890
|
+
list.addEventListener('change',e=>{const cb=e.target;if(cb.tagName!=='INPUT')return;const row=cb.closest('.pprow');if(row)togglePropKey(row.dataset.k,cb.checked);});
|
|
1891
|
+
el.querySelector('#ppSearch').addEventListener('input',renderPropPop);
|
|
1892
|
+
el.querySelector('#ppClear').onclick=()=>{C.prop_labels.props=[];refreshPropLabels();};
|
|
1893
|
+
el.querySelector('#ppClose').onclick=()=>closePropPop(true);
|
|
1894
|
+
el.querySelector('#ppPin').onclick=()=>{propPopPinned=!propPopPinned;if(propPopPinned)dockPropPop();renderPropPop();};
|
|
1895
|
+
el.querySelector('#ppPlace').addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;C.prop_labels.placement=b.dataset.pl;refreshPropLabels();});
|
|
1896
|
+
el.querySelector('#ppSel').addEventListener('change',e=>{const pl=C.prop_labels;pl.selected_only=e.target.checked;if(pl.selected_only)pl.ids=selArr().map(m=>m.id);refreshPropLabels();});
|
|
1897
|
+
// focus trap + Esc (behaves as a lightweight non-modal dialog)
|
|
1898
|
+
el.addEventListener('keydown',e=>{if(e.key==='Escape'){e.stopPropagation();closePropPop(true);return;}
|
|
1899
|
+
if(e.key!=='Tab')return;const f=[...el.querySelectorAll('button,input,[tabindex]')].filter(n=>!n.disabled&&n.offsetParent!==null);
|
|
1900
|
+
if(!f.length)return;const first=f[0],last=f[f.length-1];
|
|
1901
|
+
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
|
|
1902
|
+
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}});
|
|
1903
|
+
return el;}
|
|
1904
|
+
function togglePropKey(k,on){const pl=C.prop_labels,has=pl.props.includes(k);if(on===undefined)on=!has;
|
|
1905
|
+
if(on&&!has)pl.props.push(k);else if(!on&&has)pl.props=pl.props.filter(x=>x!==k);
|
|
1906
|
+
if(on&&pl.selected_only&&selArr().length)pl.ids=selArr().map(m=>m.id); // Selected-only: re-target the pinned set to the current selection when adding a label (so checking a prop after re-selecting labels what's actually selected, not a stale set)
|
|
1907
|
+
refreshPropLabels();}
|
|
1908
|
+
// rebuild the popup contents against the current selection (chrome + rows), preserving row focus
|
|
1909
|
+
function renderPropPop(){const el=document.getElementById('propPop');if(!el||!el.classList.contains('open'))return;
|
|
1910
|
+
const arr=selArr(),pl=C.prop_labels,q=(el.querySelector('#ppSearch').value||'').trim().toLowerCase();
|
|
1911
|
+
el.querySelector('#ppTitle').textContent='Properties ('+arr.length+' selected)';
|
|
1912
|
+
el.querySelector('#ppLabeled').textContent=pl.props.length?pl.props.length+' labeled':'';
|
|
1913
|
+
el.querySelector('#ppScope').textContent=pl.selected_only?'Labels apply to the selected members':'Labels apply to all members on this plan';
|
|
1914
|
+
el.querySelectorAll('#ppPlace button').forEach(b=>b.classList.toggle('on',b.dataset.pl===pl.placement));
|
|
1915
|
+
el.querySelector('#ppSel').checked=pl.selected_only;
|
|
1916
|
+
const ae=document.activeElement,fk=(ae&&ae.closest&&ae.closest('.pprow'))?ae.closest('.pprow').dataset.k:null;
|
|
1917
|
+
let shown=0;const rows=PROP_DEFS.map(def=>{
|
|
1918
|
+
if(q&&!def.label.toLowerCase().includes(q))return '';
|
|
1919
|
+
shown++;const r=propAggRow(def,arr),checked=pl.props.includes(def.key),dis=r.state==='na';
|
|
1920
|
+
const val=r.state==='val'?esc(r.text):r.state==='varies'?'Varies':'—',vc=r.state==='varies'?' varies':'';
|
|
1921
|
+
return `<div class="pprow${dis?' dis':''}" data-k="${def.key}"${dis?' title="Nothing to label — no value in the selection"':''}>`
|
|
1922
|
+
+`<input type=checkbox ${checked?'checked':''}${dis?' disabled':''} aria-label="${esc(def.label)}">`
|
|
1923
|
+
+`<span class=pn>${esc(def.label)}</span><span class="pv${vc}">${val}</span></div>`;}).join('');
|
|
1924
|
+
el.querySelector('#ppList').innerHTML=rows||'<div class=ppempty>No properties match your search.</div>';
|
|
1925
|
+
el.querySelector('#ppMeta').textContent=shown+' of '+PROP_DEFS.length+' shown';
|
|
1926
|
+
if(fk){const cb=el.querySelector('.pprow[data-k="'+fk+'"] input');if(cb)cb.focus();}}
|
|
1927
|
+
function dockPropPop(){const el=propPopEl(),st=document.getElementById(view3d?'stage3d':'stage').getBoundingClientRect();
|
|
1928
|
+
el.style.left=Math.max(4,st.right-el.offsetWidth-12)+'px';el.style.top=(st.top+12)+'px';}
|
|
1929
|
+
function openPropLabels(x,y){if(!selArr().length)return;const el=propPopEl();const sr=el.querySelector('#ppSearch');
|
|
1930
|
+
sr.value=''; // clear any prior filter BEFORE rendering rows, so reopening never shows a filtered list under a blank search box
|
|
1931
|
+
el.classList.add('open');renderPropPop();
|
|
1932
|
+
if(propPopPinned){dockPropPop();}
|
|
1933
|
+
else{const r=el.getBoundingClientRect();el.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';el.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';}
|
|
1934
|
+
setTimeout(()=>sr.focus(),0);}
|
|
1935
|
+
function closePropPop(force){const el=document.getElementById('propPop');if(!el)return;if(propPopPinned&&!force)return;el.classList.remove('open');
|
|
1936
|
+
const c=document.getElementById(view3d?'stage3d':'stage');if(c)c.focus&&c.focus();}
|
|
1937
|
+
document.addEventListener('pointerdown',e=>{if(propPopOpen()&&!propPopPinned&&!e.target.closest('#propPop'))closePropPop();},true);
|
|
1938
|
+
// after every render the checked labels + the popup rows stay in sync with the (possibly changed) selection/geometry
|
|
1939
|
+
function syncPropLabelsAfterRender(){updatePropHint();refreshPropLabels3d();if(propPopOpen())renderPropPop();}
|
|
1940
|
+
function refreshPropLabels(){scheduleSave();render();} // render() → renderPropLabels() (2D) + syncPropLabelsAfterRender() (hint/3D/popup)
|
|
1941
|
+
|
|
1743
1942
|
// --- Tekla-style snap override (right-click): restrict snapping to ONE type for the current operation.
|
|
1744
1943
|
// 2D: right-click anywhere on the canvas (set it before grabbing an endpoint — a drag captures the pointer).
|
|
1745
1944
|
// 3D: right-CLICK while the Dimension tool is armed (right-DRAG stays orbit/pan). The override clears when
|
|
@@ -1768,11 +1967,17 @@ function openSnapMenu(x,y,is3d){const m=snapMenuEl();m._is3d=!!is3d;
|
|
|
1768
1967
|
m.style.left=Math.max(4,Math.min(x,innerWidth-r.width-8))+'px';m.style.top=Math.max(4,Math.min(y,innerHeight-r.height-8))+'px';
|
|
1769
1968
|
m.focus();}
|
|
1770
1969
|
document.addEventListener('pointerdown',e=>{if(snapMenuOpen()&&!e.target.closest('#snapMenu'))closeSnapMenu();},true);
|
|
1771
|
-
|
|
1970
|
+
// Right-click routes by intent (spec §2a — the two conditions are mutually exclusive, so exactly one opens):
|
|
1971
|
+
// a selection in plain select mode → the Properties popup; otherwise (no selection, or a placing/measuring
|
|
1972
|
+
// mode armed) → the snap-override menu. This gates the previously-unconditional 2D snap menu.
|
|
1973
|
+
document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDefault();
|
|
1974
|
+
if(mode==='sel'&&!dimMode&&!csaxisMode&&!dimSplitMode&&!geoMode&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;}
|
|
1975
|
+
openSnapMenu(e.clientX,e.clientY,false);});
|
|
1772
1976
|
document.getElementById('stage3d').addEventListener('contextmenu',e=>{e.preventDefault();const V=window.Steel3DView;
|
|
1773
|
-
if(
|
|
1774
|
-
if(V.
|
|
1775
|
-
|
|
1977
|
+
if(V&&V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click — no menu
|
|
1978
|
+
if(V&&V.dimToolOn&&V.dimToolOn()){openSnapMenu(e.clientX,e.clientY,true);return;} // dim tool armed → snap-override menu (unchanged)
|
|
1979
|
+
if(mode==='sel'&&!cmTool&&!picking&&selArr().length){openPropLabels(e.clientX,e.clientY);return;} // a selection → Properties popup
|
|
1980
|
+
});
|
|
1776
1981
|
document.getElementById('snapStat').onclick=()=>{snapOnly=null;const V=window.Steel3DView;if(V&&V.setSnapOnly)V.setSnapOnly(null);updSnapStat();};
|
|
1777
1982
|
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
1778
1983
|
// buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
|
|
@@ -2861,6 +3066,8 @@ async function setView(on){
|
|
|
2861
3066
|
}
|
|
2862
3067
|
}else{if(cmTool&&window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d(); // 3D→2D: drop the mm-space pick the same way (tool stays armed)
|
|
2863
3068
|
applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();}
|
|
3069
|
+
if(propPopOpen()){if(propPopPinned)dockPropPop();else closePropPop(true);} // the popup is anchored to one canvas — re-dock if pinned, else dismiss on the view switch
|
|
3070
|
+
refreshPropLabels3d();updatePropHint(); // push the label set to whichever view is now live (3D shows them; 2D hides the 3D host) + refresh the corner note
|
|
2864
3071
|
}
|
|
2865
3072
|
document.getElementById('vt2d').onclick=()=>{if(view3d)setView(false);};
|
|
2866
3073
|
document.getElementById('vt3d').onclick=()=>{if(!view3d)setView(true);};
|