@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.
@@ -53022,7 +53022,7 @@ function appVersion() {
53022
53022
  return resolveVersion({
53023
53023
  isSea: isSea2(),
53024
53024
  sqVersionXml: readSqVersionXml(),
53025
- define: true ? "0.67.0" : void 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.67.0" : void 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
- document.getElementById('stage').addEventListener('contextmenu',e=>{e.preventDefault();openSnapMenu(e.clientX,e.clientY,false);});
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(!V||!V.dimToolOn||!V.dimToolOn())return; // 3D menu only for the armed dim tool
1774
- if(V.rightDragged&&V.rightDragged())return; // that right button was an orbit/pan, not a click
1775
- openSnapMenu(e.clientX,e.clientY,true);});
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);};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.67.0",
3
+ "version": "0.68.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {