@floless/app 0.62.0 → 0.64.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.
@@ -158,7 +158,7 @@
158
158
  #moreWrap{position:relative;display:inline-flex}
159
159
  #moreMenu{position:absolute;right:0;top:calc(100% + 6px);min-width:210px;background:var(--panel);border:1px solid #475569;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.5);padding:4px 0;z-index:30;display:none}
160
160
  #moreMenu.open{display:block}
161
- #moreMenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
161
+ #moreMenu .mlabel,.m3dmenu .mlabel{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);padding:8px 12px 2px}
162
162
  #moreMenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
163
163
  #moreMenu button{display:block;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
164
164
  #moreMenu button:hover{background:#334155}
@@ -191,6 +191,22 @@
191
191
  text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
192
192
  line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
193
193
  circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
194
+ /* structural grid layer (under the members): dash-dot slate lines + label bubbles at both ends */
195
+ line.gridln{stroke:#64748b;stroke-width:1.3;stroke-dasharray:12 4 2 4;vector-effect:non-scaling-stroke;pointer-events:none;opacity:.85}
196
+ line.gridhit{stroke:transparent;stroke-width:9;vector-effect:non-scaling-stroke;pointer-events:stroke}
197
+ line.gridhit.gv{cursor:ew-resize} line.gridhit.gh{cursor:ns-resize}
198
+ line.gridln.gdrag{stroke:var(--brand);opacity:1}
199
+ circle.gridbub{fill:#0f172a;stroke:#64748b;stroke-width:1.5;vector-effect:non-scaling-stroke;cursor:grab;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}
200
+ text.gridtx{fill:#e2e8f0;font-weight:700;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
201
+ g.gridbubg:hover circle.gridbub{stroke:var(--brand);fill:#1e293b}
202
+ body.gridpick #svg,body.gridpick line.member,body.gridpick line.seg,body.gridpick image{cursor:crosshair}
203
+ .gin{width:100%;background:#0f172a;border:1px solid #475569;border-radius:6px;padding:6px 8px;color:var(--text);font:12px ui-monospace,Consolas,monospace}
204
+ .gin:focus{outline:none;border-color:var(--brand)}
205
+ .gfb{color:var(--mut);font-size:11px;margin-top:3px;font-variant-numeric:tabular-nums}
206
+ .gerr{color:#fca5a5;font-size:11px;margin-top:3px}
207
+ .gbtns{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} .gbtns button{margin:0}
208
+ .gck{display:flex;align-items:center;gap:8px;color:var(--text);cursor:pointer;font-size:12px;padding:2px 0}
209
+ .gck input{accent-color:var(--brand);cursor:pointer;margin:0;width:14px;height:14px;flex:none} /* beat the panel's generic full-width input rule */
194
210
  body.dimsplit line.member,body.dimsplit line.seg,body.dimsplit line.dim,body.dimsplit line.dimwit,body.dimsplit image{cursor:crosshair}
195
211
  .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
196
212
  body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
@@ -231,6 +247,10 @@
231
247
  .m3dmenu label{display:flex;align-items:center;gap:7px;padding:7px 12px;color:var(--text);font-size:12px;cursor:pointer;white-space:nowrap}
232
248
  .m3dmenu label:hover{background:#334155}
233
249
  .m3dmenu label input{margin:0;accent-color:var(--brand);cursor:pointer}
250
+ .m3dmenu .wpprow{display:flex;gap:4px;align-items:center;padding:4px 12px}
251
+ .m3dmenu .wpprow button{width:auto;flex:none;padding:4px 8px;border:1px solid var(--line);border-radius:5px}
252
+ .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}
253
+ .m3dmenu .wpprow input:focus{outline:none;border-color:var(--brand)}
234
254
  /* thin cluster divider in the 3D toolbar */
235
255
  #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
236
256
  /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
@@ -285,6 +305,9 @@
285
305
  </style>
286
306
  <script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/","three-bvh-csg":"./vendor/three-bvh-csg.module.js"}}</script>
287
307
  <script type="module" src="./steel-3d-view.js"></script>
308
+ <!-- grid-core is an ES module; the editor's classic script reaches it via window.GridCore (set before
309
+ the async contract fetch resolves in practice; gridcore-ready re-renders as a safety net). -->
310
+ <script type="module">import * as GC from './grid-core.js';window.GridCore=GC;document.dispatchEvent(new Event('gridcore-ready'));</script>
288
311
  </head><body>
289
312
  <header>
290
313
  <b>Steel Model</b>
@@ -324,6 +347,10 @@
324
347
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
325
348
  <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
326
349
  <hr>
350
+ <div class=mlabel>Grid</div>
351
+ <button id=gridEditB title="Grid lines — a plan reference with Tekla-style spacings (n*d repeats a bay). Shows in 2D and 3D; drawing and drags snap to its lines and intersections.">Grid lines…</button>
352
+ <button id=gridToggleB title="Show or hide the grid lines in 2D and 3D">Hide grid</button>
353
+ <hr>
327
354
  <div class=mlabel>Coordinate system</div>
328
355
  <button id=csSetB title="Define a local X axis by clicking two points (origin, then X-direction). Y follows at 90°. Orthogonal drawing and X/Y dimensions then snap to this frame — for skewed framing.">↺ Set local axes…</button>
329
356
  <button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
@@ -377,6 +404,22 @@
377
404
  <button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
378
405
  </div>
379
406
  </div>
407
+ <div class=m3dwrap>
408
+ <button id=m3dWp data-tip="Working plane — every 3D pick (Move/Copy, next: drawing) lands on it. Set it from a face, 3 points, or a principal plane.">◇ Plane ▾</button>
409
+ <div id=m3dWpMenu class=m3dmenu role=menu>
410
+ <button data-wp=face data-tip="One click on any member face — the plane lands on it">Pick a face…</button>
411
+ <button data-wp=3pt data-tip="Three snapped points: origin, X direction, a point on the Y side">3 points…</button>
412
+ <hr>
413
+ <div class=mlabel>Principal + offset</div>
414
+ <div class=wpprow>
415
+ <button data-wpp=xy data-tip="Horizontal plane at the offset elevation">XY</button><button data-wpp=xz data-tip="Vertical plane facing Y, offset along Y">XZ</button><button data-wpp=yz data-tip="Vertical plane facing X, offset along X">YZ</button>
416
+ <input id=m3dWpOff inputmode=decimal placeholder="16'-6" autocomplete=off data-tip="Offset along the plane normal (ft-in)">
417
+ </div>
418
+ <hr>
419
+ <label data-tip="Show or hide the plane's grid patch"><input type=checkbox id=m3dWpShow checked> Show plane</label>
420
+ <button data-wp=reset data-tip="Back to the active plan's level">Reset to level</button>
421
+ </div>
422
+ </div>
380
423
  <div class=m3dwrap>
381
424
  <button id=m3dWork data-tip="Working area — bound the view to a box (Tekla-style)">Work area ▾</button>
382
425
  <div id=m3dWorkMenu class=m3dmenu role=menu>
@@ -580,6 +623,7 @@ let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
580
623
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
581
624
  let dimMode=false, dimDraft=null, dimAxis='free', selDimIds=new Set(), dimsVisible=true; // Dimension tool: armed flag, in-progress {a,b,axis}, sticky axis, selected dim ids (multi-select), show/hide
582
625
  let calloutsVisible=true; // Workstream B: show/hide the clickable callout bubbles (details + section/elevation/detail-ref) on the plan in select mode
626
+ let gridMode=false, gridPick=false; // Grid lines: panel-takeover editing mode + armed pick-origin click
583
627
  let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
584
628
  let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
585
629
  let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
@@ -667,7 +711,7 @@ const autofillTOS=syncDefaults;
667
711
  // live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
668
712
  // unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
669
713
  // slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
670
- function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null});} // clips/work area are view-state but ride the contract undo stack
714
+ function snapshot(){const pm=new Set((P.members||[]).map(m=>m.id));return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null,grid:P.grid||null,joints:(C.joints||[]).filter(j=>j&&pm.has(j.main)),clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null});} // clips/work area are view-state but ride the contract undo stack; grid is per-plan (like frame/dims) so the per-plan stack restores it safely
671
715
  // Cross-plan undo (spec §4.5): a commit that also touches ANOTHER plan captures that plan's member
672
716
  // slice too; undo/redo restore it by sheet. The entry lives on the CURRENT plan's stack — undoing
673
717
  // from the plan where the command ran is the supported path (same per-plan-stack rule as everything).
@@ -697,17 +741,18 @@ function refreshOverlayDims3d(){if(window.Steel3DView&&window.Steel3DView.refres
697
741
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
698
742
  function apply(json){const d=JSON.parse(json);
699
743
  if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
700
- else{const pmOld=new Set((P.members||[]).map(m=>m.id));P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;
744
+ else{const pmOld=new Set((P.members||[]).map(m=>m.id));P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;if('grid' in d)P.grid=d.grid;
701
745
  if('joints' in d){const own=new Set([...pmOld,...P.members.map(m=>m.id)]);C.joints=(C.joints||[]).filter(j=>!(j&&own.has(j.main))).concat(d.joints||[]);}
702
746
  if(Array.isArray(d.others))for(const o of d.others){const tp=C.plans.find(p=>p.sheet===o.sheet);
703
747
  if(tp&&tp!==P)tp.members=(o.members||[]).map(ensureMeta);
704
- else if(!tp){console.error('undo: sheet '+o.sheet+' not found — cross-plan slice skipped');toast('Undo couldn’t restore sheet '+o.sheet);}}} // restore THIS plan's joint slice: drop joints owned by this plan (old ∪ restored member ids), re-add the snapshot's — OTHER sheets' joints stay untouched. Legacy member-only snapshots lack 'joints' → left alone. dims3d rides its own path. 'others' = the cross-plan slice a to-level commit captured.
748
+ else if(!tp){console.error('undo: sheet '+o.sheet+' not found — cross-plan slice skipped');toast('Undo couldn’t restore sheet '+o.sheet);}}} // restore THIS plan's joint slice: drop joints owned by this plan (old ∪ restored member ids), re-add the snapshot's — OTHER sheets' joints stay untouched. grid is per-plan (restored above); 'others' = the cross-plan slice a to-level commit captured. dims3d rides its own path.
705
749
  updCS();
706
750
  selIds=new Set([...selIds].filter(id=>byId(id)));
707
751
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
708
752
  if(sel3dDimIds.size){const ok=new Set((C.dims3d||[]).map(x=>x.id));sel3dDimIds=new Set([...sel3dDimIds].filter(id=>ok.has(id)));} // drop stale 3D-dim selections
709
753
  dimDraft=null;dimChainPrev=null;dimPrevClear(); // undo/redo changed geometry under the tool → abandon any in-progress dim placement / chain (else the chain head points at a removed segment)
710
754
  cmDraft=null;cmPrevClear();cmHudClose(); // same for an in-progress Move/Copy pick — the base may reference removed geometry
755
+ if(window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d(); // and the 3D-side draft/ghosts
711
756
  if(window.Steel3DView&&window.Steel3DView.setClipState&&'clips' in d)window.Steel3DView.setClipState(d.clips); // restore clips + work area alongside the contract
712
757
  scheduleSave();render();sync3D();}
713
758
  function doUndo(){if(!undo.length)return;redo.push(snapshotLike(undo[undo.length-1]));apply(undo.pop());}
@@ -724,7 +769,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
724
769
  else el.textContent=msg||'Auto-save on';}
725
770
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
726
771
  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,
727
- 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}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
772
+ 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);}}
728
773
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
729
774
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
730
775
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
@@ -753,7 +798,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
753
798
  if(d.custom_details)C.custom_details=d.custom_details;
754
799
  if(d.profile_colors)C.profile_colors=d.profile_colors;
755
800
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
756
- C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;p.autofilled=true;}});
801
+ C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;if('grid' in s)p.grid=s.grid||null;p.autofilled=true;}});
757
802
  if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
758
803
  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)
759
804
  if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
@@ -847,12 +892,201 @@ function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
847
892
  const HR=epR();for(const d of P.dims)if(selDimIds.has(d.id)) // each selected dim gets draggable anchor handles — drag an end to re-measure (snaps like drawing; Alt off)
848
893
  s+=`<circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="0" cx="${d.a[0]}" cy="${d.a[1]}" r="${HR}"/><circle class=dimhandle data-dim="${esc(d.id)}" data-dimend="1" cx="${d.b[0]}" cy="${d.b[1]}" r="${HR}"/>`;
849
894
  return s;}
895
+ // --- structural grid (Tekla-style; web/grid-core.js owns parsing/geometry — loaded via the module
896
+ // shim as window.GridCore). The grid is PER-PLAN contract data (P.grid, like P.frame/P.dims — its
897
+ // origin/spacings live in the sheet's display space, which is per-plan): X/Y are relative spacing
898
+ // strings ("0 3*25'"), Z absolute levels. It renders under the members in 2D + as dashed lines/label
899
+ // sprites in 3D, and feeds both snap engines. Riding the per-plan undo stack is therefore SAFE
900
+ // (unlike the model-global dims3d). Visibility is P.grid.on (persisted, not undoable — a view toggle).
901
+ const GC=()=>window.GridCore||null;
902
+ function gridOn(){return !!(typeof P!=='undefined'&&P&&P.grid&&P.grid.on!==false);}
903
+ function gridGeo(){const gc=GC();if(!gc||typeof P==='undefined'||!P||!P.grid)return null;try{return gc.gridGeometry(P.grid,(P.pt_per_ft>0)?P.pt_per_ft:1);}catch(_){return null;}}
904
+ function gridSvg(){
905
+ if(!gridOn())return '';
906
+ const geo=gridGeo();if(!geo||(!geo.v.length&&!geo.h.length))return '';
907
+ const R=11/zoom,F=12/zoom;
908
+ let s='<g id=gridG>';
909
+ geo.v.forEach((l,i)=>{s+=`<line class=gridln data-gv="${i}" x1="${l.x}" y1="${l.lo}" x2="${l.x}" y2="${l.hi}"/><line class="gridhit gv" data-gv="${i}" x1="${l.x}" y1="${l.lo}" x2="${l.x}" y2="${l.hi}"><title>Grid ${esc(l.label)} — drag to move</title></line>`;});
910
+ geo.h.forEach((l,i)=>{s+=`<line class=gridln data-gh="${i}" x1="${l.lo}" y1="${l.y}" x2="${l.hi}" y2="${l.y}"/><line class="gridhit gh" data-gh="${i}" x1="${l.lo}" y1="${l.y}" x2="${l.hi}" y2="${l.y}"><title>Grid ${esc(l.label)} — drag to move</title></line>`;});
911
+ const bub=(x,y,lb,ax,i,end)=>`<g class=gridbubg data-${ax}="${i}" data-end="${end}"><title>Grid ${esc(lb)} — drag across to move, along to extend/shorten · dbl-click to rename · click to edit the grid</title><circle class=gridbub data-bx="${x}" data-by="${y}" cx="${x}" cy="${y}" r="${R}"/><text class=gridtx data-bx="${x}" data-by="${y}" x="${x}" y="${y}" style="font-size:${F}px">${esc(lb)}</text></g>`;
912
+ geo.v.forEach((l,i)=>{s+=bub(l.x,l.lo,l.label,'gv',i,0)+bub(l.x,l.hi,l.label,'gv',i,1);});
913
+ geo.h.forEach((l,i)=>{s+=bub(l.lo,l.y,l.label,'gh',i,0)+bub(l.hi,l.y,l.label,'gh',i,1);});
914
+ return s+'</g>';}
915
+ // live re-render of just the grid layer while a line is being dragged (render() is too heavy per move)
916
+ function refreshGridLayer(){const el=document.getElementById('gridG');if(el)el.outerHTML=gridSvg();}
917
+ // cursor-following distance readout for grid drags (same chrome as the 3D drag readout)
918
+ let gridRd=null;
919
+ function gridReadout(e,main,sub){
920
+ if(!gridRd){gridRd=document.createElement('div');
921
+ gridRd.style.cssText='position:fixed;display:none;pointer-events:none;z-index:60;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:4px;padding:3px 7px;font:12px system-ui;white-space:nowrap;box-shadow:0 2px 8px rgba(0,0,0,.5)';
922
+ gridRd._m=document.createElement('span');gridRd._s=document.createElement('span');gridRd._s.style.color='var(--mut)';
923
+ gridRd.append(gridRd._m,gridRd._s);document.body.appendChild(gridRd);}
924
+ gridRd._m.textContent=main;gridRd._s.textContent=sub||'';
925
+ gridRd.style.left=(e.clientX+14)+'px';gridRd.style.top=(e.clientY+14)+'px';gridRd.style.display='block';}
926
+ function gridReadoutHide(){if(gridRd)gridRd.style.display='none';}
927
+ const gridDelta=(d)=>'Δ '+(d<0?'−':'+')+dimValueText(Math.abs(d));
928
+ // double-click a bubble → rename that one label in place (a themed input over the bubble; Enter/blur
929
+ // commits into the labels override — undoable; the open grid panel's Labels field refreshes via render)
930
+ function gridRenameBubble(axis,gi,anchorEl){
931
+ const gc=GC();if(!gc||!P.grid)return;
932
+ const geo=gridGeo();const lines=axis==='v'?geo.v:geo.h;const line=lines[gi];if(!line||!anchorEl)return;
933
+ const cur=line.label,r=anchorEl.getBoundingClientRect();
934
+ const inp=document.createElement('input');inp.value=cur;inp.className='gin';
935
+ inp.style.cssText='position:fixed;z-index:70;width:72px;text-align:center;left:'+(r.left+r.width/2-36)+'px;top:'+(r.top+r.height/2-14)+'px;padding:4px;border-color:var(--brand);box-shadow:0 4px 14px rgba(0,0,0,.5)';
936
+ document.body.appendChild(inp);inp.focus();inp.select();
937
+ let done=false;
938
+ const finish=(save)=>{if(done)return;done=true;inp.remove();
939
+ if(!save)return;
940
+ const nv=inp.value.trim();
941
+ if(!nv||nv===cur)return;
942
+ if(/\s/.test(nv)){toast('Labels can’t contain spaces');return;}
943
+ const key=axis==='v'?'labels_x':'labels_y',style=axis==='v'?'num':'alpha';
944
+ const toks=gc.labelsFor(P.grid[key],style,lines.length);toks[gi]=nv;
945
+ edit(()=>{P.grid[key]=toks.join(' ');});};
946
+ inp.addEventListener('keydown',ev=>{ev.stopPropagation();
947
+ if(ev.key==='Enter'){ev.preventDefault();finish(true);}
948
+ else if(ev.key==='Escape'){ev.preventDefault();finish(false);}});
949
+ inp.addEventListener('blur',()=>finish(true));}
950
+ function planColumnCount(){let n=0;for(const m of ((P&&P.members)||[]))if(m.role==='column')n++;return n;}
951
+ // The active plan's steel-filter elements (per-sheet linework the reader captured) — the source the
952
+ // grid-from-drawing detector reads. Falls back to the legacy single-sheet filter shape.
953
+ function filterElsForPlan(){const f=C&&C.filter;if(!f)return null;
954
+ const sh=(f.sheets||[]).find(s=>s&&s.sheet&&P&&P.sheet&&s.sheet.toUpperCase()===P.sheet.toUpperCase());
955
+ const els=(sh&&sh.elements)||((f.sheets||[]).length===0?f.elements:null);
956
+ return (Array.isArray(els)&&els.length)?els:null;}
957
+ // Detect the printed grid in the sheet's linework (grid-core gridFromDrawing) and apply it. keep = an
958
+ // existing grid whose Z levels / hand-set labels should survive a re-read.
959
+ function gridReadFromDrawing(keep){
960
+ const gc=GC(),els=filterElsForPlan();if(!gc||!els)return;
961
+ let r=null;try{r=gc.gridFromDrawing(els,(P.pt_per_ft>0)?P.pt_per_ft:1);}catch(_){r=null;}
962
+ if(!r){toast('No grid found in this sheet’s linework — draw one with Start blank instead');return;}
963
+ const g=r.grid;
964
+ if(keep){g.z=keep.z; // the user's levels always survive a re-read; their hand-set labels beat detected ones (the tooltip promises it)
965
+ if(keep.labels_x)g.labels_x=keep.labels_x;
966
+ if(keep.labels_y)g.labels_y=keep.labels_y;}
967
+ edit(()=>{P.grid=g;});
968
+ toast('Grid read from the drawing — '+r.info.v+' × '+r.info.h+' lines'+(r.info.labeled?'':'. Labels are auto — Ask AI can read the printed marks.'));}
969
+ // The bubble marks on most CAD sets are OUTLINED (not text), so the detector can't read them. The
970
+ // sanctioned path for that gap is the Ask-AI relay (the UI never calls an LLM): pre-fill the existing
971
+ // tweak-contract request with a self-contained instruction; the terminal AI has vision + the source
972
+ // PDF and PUTs the labels back as a contract revision.
973
+ function gridAskAiLabels(){
974
+ const geo=gridGeo();if(!geo)return;
975
+ const t=document.getElementById('askAiText');
976
+ t.value='Read the structural grid bubble labels on sheet '+P.sheet+' from the source drawing and set plan.grid.labels_x and plan.grid.labels_y. '
977
+ +'The grid line positions are already detected: '+geo.v.length+' X lines (labels run left→right) and '+geo.h.length+' Y lines (labels run bottom→up). '
978
+ +'Give each as whitespace-separated tokens in that order, exactly as printed on the bubbles (e.g. "K J H G F.2 F D C B A").';
979
+ if(typeof askAiSyncSend==='function')askAiSyncSend();
980
+ askAiOpen();}
981
+ function gridDefaultOrigin(){const xs=[],ys=[];for(const m of ((P&&P.members)||[]))if(Array.isArray(m.wp)&&m.wp.length>=2){xs.push(m.wp[0][0],m.wp[1][0]);ys.push(m.wp[0][1],m.wp[1][1]);}
982
+ if(xs.length)return [Math.min(...xs),Math.max(...ys)]; // bottom-left of the steel
983
+ return [(typeof X0==='number'?X0:0)+50,(typeof Y1==='number'?Y1:300)-50];} // empty plan → near the sheet corner
984
+ function refresh3DGrid(){if(view3dReady&&window.Steel3DView&&window.Steel3DView.refreshGrid)window.Steel3DView.refreshGrid();}
985
+ function updGridToggle(){const b=document.getElementById('gridToggleB');if(!b)return;b.disabled=!(typeof P!=='undefined'&&P&&P.grid);b.textContent=gridOn()?'Hide grid':'Show grid';}
986
+ // ONE visibility switch, THREE surfaces: the grid panel's checkbox, the 3D legend's "Grid lines" row,
987
+ // and the ⋯ menu item — all call this. grid.on is contract data, so the flip is undoable like every
988
+ // other grid operation.
989
+ function gridSetVisible(on){if(!C||typeof P==='undefined'||!P||!P.grid||on===gridOn())return;
990
+ edit(()=>{P.grid.on=!!on;});updGridToggle();refresh3DGrid();if(view3d)build3DLegend();}
991
+ document.addEventListener('gridcore-ready',()=>{try{if(C)render();}catch(_){}}); // module shim finished after boot → paint the grid layer
992
+ function setGridMode(on){if(!C)return;gridMode=!!on;
993
+ if(!gridMode){const ae=document.activeElement,pn=document.getElementById('panel');if(ae&&pn&&pn.contains(ae))ae.blur(); // commit a focused field (change fires on blur) BEFORE the panel is torn down — a canvas click's pointerdown runs before the browser's own focus change
994
+ gridPick=false;document.body.classList.remove('gridpick');snapClear();}
995
+ else{if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // disarm Move/Copy too — grid mode and cmTool must be mutually exclusive (both own the left-click/Esc)
996
+ panel();}
997
+ // The grid panel (armed-tool takeover, like the Dimension tool). Empty state = two explicit create
998
+ // buttons (nothing is created just by opening — UX review); fields commit on change (blur/Enter),
999
+ // one undo step each; keystrokes only update the live feedback line.
1000
+ function renderGridPanel(p){
1001
+ const gc=GC();
1002
+ if(!gc){p.innerHTML='<span class=badge>Grid lines</span><div class=hint style="margin-top:8px">Loading grid tools…</div>';return;}
1003
+ const ppf=(P&&P.pt_per_ft>0)?P.pt_per_ft:1;
1004
+ if(!P.grid){
1005
+ const nCols=planColumnCount(),hasEls=!!filterElsForPlan();
1006
+ p.innerHTML=`<span class=badge>Grid lines</span>
1007
+ <div class=hint style="margin-top:8px">No grid yet. Grid lines are a plan reference — they don't constrain members.</div>
1008
+ <div class=gbtns><button id=gridReadB ${hasEls?'':'disabled'} title="${hasEls?'Detect the printed grid lines and bubbles in this sheet’s linework. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB ${nCols<2?'disabled':''} title="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button><button id=gridBlankB title="Start from a 4×4 grid of 30' bays and edit the spacings. Undoable.">Start blank</button></div>
1009
+ <div class=hint style="margin-top:10px">Spacing uses <b>n*d</b> to repeat (e.g. <b>3*25'</b> = three 25' bays). Tokens: <b>25'-6"</b>, <b>6"</b>, <b>7.5m</b>, bare mm.</div>`;
1010
+ const rb=document.getElementById('gridReadB');if(rb)rb.onclick=()=>gridReadFromDrawing(null);
1011
+ const ab=document.getElementById('gridAutoB');if(ab)ab.onclick=()=>{const g=gc.inferGrid(P.members,ppf);if(!g){toast('No columns to infer a grid from');return;}edit(()=>{P.grid=g;});};
1012
+ const bb=document.getElementById('gridBlankB');if(bb)bb.onclick=()=>{const o=gridDefaultOrigin();edit(()=>{P.grid=gc.defaultGrid(o);});};
1013
+ return;}
1014
+ const g=P.grid,nCols=planColumnCount();
1015
+ p.innerHTML=`<span class=badge>Grid lines</span>
1016
+ <div class=hint style="margin-top:8px">A plan reference — doesn't constrain members. <b>n*d</b> repeats a bay (e.g. <b>3*25'</b>); tokens: <b>25'-6"</b>, <b>6"</b>, <b>7.5m</b>, bare mm.</div>
1017
+ <label class=gck style="margin-top:10px"><input type=checkbox id=gShow ${gridOn()?'checked':''}><span>Show grid lines</span></label>
1018
+ <div class=sect style="margin-top:12px">X spacings <span style="color:var(--mut);font-weight:400;text-transform:none">· lines 1, 2, 3…</span></div>
1019
+ <input id=gX class=gin value="${esc(g.x||'')}" placeholder="e.g. 0 3*25' 20'-6&quot;" autocomplete=off spellcheck=false>
1020
+ <div id=gXfb class=gfb></div>
1021
+ <div class=sect style="margin-top:10px">Y spacings <span style="color:var(--mut);font-weight:400;text-transform:none">· lines A, B, C…</span></div>
1022
+ <input id=gY class=gin value="${esc(g.y||'')}" placeholder="e.g. 0 4*30'" autocomplete=off spellcheck=false>
1023
+ <div id=gYfb class=gfb></div>
1024
+ <div class=sect style="margin-top:10px">Levels (Z)</div>
1025
+ <input id=gZ class=gin value="${esc(g.z||'')}" placeholder="e.g. 0 16'-6&quot; 33'" autocomplete=off spellcheck=false>
1026
+ <div id=gZfb class=gfb></div>
1027
+ <div class=hint style="margin-top:3px">Absolute elevations, one per level — shown as tags in the 3D view.</div>
1028
+ <div class=sect style="margin-top:10px">Labels</div>
1029
+ <div style="display:flex;gap:6px"><input id=gLX class=gin value="${esc(g.labels_x||'')}" placeholder="auto: 1 2 3…" autocomplete=off spellcheck=false><input id=gLY class=gin value="${esc(g.labels_y||'')}" placeholder="auto: A B C…" autocomplete=off spellcheck=false></div>
1030
+ <div id=gLfb class=gfb></div>
1031
+ ${(!g.labels_x&&!g.labels_y)?'<div class=gbtns style="margin-top:6px"><button id=gridLabelsAiB class=ghostw title="Many drawings outline their bubble text, so the marks can’t be read deterministically. This sends a request to your AI terminal — it reads the printed marks from the source drawing and fills the labels as a contract update.">Ask AI to read the labels ▸</button></div>':''}
1032
+ <div class=sect style="margin-top:10px">Extension</div>
1033
+ <input id=gExt class=gin style="width:110px" value="${esc(gc.fmtLen(isFinite(g.ext)?g.ext:1524))}" autocomplete=off spellcheck=false>
1034
+ <div class=hint style="margin-top:3px">How far every line runs past the outermost, in plan. Drag a bubble <b>along</b> its line to override just that one.</div>
1035
+ ${(()=>{const n=Object.keys(g.ends_x||{}).length+Object.keys(g.ends_y||{}).length;return n?`<div class=gfb style="display:flex;align-items:center;gap:8px;margin-top:5px"><span>${n} line${n===1?'':'s'} with a custom extent</span><button id=gridResetExtB class=ghost style="height:22px;padding:0 8px;font-size:11px" title="Reset every line back to the Extension value above. Undoable.">Reset all</button></div>`:'';})()}
1036
+ <div class=gbtns><button id=gridPickB class="${gridPick?'on':''}" title="Click the grid's 1-A corner on the plan — snaps to member ends. Esc cancels.">Pick origin</button><button id=gridReadB2 ${filterElsForPlan()?'':'disabled'} title="${filterElsForPlan()?'Re-detect the printed grid from this sheet’s linework — keeps your levels and hand-set labels. Undoable.':'Needs this sheet’s filter linework — re-read the drawing with the steel filter step to capture it.'}">From drawing</button><button id=gridAutoB2 ${nCols<2?'disabled':''} title="${nCols<2?'Needs at least two columns in the model to infer spacing.':'Re-infer spacings and origin from the column layout. Undoable.'}">Auto from columns</button></div>
1037
+ ${gridPick?'<div class=hint style="margin-top:6px">Click the grid’s <b>1-A corner</b> on the plan — snaps to member ends (<b>Alt</b> off). <b>Esc</b> cancels.</div>':''}
1038
+ <div class=gbtns style="margin-top:16px"><button id=gridRemoveB class=ghostw style="flex:1" title="Delete all grid lines and levels. Undoable.">Remove grid</button><button id=gridDoneB>Done</button></div>`;
1039
+ const fbSpacing=(inpId,fbId,style,override,emptyMsg)=>{
1040
+ const el=document.getElementById(fbId),v=document.getElementById(inpId).value;
1041
+ const r=gc.parseSpacings(v);
1042
+ if(r.err){el.className='gerr';el.textContent=r.err.replace(/^c/,'C')+(r.err.indexOf('read')>=0?" — expected n*distance, like 3*25'.":'');return;}
1043
+ el.className='gfb';
1044
+ if(!r.offsets.length){el.textContent=emptyMsg;return;}
1045
+ const lbs=gc.labelsFor(document.getElementById(override).value,style,r.offsets.length);
1046
+ const run=r.offsets[r.offsets.length-1]-r.offsets[0];
1047
+ el.textContent=r.offsets.length+' line'+(r.offsets.length===1?'':'s')+' · '+lbs[0]+(r.offsets.length>1?'–'+lbs[lbs.length-1]:'')+(run>0?' · '+gc.fmtLen(run)+' total':'');};
1048
+ const fbZ=()=>{const el=document.getElementById('gZfb'),toks=document.getElementById('gZ').value.trim().split(/[\s,]+/).filter(Boolean);
1049
+ for(const t of toks)if(!isFinite(gc.parseLen(t))){el.className='gerr';el.textContent='Can’t read “'+t+'” — one elevation per level, like 16\'-6".';return;}
1050
+ el.className='gfb';el.textContent=toks.length?(toks.length+' level'+(toks.length===1?'':'s')):'No levels — grid draws at elevation 0 in 3D.';};
1051
+ const fbLabels=()=>{const el=document.getElementById('gLfb');
1052
+ for(const [id,style,n] of [['gLX','num',gc.parseSpacings(document.getElementById('gX').value).offsets.length],['gLY','alpha',gc.parseSpacings(document.getElementById('gY').value).offsets.length]]){
1053
+ const lbs=gc.labelsFor(document.getElementById(id).value,style,n),seen=new Set();
1054
+ for(const lb of lbs){if(seen.has(lb)){el.className='gerr';el.textContent='Duplicate label “'+lb+'” — labels must be unique per direction.';return;}seen.add(lb);}}
1055
+ el.className='gfb';el.textContent='';};
1056
+ const fbAll=()=>{fbSpacing('gX','gXfb','num','gLX','No X lines — grid runs Y only.');fbSpacing('gY','gYfb','alpha','gLY','No Y lines — grid runs X only.');fbZ();fbLabels();};
1057
+ fbAll();
1058
+ // commit on change (blur/Enter); Esc restores the field; keystrokes refresh only the feedback line
1059
+ const wire=(id,commit)=>{const el=document.getElementById(id),orig=el.value;
1060
+ el.addEventListener('input',fbAll);
1061
+ el.addEventListener('keydown',ev=>{ev.stopPropagation();
1062
+ if(ev.key==='Enter'){ev.preventDefault();el.blur();}
1063
+ else if(ev.key==='Escape'){ev.preventDefault();el.value=orig;fbAll();}});
1064
+ el.addEventListener('change',()=>commit(el.value.trim(),el));};
1065
+ wire('gX',(v)=>{const r=gc.parseSpacings(v);if(r.err)return;if(v!==(g.x||''))edit(()=>{ // a changed LINE COUNT invalidates the index-keyed per-line ends (they'd re-bind to the wrong lines)
1066
+ if(P.grid.ends_x&&r.offsets.length!==gc.parseSpacings(P.grid.x).offsets.length)delete P.grid.ends_x;
1067
+ P.grid.x=v;});});
1068
+ wire('gY',(v)=>{const r=gc.parseSpacings(v);if(r.err)return;if(v!==(g.y||''))edit(()=>{
1069
+ if(P.grid.ends_y&&r.offsets.length!==gc.parseSpacings(P.grid.y).offsets.length)delete P.grid.ends_y;
1070
+ P.grid.y=v;});});
1071
+ wire('gZ',(v)=>{const toks=v.split(/[\s,]+/).filter(Boolean);if(toks.some(t=>!isFinite(gc.parseLen(t))))return;if(v!==(g.z||''))edit(()=>{P.grid.z=v;});});
1072
+ wire('gLX',(v)=>{if(v!==(g.labels_x||''))edit(()=>{P.grid.labels_x=v;});});
1073
+ wire('gLY',(v)=>{if(v!==(g.labels_y||''))edit(()=>{P.grid.labels_y=v;});});
1074
+ wire('gExt',(v,el)=>{const mm=gc.parseLen(v);if(!isFinite(mm)||mm<0){el.style.borderColor='#fca5a5';return;}el.style.borderColor='';if(Math.abs(mm-(isFinite(g.ext)?g.ext:1524))>0.5)edit(()=>{P.grid.ext=mm;});});
1075
+ const gsh=document.getElementById('gShow');if(gsh)gsh.addEventListener('change',()=>gridSetVisible(gsh.checked));
1076
+ const grx=document.getElementById('gridResetExtB');if(grx)grx.onclick=()=>{edit(()=>{delete P.grid.ends_x;delete P.grid.ends_y;});}; // drop every per-line extent override → all lines follow the global Extension again
1077
+ document.getElementById('gridPickB').onclick=()=>{gridPick=!gridPick;document.body.classList.toggle('gridpick',gridPick);panel();};
1078
+ const r2=document.getElementById('gridReadB2');if(r2)r2.onclick=()=>gridReadFromDrawing(P.grid);
1079
+ const lb=document.getElementById('gridLabelsAiB');if(lb)lb.onclick=gridAskAiLabels;
1080
+ const a2=document.getElementById('gridAutoB2');if(a2)a2.onclick=()=>{const ng=gc.inferGrid(P.members,ppf);if(!ng){toast('No columns to infer a grid from');return;}ng.z=P.grid.z;ng.labels_x=P.grid.labels_x;ng.labels_y=P.grid.labels_y;ng.ext=P.grid.ext;edit(()=>{P.grid=ng;});}; // re-infer geometry, keep the user's levels/labels/extension
1081
+ document.getElementById('gridRemoveB').onclick=()=>{edit(()=>{P.grid=null;});};
1082
+ document.getElementById('gridDoneB').onclick=()=>{setGridMode(false);render();};
1083
+ }
850
1084
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
851
1085
  // --- repeat last command (AutoCAD/Tekla): when NO tool is active, Space/Enter re-invokes the last command ---
852
1086
  let lastCmd=null;
853
1087
  function setLastCmd(label,run){lastCmd={label,run};}
854
1088
  function anyToolActive(){
855
- if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||mode==='add'||picking||cmTool)return true; // 2D tools armed (cmTool = Move/Copy)
1089
+ if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||gridMode||gridPick||mode==='add'||picking||cmTool)return true; // 2D tools armed (cmTool = Move/Copy)
856
1090
  if(view3d&&window.Steel3DView){if(window.Steel3DView.clipMode&&window.Steel3DView.clipMode())return true;const dm=document.getElementById('m3dDim');if(dm&&dm.classList.contains('on'))return true;} // 3D dim/clip armed
857
1091
  return false;
858
1092
  }
@@ -958,6 +1192,7 @@ function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&
958
1192
  function render(){
959
1193
  if(geoMode&&(selIds.size<1||(geoMode==='split'&&selIds.size!==1))){geoMode=null;document.body.classList.remove('geo');} // Split needs exactly one member; Extend/Trim works on any selection
960
1194
  let s=RB64?`<image href="data:image/jpeg;base64,${RB64}" x="${X0}" y="${Y0}" width="${X1-X0}" height="${Y1-Y0}"/>`:'';
1195
+ s+=gridSvg(); // structural grid under the linework (members/dims stay on top)
961
1196
  for(const sg of P.segments) s+=`<line class=seg data-seg="${sg.id}" x1="${sg.a[0]}" y1="${sg.a[1]}" x2="${sg.b[0]}" y2="${sg.b[1]}"/>`;
962
1197
  for(const m of P.members){const c=colorFor(m.profile);const on=selIds.has(m.id);const g=on?` style="filter:drop-shadow(0 0 3px ${c}) drop-shadow(0 0 8px ${c})"`:'';
963
1198
  s+=`<line class="member${m.rfi?' rfi':''}${on?' sel':''}" data-id="${m.id}" x1="${m.wp[0][0]}" y1="${m.wp[0][1]}" x2="${m.wp[1][0]}" y2="${m.wp[1][1]}" stroke="${c}"${g}/>`;}
@@ -991,7 +1226,7 @@ function render(){
991
1226
  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>`;});}}
992
1227
  s+=renderDims();
993
1228
  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)
994
- 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();
1229
+ 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();
995
1230
  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)
996
1231
  }
997
1232
  function updDup(){const n=redundantDups().length;
@@ -1006,6 +1241,8 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
1006
1241
  svg.querySelectorAll('circle.handle,circle.dimhandle').forEach(h=>h.setAttribute('r',epR()));
1007
1242
  const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
1008
1243
  svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
1244
+ svg.querySelectorAll('circle.gridbub').forEach(c=>c.setAttribute('r',11/zoom));
1245
+ svg.querySelectorAll('text.gridtx').forEach(t=>t.style.fontSize=(12/zoom)+'px');
1009
1246
  svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
1010
1247
  r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
1011
1248
  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)
@@ -1024,6 +1261,7 @@ function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for
1024
1261
  function panel(){
1025
1262
  const p=document.getElementById('panel');
1026
1263
  if(!selDimIds.size||!dimsVisible)dimSplitMode=false;document.body.classList.toggle('dimsplit',dimSplitMode); // split mode is meaningless without a (visible) dim selected — also disarms when dims are hidden
1264
+ if(gridMode){renderGridPanel(p);return;}
1027
1265
  if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
1028
1266
  <div class=hint id=csHint style="margin-top:8px">${csDraft?'Click the <b>second</b> point — the local-X direction. Y follows at 90°.':'Click the <b>origin</b> point to start.'}</div>
1029
1267
  <div class=hint style="margin-top:8px">Two clicks define the frame; snaps to member ends &amp; grid (<b>Alt</b> off). <b>Esc</b> cancels.</div>`;return;}
@@ -1064,7 +1302,7 @@ function panel(){
1064
1302
  <div class=hint id=pickHint style="margin-top:4px;font-style:italic">or click a label in the drawing</div>
1065
1303
  <div class=divrow><hr></div>
1066
1304
  <div class=sect>Draw a member</div>
1067
- <div class=hint>✏️ Drag two points on the canvas — or — click a detected segment. New members take the current profile. <b>Shift</b> = straight, <b>Esc</b> = exit.</div>`;
1305
+ <div class=hint>✏️ ${view3d?'Click the start then the end — both land on the <b>working plane</b>. A near-vertical pick pair becomes a column.':'Drag two points on the canvas — or — click a detected segment.'} New members take the current profile. <b>Shift</b> = straight, <b>Esc</b> = exit.</div>`;
1068
1306
  const ap=document.getElementById('addProf');
1069
1307
  ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
1070
1308
  return;}
@@ -1345,12 +1583,14 @@ function rectHit(p0,p1,r){
1345
1583
  // --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△); members lie on grids → snaps to grid intersections; Alt = off ---
1346
1584
  const SNAP_PX=9; let snapPts=[], snapSegs=[], snapMids=[], lastSnapKind='end'; // lastSnapKind drives the marker glyph: 'mid' → triangle, else circle
1347
1585
  const _mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
1348
- function buildSnap(except){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
1586
+ function buildSnap(except,opts){const ex=except instanceof Set?except:(except!=null?new Set([except]):new Set()); // id or Set of ids to skip
1349
1587
  snapPts=[];snapSegs=[];snapMids=[];
1350
1588
  for(const m of P.members){if(ex.has(m.id))continue;snapPts.push(m.wp[0],m.wp[1]);snapSegs.push([m.wp[0],m.wp[1]]);snapMids.push(_mid(m.wp[0],m.wp[1]));}
1351
1589
  for(const s of P.segments){snapPts.push(s.a,s.b);snapSegs.push([s.a,s.b]);snapMids.push(_mid(s.a,s.b));}
1352
1590
  if(dimsVisible&&Array.isArray(P.dims))for(const d of P.dims){if(ex.has(d.id))continue; // snap to a dimension's VISIBLE (offset) line — its ends, midpoint, and onto-line (dimGeo already gives g.mid)
1353
- const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);snapPts.push(g.p1,g.p2);snapSegs.push([g.p1,g.p2]);snapMids.push(g.mid);}}
1591
+ const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);snapPts.push(g.p1,g.p2);snapSegs.push([g.p1,g.p2]);snapMids.push(g.mid);}
1592
+ if(gridOn()&&!(opts&&opts.noGrid)){const geo=gridGeo();if(geo){const gs=GC().gridSnapGeom(geo); // grid lines + intersections snap like member geometry (a hidden grid doesn't snap; a DRAGGED grid line must not stick to its own intersections)
1593
+ for(const sg of gs.segs)snapSegs.push(sg);for(const q of gs.pts)snapPts.push(q);}}}
1354
1594
  // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
1355
1595
  function nearestOnLine(x,y,tol){let bp=null,bd=tol;
1356
1596
  for(const sg of snapSegs){const pr=projPt([x,y],sg[0],sg[1]);const d=Math.hypot(pr.pt[0]-x,pr.pt[1]-y);if(d<bd){bd=d;bp=pr.pt;}}
@@ -1459,16 +1699,19 @@ function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
1459
1699
  document.getElementById('mvCaret').classList.toggle('on',cmTool==='move');
1460
1700
  document.getElementById('cpB').classList.toggle('on',cmTool==='copy');
1461
1701
  document.getElementById('cpCaret').classList.toggle('on',cmTool==='copy');}
1462
- function disarmCm(){cmTool=null;cmArrayMode=false;cmDraft=null;cmHudClose();cmPrevClear();setCmUi();}
1463
- function armCm(tool,array){ // re-arming the same config toggles the tool off
1464
- if(view3d){toast('Move/Copy in 3D lands with the working plane (next slice)switch to 2D');return;}
1702
+ function disarmCm(){cmTool=null;cmArrayMode=false;cmDraft=null;cmHudClose();cmPrevClear();setCmUi();
1703
+ if(window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d();} // drop the 3D-side pick/preview too
1704
+ function armCm(tool,array){ // re-arming the same config toggles the tool offworks in 2D and (on the working plane) in 3D
1465
1705
  if(cmTool===tool&&cmArrayMode===!!array){disarmCm();render();return;}
1706
+ if(view3d&&window.Steel3DView){const dm=document.getElementById('m3dDim');if(dm&&dm.classList.contains('on'))window.Steel3DView.toggleDimTool();} // the 3D dim tool shares the left-click — disarm it
1466
1707
  if(!selIds.size){toast('Select members first, then '+(tool==='move'?'Move':'Copy'));return;}
1467
- if(mode==='add'){mode='sel';setMode();}
1708
+ if(mode==='add'){mode='sel';setMode();if(window.Steel3DView&&window.Steel3DView.drClear3d)window.Steel3DView.drClear3d();}
1468
1709
  if(dimMode){dimMode=false;setDimMode();}
1469
1710
  if(csaxisMode){csaxisMode=false;setCsMode();}
1711
+ if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // arming Move/Copy leaves the grid panel — grid mode and cmTool must be mutually exclusive
1470
1712
  geoMode=null;setGeo();selDimIds.clear();dimSplitMode=false;picking=false;pickEnd=null;
1471
1713
  cmHudClose();cmPrevClear(); // switching Move⇄Copy mid-pick must drop the old HUD/preview — a stale HUD Enter would commit for the previous tool
1714
+ if(window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d(); // and the 3D-side draft (same reason)
1472
1715
  cmTool=tool;cmArrayMode=!!array&&tool==='copy';cmDraft=null;cmAxis='free';setCmUi();render();
1473
1716
  setLastCmd(tool==='move'?'Move':(cmArrayMode?'Copy array':'Copy'),()=>{if(!cmTool)armCm(tool,array);});}
1474
1717
  function cmHintText(){if(!cmDraft)return (cmTool==='move'?'Move':'Copy')+' — click the base point (a snapped end, grid or line point).';
@@ -1514,22 +1757,28 @@ function cmRubSvg(a,c,dimmed){const L=Math.hypot(c[0]-a[0],c[1]-a[1]);if(L<1e-6)
1514
1757
  return `<line class=cmrub${dimmed?' style="opacity:.35"':''} x1="${a[0]}" y1="${a[1]}" x2="${c[0]}" y2="${c[1]}"/>`
1515
1758
  +`<path class=cmarrow${dimmed?' style="opacity:.35"':''} d="M ${c[0]} ${c[1]} L ${p1[0]} ${p1[1]} L ${p2[0]} ${p2[1]} Z"/>`
1516
1759
  +(dimmed?'':`<rect class=cmchip x="${mid[0]-cw/2/zoom}" y="${mid[1]-ch/2/zoom}" width="${cw/zoom}" height="${ch/zoom}" rx="${4/zoom}"/><text class=cmtx x="${mid[0]}" y="${mid[1]}" style="font-size:${12/zoom}px">${txt}</text>`);}
1517
- function cmClick(e){const s=cmSnapAt(e);
1760
+ function cmClick(e){
1761
+ if(!selIds.size){toast('Selection is empty — '+(cmTool==='move'?'Move':'Copy')+' ended');disarmCm();render();return;} // selection drained while armed (undo/delete) — end the tool instead of collecting doomed picks
1762
+ const s=cmSnapAt(e);
1518
1763
  if(!cmDraft){cmDraft={base:[s.x,s.y],vA:null};cmPrev(e);panel();return;}
1519
1764
  const v=[s.x-cmDraft.base[0],s.y-cmDraft.base[1],0];
1520
1765
  if(Math.hypot(v[0],v[1])<0.5)return; // ignore a zero-distance double-click
1521
1766
  if(cmArrayMode&&!cmDraft.vA){cmDraft.vA=v;cmPrev(e);panel();return;} // array: this click fixes direction A
1522
1767
  cmCommit(v);}
1523
- function cmCommit(v){const sel=selArr();if(!sel.length){toast('Selection is empty '+(cmTool==='move'?'Move':'Copy')+' ended');disarmCm();render();return;}
1768
+ // Shared commit core (2D picks, HUD, and the 3D flow all land here): offs = [dx,dy,dzIn] lists in
1769
+ // plan px / inches. Move applies offs[0] to the selection; copy clones per offset and selects the set.
1770
+ function cmApplyOffsets(offs){const sel=selArr();if(!sel.length){toast('Selection is empty — '+(cmTool==='move'?'Move':'Copy')+' ended');disarmCm();render();return;}
1524
1771
  if(cmArrayMode&&cmCountA*cmCountB>100){toast('Array capped at 100 instances — lower the counts (two passes reach further)');return;}
1525
- const offs=cmOffsetsFor(v);
1772
+ if(!offs.length){toast(cmArrayMode?'A 1 × 1 array places no copies — raise a count':'Nothing to place');return;} // empty offset list (e.g. array 1×1) → no-op, never deref offs[0]
1526
1773
  edit(()=>{
1527
- if(cmTool==='move'){translateMembers(sel,v);}
1774
+ if(cmTool==='move'){translateMembers(sel,offs[0]);}
1528
1775
  else{const ns=new Set();
1529
1776
  for(const off of offs)for(const m of sel){const c=cloneMember(m);translateMembers([c],off);P.members.push(c);ns.add(c.id);}
1530
1777
  selIds=ns;}}); // copy selects the new set (numbered badges)
1531
- toast((cmTool==='move'?'Moved ':'Copied ')+sel.length+' member'+(sel.length===1?'':'s')+(offs.length>1?' ×'+offs.length:'')+' · '+fmtFtIn(Math.hypot(v[0],v[1])/FT*12));
1778
+ const L=Math.hypot(offs[0][0],offs[0][1],((offs[0][2]||0)*FT/12)); // dz shown in the same ft-in scale
1779
+ toast((cmTool==='move'?'Moved ':'Copied ')+sel.length+' member'+(sel.length===1?'':'s')+(offs.length>1?' ×'+offs.length:'')+' · '+fmtFtIn(L/FT*12));
1532
1780
  cmHudClose();cmDraft=null;cmPrevClear();panel();} // tool stays armed for the next pick pair; Esc finishes
1781
+ function cmCommit(v){cmApplyOffsets(cmOffsetsFor(v));}
1533
1782
  // --- numeric HUD (spec §3.3): type after the base pick → exact distance or dx,dy[,dz] (dz inches).
1534
1783
  // Live parse echo while typing; unparseable Enter flags the chip red (inline, not just a toast). ---
1535
1784
  let cmHudEl=null;
@@ -1539,7 +1788,7 @@ function cmHudEcho(){const inp=cmHudEl.querySelector('input'),lbl=cmHudEl.queryS
1539
1788
  const pv=parseVec(inp.value),n=(cmTool==='copy'&&!cmArrayMode&&cmCount>1)?(' ×'+cmCount):'';
1540
1789
  lbl.textContent=(pv?(pv.dist!=null?('= '+fmtFtIn(pv.dist)):('= '+pv.comp.map(fmtFtIn).join(', ')))+n:n.trim());
1541
1790
  cmHudEl.classList.remove('err');}
1542
- function cmHudShow(seed){if(!cmDraft)return;
1791
+ function cmHudShow(seed){if(!cmDraft&&!(view3d&&window.Steel3DView&&window.Steel3DView.cmHasBase&&window.Steel3DView.cmHasBase()))return;
1543
1792
  if(!cmHudEl){cmHudEl=document.createElement('div');cmHudEl.id='cmHud';
1544
1793
  const inp=document.createElement('input');inp.placeholder="12'-6 · or dx,dy";inp.autocomplete='off';inp.spellcheck=false;
1545
1794
  const lbl=document.createElement('span');lbl.id='cmHudLbl';
@@ -1548,13 +1797,19 @@ function cmHudShow(seed){if(!cmDraft)return;
1548
1797
  inp.addEventListener('keydown',ev=>{ev.stopPropagation();
1549
1798
  if(ev.key==='Enter'){ev.preventDefault();cmHudCommit(inp.value);}
1550
1799
  else if(ev.key==='Escape'){ev.preventDefault();cmHudClose();}});}
1551
- const scr=cmLastEvt?[cmLastEvt.clientX,cmLastEvt.clientY]:[innerWidth/2,innerHeight/2];
1800
+ const scr3=view3d&&window.Steel3DView&&window.Steel3DView.cmLastClient?window.Steel3DView.cmLastClient():null;
1801
+ const scr=scr3||(cmLastEvt?[cmLastEvt.clientX,cmLastEvt.clientY]:[innerWidth/2,innerHeight/2]);
1552
1802
  cmHudEl.style.left=Math.min(scr[0]+16,innerWidth-200)+'px';cmHudEl.style.top=Math.min(scr[1]+16,innerHeight-46)+'px';
1553
1803
  cmHudEl.style.display='flex';cmHudEl.classList.remove('err');
1554
1804
  const inp=cmHudEl.querySelector('input');inp.value=seed||'';cmHudEcho();inp.focus();
1555
1805
  try{inp.setSelectionRange(inp.value.length,inp.value.length);}catch(_){}}
1556
1806
  function cmHudCommit(str){const pv=parseVec(str);
1557
1807
  if(!pv){cmHudEl.classList.add('err');toast('Enter a length (12\'-6) or components (dx,dy)');return;}
1808
+ if(view3d&&window.Steel3DView&&window.Steel3DView.cmHudApply){ // 3D: the view maps the value into the working-plane basis and commits via onTransform3d
1809
+ const res=window.Steel3DView.cmHudApply(pv);
1810
+ if(res&&res.err){cmHudEl.classList.add('err');toast(res.err);return;}
1811
+ if(res&&res.staged){cmHudClose();panel();return;} // array direction A staged
1812
+ cmHudClose();return;}
1558
1813
  let v;
1559
1814
  if(pv.comp){const u=P.frame?P.frame.u:[1,0],vv=[u[1],-u[0]]; // typed components live in the local frame; +Y = up the plan
1560
1815
  const dxp=pv.comp[0]*FT/12,dyp=pv.comp[1]*FT/12;
@@ -1571,7 +1826,7 @@ function cmHudCommit(str){const pv=parseVec(str);
1571
1826
  let lvModal=null;
1572
1827
  function lvOpen(){return !!lvModal;}
1573
1828
  function closeLevelModal(){if(lvModal){lvModal.remove();lvModal=null;}}
1574
- function openLevelModal(tool){if(view3d){toast('Switch to 2D to move/copy between levels');return;}
1829
+ function openLevelModal(tool){ // works from 2D and 3D the commit is plan-data-level
1575
1830
  if(!selIds.size){toast('Select members first, then '+(tool==='move'?'Move':'Copy')+' to level');return;}
1576
1831
  if(C.plans.length<2){toast('Only one plan is loaded — nothing to '+tool+' to');return;}
1577
1832
  closeLevelModal();
@@ -1665,6 +1920,18 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1665
1920
  if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
1666
1921
  if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
1667
1922
  if(cmTool){cmClick(e);e.preventDefault();return;} // Move/Copy armed → clicks pick base/destination
1923
+ if(gridPick){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear(); // armed pick-origin → one snapped click sets the grid's 1-A corner (undoable)
1924
+ gridPick=false;document.body.classList.remove('gridpick');if(P.grid)edit(()=>{P.grid.origin=[x,y];});else panel();e.preventDefault();return;}
1925
+ {const gb=t.closest&&t.closest('g.gridbubg'),ghit=(t.classList&&t.classList.contains('gridhit'))?t:null; // grid line body OR label bubble → drag slides the line (a no-move bubble click still opens the panel)
1926
+ const gel=gb||ghit;
1927
+ if(gel&&mode==='sel'&&!geoMode&&!dimSplitMode&&!picking&&P.grid){
1928
+ const axis=gel.dataset.gv!=null?'v':'h',gi=+(gel.dataset.gv!=null?gel.dataset.gv:gel.dataset.gh);
1929
+ if(isFinite(gi)){buildSnap(null,{noGrid:true});const q=toSvg(e);const geo=gridGeo();const line=geo&&geo[axis][gi];
1930
+ if(line){const end=gb?+(gb.dataset.end||0):null; // a bubble grab can become an END drag (along the line); a body grab is always a perpendicular move
1931
+ drag={type:'gridline',axis,gi,startPt:[q.x,q.y],orig:axis==='v'?line.x:line.y,origEnd:end===0?line.lo:line.hi,end,
1932
+ base:geo[axis].map(l=>axis==='v'?l.x:l.y),bubble:!!gb,moved:false,dir:null,pre:snapshot()}; // base = dragstart positions so one drag quantizes the string ONCE (no 1/16" creep on unmoved lines)
1933
+ svg.setPointerCapture(e.pointerId);e.preventDefault();return;}}}}
1934
+ if(gridMode)setGridMode(false); // any other canvas click leaves the grid panel — selection takes the panel back (fields commit on blur first)
1668
1935
  if(dimSplitMode&&selDimIds.size&&dimsVisible&&mode==='sel'){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear();dimSplitAt([x,y]);e.preventDefault();return;} // split mode → each click inserts a point into the selected dim under it
1669
1936
  if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(id);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps; exclude this dim so the end can't snap to its own line/midpoint)
1670
1937
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
@@ -1719,6 +1986,34 @@ svg.addEventListener('pointermove',e=>{
1719
1986
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
1720
1987
  if(d<16/zoom&&raw.t>0.03&&raw.t<0.97){let cut=raw.pt;if(!e.altKey){buildSnap(m.id);const sn=snap(raw.pt[0],raw.pt[1]);if(sn.hit){const p2=projPt([sn.x,sn.y],m.wp[0],m.wp[1]);if(p2.t>0.02&&p2.t<0.98)cut=p2.pt;}}snapMark(cut[0],cut[1],'end');}else snapClear();}return;} // the cut lands ON the member → always the neutral circle, never a stale midpoint △ (e.g. Alt held = no snap() this frame)
1721
1988
  if(!drag)return;const p=toSvg(e);
1989
+ if(drag.type==='gridline'){const dxc=p.x-drag.startPt[0],dyc=p.y-drag.startPt[1];
1990
+ if(!drag.moved){if(Math.hypot(dxc,dyc)<=4/zoom)return;drag.moved=true; // past the tolerance = a drag, not a click
1991
+ const along=drag.axis==='v'?Math.abs(dyc):Math.abs(dxc),perp=drag.axis==='v'?Math.abs(dxc):Math.abs(dyc);
1992
+ drag.dir=(drag.end!=null&&along>perp)?'along':'perp';} // a bubble dragged ALONG the line adjusts that end; anything else slides the line
1993
+ const gc=GC();if(!gc)return;const ppf=(P.pt_per_ft>0)?P.pt_per_ft:1;
1994
+ if(drag.dir==='perp'){
1995
+ let np=drag.orig+(drag.axis==='v'?dxc:dyc);
1996
+ if(!e.altKey){let bd=SNAP_PX/zoom,bp=null; // slide-snap: the PERPENDICULAR coordinate of member/segment endpoints (align the grid to the steel)
1997
+ for(const q of snapPts){const c=drag.axis==='v'?q[0]:q[1];const d=Math.abs(c-np);if(d<bd){bd=d;bp=c;}}
1998
+ if(bp!=null){np=bp;snapMark(drag.axis==='v'?np:p.x,drag.axis==='v'?p.y:np,'line');}else snapClear();}
1999
+ else snapClear();
2000
+ const applied=gc.moveGridLine(P.grid,ppf,drag.axis,drag.gi,np,drag.base); // clamps between neighbours, rebases origin on line 1/A
2001
+ const geo=gridGeo(),lines=geo?geo[drag.axis]:null; // live readout: how far it moved + the bay it resizes
2002
+ let sub='';
2003
+ if(lines&&lines.length>1){const pos=(l)=>drag.axis==='v'?l.x:l.y;const bay=drag.gi>0?Math.abs(pos(lines[drag.gi])-pos(lines[drag.gi-1])):Math.abs(pos(lines[1])-pos(lines[0]));sub=' · bay '+dimValueText(bay);}
2004
+ gridReadout(e,gridDelta((applied!=null?applied:np)-drag.orig),sub);
2005
+ }else{
2006
+ let np=drag.origEnd+(drag.axis==='v'?dyc:dxc);
2007
+ if(!e.altKey){let bd=SNAP_PX/zoom,bp=null; // end-snap: the ALONG coordinate of member/segment endpoints
2008
+ for(const q of snapPts){const c=drag.axis==='v'?q[1]:q[0];const d=Math.abs(c-np);if(d<bd){bd=d;bp=c;}}
2009
+ if(bp!=null){np=bp;snapMark(drag.axis==='v'?drag.orig:np,drag.axis==='v'?np:drag.orig,'end');}else snapClear();}
2010
+ else snapClear();
2011
+ const applied=gc.moveGridLineEnd(P.grid,ppf,drag.axis,drag.gi,drag.end,np); // 12" min length; near the full span → back to auto
2012
+ gridReadout(e,gridDelta((applied!=null?applied:np)-drag.origEnd),'');
2013
+ }
2014
+ refreshGridLayer();
2015
+ const dl=svg.querySelector(`line.gridln[data-g${drag.axis}="${drag.gi}"]`);if(dl)dl.classList.add('gdrag'); // highlight the line in flight
2016
+ return;}
1722
2017
  if(drag.type==='dimend'){const d=P.dims.find(x=>x.id===drag.id);if(d){let x=p.x,y=p.y;if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();if(drag.end===0)d.a=[x,y];else d.b=[x,y];render();}return;} // dragging a dim anchor → snap + re-measure live
1723
2018
  if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y],d.rot);render();}return;}
1724
2019
  if(drag.type==='draw'){let x=p.x,y=p.y;
@@ -1745,6 +2040,13 @@ svg.addEventListener('pointermove',e=>{
1745
2040
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
1746
2041
  m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
1747
2042
  svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
2043
+ if(drag.type==='gridline'){const wasBubble=drag.bubble,moved=drag.moved,pre=drag.pre;drag=null;gridReadoutHide();
2044
+ if(!moved){if(wasBubble){setGridMode(true); // opens the panel (clears the logical selection). NO render() — replacing the bubble between the two clicks of a dbl-click would swallow the rename gesture…
2045
+ svg.querySelectorAll('line.member.sel').forEach(n=>{n.classList.remove('sel');n.style.filter='';}); // …so drop the stale selection VISUALS surgically instead
2046
+ svg.querySelectorAll('circle.handle,circle.dimhandle,circle.numbg,text.numtx').forEach(n=>n.remove());
2047
+ svg.querySelectorAll('.dimsel').forEach(n=>n.classList.remove('dimsel'));}
2048
+ return;} // a no-move line-body click is a no-op (selection untouched)
2049
+ if(pre&&snapshot()!==pre)pushUndo(pre);render();return;}
1748
2050
  if(drag.type==='draw'){const a=[drag.x0,drag.y0],b=drag.cur||a;drag.line.remove();
1749
2051
  if(len(a,b)>6){const dx=Math.abs(b[0]-a[0]),dy=Math.abs(b[1]-a[1]),o=dx>3*dy?'H':(dy>3*dx?'V':'D');
1750
2052
  const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
@@ -1755,8 +2057,17 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1755
2057
  if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
1756
2058
  drag.rect.remove();drag=null;render();return;}
1757
2059
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
2060
+ svg.addEventListener('pointercancel',()=>{if(drag&&drag.type==='gridline'){drag=null;gridReadoutHide();snapClear();render();}}); // a cancelled pointer must not strand the floating readout
2061
+ svg.addEventListener('dblclick',e=>{ // dbl-click a grid bubble → rename that label in place
2062
+ const at=document.elementFromPoint(e.clientX,e.clientY); // the pointer capture the drag takes retargets dblclick to the svg root — resolve the bubble by position, not e.target
2063
+ const gb=at&&at.closest&&at.closest('g.gridbubg');
2064
+ if(gb&&mode==='sel'&&P&&P.grid&&!dimMode&&!csaxisMode&&!geoMode&&!picking){
2065
+ e.preventDefault();
2066
+ gridRenameBubble(gb.dataset.gv!=null?'v':'h',+(gb.dataset.gv!=null?gb.dataset.gv:gb.dataset.gh),gb.querySelector('circle'));}});
1758
2067
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
1759
- document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
2068
+ document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();
2069
+ if(view3d&&window.Steel3DView){const dm=document.getElementById('m3dDim');if(dm&&dm.classList.contains('on'))window.Steel3DView.toggleDimTool();} // the 3D dim tool shares the left-click — disarm it when entering add mode
2070
+ selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();else if(window.Steel3DView&&window.Steel3DView.drClear3d)window.Steel3DView.drClear3d();setMode();render();setLastCmd('Add member',()=>{if(mode!=='add'){if(dimMode){dimMode=false;setDimMode();}mode='add';selIds.clear();setMode();render();}});}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection; leaving add clears the 3D draw draft
1760
2071
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
1761
2072
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
1762
2073
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
@@ -1764,6 +2075,8 @@ function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.tex
1764
2075
  document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
1765
2076
  function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
1766
2077
  document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
2078
+ document.getElementById('gridEditB').onclick=()=>{setGridMode(!gridMode);render();};
2079
+ document.getElementById('gridToggleB').onclick=()=>gridSetVisible(!gridOn());
1767
2080
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
1768
2081
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
1769
2082
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
@@ -1794,11 +2107,16 @@ addEventListener('keydown',e=>{
1794
2107
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
1795
2108
  if(!view3d&&!inForm&&(((e.key==='z'||e.key==='Z')&&e.altKey)||(e.key===' '&&e.shiftKey))){e.preventDefault();zoomToSelection();return;} // 2D zoom-to-selected (Tekla Shift+Space / Alt+Z); 3D handles its own (steel-3d-view onKey)
1796
2109
  if((e.key===' '||e.key==='Enter')&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!e.shiftKey){if(!anyToolActive()&&lastCmd){e.preventDefault();repeatLast();}return;} // idle Space/Enter → repeat the last command (AutoCAD/Tekla)
1797
- if(e.key==='Escape'&&cmTool){if(cmHudIsOpen())cmHudClose();else if(cmDraft&&cmDraft.vA){cmDraft.vA=null;cmRefreshPrev();panel();}else if(cmDraft){cmDraft=null;cmPrevClear();panel();}else{disarmCm();render();}return;} // Move/Copy Esc ladder: HUD → array dir A → base pick exit tool
2110
+ if(e.key==='Escape'&&gridPick){gridPick=false;document.body.classList.remove('gridpick');snapClear();panel();return;} // Esc disarms pick-origin (grid mode stays)
2111
+ if(e.key==='Escape'&&gridMode&&!inForm){setGridMode(false);render();return;} // Esc exits the grid panel (a focused field handles its own Esc = revert)
2112
+ if(e.key==='Escape'&&cmTool){if(cmHudIsOpen())cmHudClose();
2113
+ else if(view3d&&window.Steel3DView&&window.Steel3DView.cmEscape&&window.Steel3DView.cmEscape()){} // 3D: step the pick back (array dir A → base) before exiting
2114
+ else if(cmDraft&&cmDraft.vA){cmDraft.vA=null;cmRefreshPrev();panel();}else if(cmDraft){cmDraft=null;cmPrevClear();panel();}else{disarmCm();render();}return;} // Move/Copy Esc ladder: HUD → array dir A → base pick → exit tool
1798
2115
  if(e.key==='Escape'&&csaxisMode){if(csDraft){csDraft=null;csPrevClear();}else{csaxisMode=false;setCsMode();}render();return;} // 1st Esc drops the in-progress origin, 2nd exits set-axes
1799
2116
  if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1800
2117
  if(e.key==='Escape'&&dimMode){if(dimChainPrev){dimChainPrev=null;dimPrevClear();}else if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // Esc: end the chain → drop the in-progress dim → exit the tool
1801
2118
  if(e.key==='Escape'&&selDimIds.size){if(dimSplitMode){dimSplitMode=false;snapClear();render();}else{selDimIds.clear();render();}return;} // Esc on a selected dim: end split mode → else deselect
2119
+ if(e.key==='Escape'&&view3d&&mode==='add'&&window.Steel3DView&&window.Steel3DView.drEscape&&window.Steel3DView.drEscape()){return;} // 3D draw: 1st Esc drops the in-progress start pick, 2nd exits add mode (falls through below)
1802
2120
  if(e.key==='Escape'){if(geoMode){geoMode=null;setGeo();render();return;}if(mode==='add'){mode='sel';setMode();}picking=false;pickKind='profile';pickEnd=null;selIds.clear();render();return;}
1803
2121
  if((e.key==='Delete'||e.key==='Backspace')&&view3d&&window.Steel3DView.selectedClips&&window.Steel3DView.selectedClips().length&&!selIds.size&&!sel3dDimIds.size&&!selDimIds.size&&!inForm){e.preventDefault();window.Steel3DView.deleteSelectedClips();return;} // 3D: Del removes the selected clip plane(s)/box(es) — only when nothing else is selected (clip selection is exclusive)
1804
2122
  if((e.key==='Delete'||e.key==='Backspace')&&view3d&&sel3dDimIds.size&&!selIds.size&&!inForm){e.preventDefault();[...sel3dDimIds].forEach(id=>view3dApi.onDeleteDim3d(id));return;} // 3D: Delete removes ALL selected 3D dims — only when no member is selected
@@ -1809,10 +2127,13 @@ addEventListener('keydown',e=>{
1809
2127
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1810
2128
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1811
2129
  if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
1812
- if(cmTool&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const ak=e.key.toLowerCase(); // Move/Copy armed: X/Y/F force the axis, typing opens the exact-value HUD
1813
- if(ak==='x'||ak==='y'||ak==='f'){e.preventDefault();cmAxis=ak==='f'?'free':ak;cmRefreshPrev();return;}
1814
- if(cmDraft&&/^[0-9.\-']$/.test(e.key)){e.preventDefault();cmHudShow(e.key);return;}}
1815
- if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!view3d&&!dimMode){const ck=e.key.toLowerCase(); // M / C — arm Move / Copy (mirrors D for dims; dim-armed keys win above)
2130
+ if(cmTool&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const ak=e.key.toLowerCase(); // Move/Copy armed: X/Y/F force the axis (plane axes in 3D), typing opens the exact-value HUD
2131
+ if(ak==='x'||ak==='y'||ak==='f'){e.preventDefault();
2132
+ if(view3d&&window.Steel3DView&&window.Steel3DView.setCmAxis)window.Steel3DView.setCmAxis(ak);
2133
+ else{cmAxis=ak==='f'?'free':ak;cmRefreshPrev();}return;}
2134
+ const hasBase=cmDraft||(view3d&&window.Steel3DView&&window.Steel3DView.cmHasBase&&window.Steel3DView.cmHasBase());
2135
+ if(hasBase&&/^[0-9.\-']$/.test(e.key)){e.preventDefault();cmHudShow(e.key);return;}}
2136
+ if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&!dimMode){const ck=e.key.toLowerCase(); // M / C — arm Move / Copy (2D and 3D; dim-armed keys win above)
1816
2137
  if(ck==='m'){e.preventDefault();armCm('move',false);return;}
1817
2138
  if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
1818
2139
  if(!inForm&&selDimIds.size>=1&&!selIds.size&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault();toggleDimSplit();return;} // S — toggle "add split point" on the selected dimension(s)
@@ -1875,6 +2196,7 @@ const view3dApi={
1875
2196
  onTrimExtend:(targetId)=>{const tm=byId(targetId);if(tm){geoMode=null;setGeo();snapEndMulti(selArr(),tm.wp[0],tm.wp[1]);}}, // 3D Trim/Extend → every selected member's nearest end to the target line
1876
2197
  onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
1877
2198
  getDims3d:()=>C.dims3d||[],
2199
+ getGrid:()=>(P&&P.grid)||null, // the ACTIVE plan's structural grid for the 3D render + plan-locked snapping (per-plan, display-space)
1878
2200
  getDimOverlays:()=>C.dim_overlays||{}, // the legend DIMENSIONS toggles {bolt_pitch,edge_clearance,cope_size} — the 3D view derives + filters overlays from this
1879
2201
  // dims3d is model-global: mutate + persist directly. NOT via edit() — a plan-scoped undo snapshot would corrupt dims3d across plans (undo on plan A could wipe dims added on plan B), and edit()'s sync3D would needlessly re-extrude every member mesh for a pure annotation. boot() guarantees C.dims3d is an array.
1880
2202
  onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
@@ -1890,6 +2212,31 @@ const view3dApi={
1890
2212
  selDim3d:()=>[...sel3dDimIds],
1891
2213
  newDim3dId:()=>'d3'+Date.now()+'_'+(dimSeq++), // same scheme as 2D dim ids (Date.now + dimSeq tie-breaker)
1892
2214
  fmtLen:(mm)=>fmtFtIn(mm/304.8*12),
2215
+ toast:(m)=>toast(m),
2216
+ // 3D Move/Copy: the 3D view reads the armed tool + counts, and hands commit offsets back in scene mm.
2217
+ cmState:()=>cmTool?{tool:cmTool,array:cmArrayMode,count:cmCount,countA:cmCountA,countB:cmCountB}:null,
2218
+ onTransform3d:(offsMm)=>{const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1),r6=(n)=>Math.round(n*1e6)/1e6; // mm → plan px (Y FLIPPED, matching dispToMm) + inches for dz; r6 kills float-noise before it persists into wp
2219
+ cmApplyOffsets(offsMm.map(o=>[r6(o[0]/k),r6(-o[1]/k),r6((o[2]||0)/25.4)]));},
2220
+ disarmTransform:()=>{if(cmTool){disarmCm();render();}}, // the 3D dim tool arming kicks Move/Copy off the left-click
2221
+ // 3D draw-on-plane (spec §7): the editor's add-member mode + profile drive it; two plane picks (scene mm) come back here.
2222
+ addModeActive:()=>mode==='add',
2223
+ disarmAdd:()=>{if(mode==='add'){mode='sel';setMode();render();}},
2224
+ onAddMember3d:(aMm,bMm)=>{const k=304.8/((P&&P.pt_per_ft>0)?P.pt_per_ft:1),r6=n=>Math.round(n*1e6)/1e6; // mm → wp px (Y FLIPPED) + elevation inches
2225
+ const wpA=[r6(aMm[0]/k),r6(-aMm[1]/k)],wpB=[r6(bMm[0]/k),r6(-bMm[1]/k)];
2226
+ const zA=r6(aMm[2]/25.4),zB=r6(bMm[2]/25.4);
2227
+ const hpx=Math.hypot(wpB[0]-wpA[0],wpB[1]-wpA[1]),hIn=hpx/FT*12,dzIn=Math.abs(zB-zA);
2228
+ const id='m'+Date.now();
2229
+ edit(()=>{let m;
2230
+ if(hIn<6&&dzIn>6){m=ensureMeta({id,profile:addProfile,wp:[wpA.slice(),wpA.slice()],role:'column'}); // near-vertical run → a column at the plan point
2231
+ m.col={bos:Math.min(zA,zB),tos:Math.max(zA,zB),note:'',tosDef:false,detail:''};}
2232
+ else{const near=z=>Math.abs(z-defaultTOS)<0.5; // a pick ON the level stays default-linked
2233
+ m=ensureMeta({id,profile:addProfile,wp:[wpA,wpB],role:'beam',angle:_mAngle([wpA,wpB])}); // classify H/V/D from the PLAN delta — same rule as _mAngle + the 2D add path (all three agree)
2234
+ const mfNote=/(^|[^A-Z])MF/i.test(addProfile||'')?'moment':''; // a moment-frame profile keeps the auto note ensureMeta gives 2D-drawn members
2235
+ m.ends=[{tos:near(zA)?null:zA,note:mfNote,tosDef:near(zA),detail:''},{tos:near(zB)?null:zB,note:mfNote,tosDef:near(zB),detail:''}];}
2236
+ m.rfi=(_wt(addProfile)==null);P.members.push(m);
2237
+ if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}
2238
+ selIds=new Set([id]);});
2239
+ toast('Added '+(hIn<6&&dzIn>6?'a column':'a beam')+' on the working plane');},
1893
2240
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
1894
2241
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
1895
2242
  onClipModeChange:(m)=>{const b=document.getElementById('m3dClip');if(b){b.classList.toggle('on',!!m);b.textContent=m?'Clip ✕':'Clip ▾';}}, // armed → button fills brand-blue + becomes a cancel target (✕ = cancel)
@@ -2082,6 +2429,18 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
2082
2429
  host.appendChild(buildCatHeader(ck,dc.label,dc.cats.length,{sub:true,getState:()=>dcState(dc.cats),onToggle:()=>dcToggle(dc.cats),toggleTitle:'Show / hide all '+dc.label.toLowerCase()+' dimensions'}));
2083
2430
  if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
2084
2431
  }
2432
+ // GRID — the structural grid's visibility (per-plan contract data; same undoable switch as the panel
2433
+ // checkbox and the ⋯ menu item). Only shown when this plan has a grid — slate outline swatch to match
2434
+ // the grid's colour, not the cyan dim overlays.
2435
+ if(typeof P!=='undefined'&&P&&P.grid){
2436
+ host.appendChild(Object.assign(document.createElement('div'),{className:'ldiv'}));
2437
+ const grow=document.createElement('div');grow.className='lrow dim';grow.title='Show / hide the structural grid (2D + 3D)';
2438
+ const gsw=document.createElement('span');gsw.className='lsw';gsw.style.borderColor='#64748b';
2439
+ grow.append(gsw,document.createTextNode('Grid lines'));
2440
+ grow.classList.toggle('dimoff',!gridOn());
2441
+ grow.addEventListener('click',()=>gridSetVisible(!gridOn()));
2442
+ host.appendChild(grow);
2443
+ }
2085
2444
  // CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
2086
2445
  // disable it (eye-style, like a hidden part); the × deletes. Empty state reads "(no clips)" so the section
2087
2446
  // looks available, not broken. Rebuilt by api.onClipsChange whenever a clip is added/removed/toggled.
@@ -2159,7 +2518,25 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
2159
2518
  function workMenuClose(){workMenu.classList.remove('open');document.removeEventListener('mousedown',workMenuOutside,true);}
2160
2519
  workBtn.onclick=e=>{e.stopPropagation();if(workMenu.classList.contains('open'))workMenuClose();else{updateWorkBtn();workMenu.classList.add('open');document.addEventListener('mousedown',workMenuOutside,true);}};
2161
2520
  workMenu.querySelectorAll('button[data-wa]').forEach(b=>b.onclick=()=>{workMenuClose();const a=b.dataset.wa;if(a==='all')d3.workAreaSetAll();else if(a==='sel'){if(!d3.workAreaFromSelection())d3.workAreaSetAll();}}); // define from selection; fall back to whole model if nothing's selected
2162
- document.getElementById('m3dWorkOn').onchange=e=>{d3.workAreaToggle(e.target.checked);};}
2521
+ document.getElementById('m3dWorkOn').onchange=e=>{d3.workAreaToggle(e.target.checked);};
2522
+ // Working plane: ◇ Plane menu (face pick / 3 points / principal+offset / show / reset). While a pick
2523
+ // mode is armed the button is a cancel target (reflectWpBar renders '✕' — same pattern as Clip).
2524
+ const wpBtn=document.getElementById('m3dWp'),wpMenu=document.getElementById('m3dWpMenu');
2525
+ function wpMenuOutside(e){if(!wpMenu.contains(e.target)&&e.target!==wpBtn)wpMenuClose();}
2526
+ function wpMenuClose(){wpMenu.classList.remove('open');document.removeEventListener('mousedown',wpMenuOutside,true);}
2527
+ wpBtn.onclick=e=>{e.stopPropagation();
2528
+ const info=d3.workPlaneInfo();
2529
+ if(info.mode){d3.armWorkPlanePick(info.mode);return;} // armed → cancel the pick
2530
+ if(wpMenu.classList.contains('open'))wpMenuClose();
2531
+ else{wpMenu.classList.add('open');document.addEventListener('mousedown',wpMenuOutside,true);}};
2532
+ wpMenu.querySelectorAll('button[data-wp]').forEach(b=>b.onclick=()=>{wpMenuClose();const a=b.dataset.wp;
2533
+ if(a==='reset')d3.clearWorkPlane();
2534
+ else{d3.armWorkPlanePick(a);setLastCmd('Set working plane',()=>d3.armWorkPlanePick(a));}});
2535
+ wpMenu.querySelectorAll('button[data-wpp]').forEach(b=>b.onclick=()=>{wpMenuClose();
2536
+ const offIn=parseLen(document.getElementById('m3dWpOff').value)||0; // ft-in → inches → mm along the normal
2537
+ d3.setWorkPlanePrincipal(b.dataset.wpp,offIn*25.4);});
2538
+ document.getElementById('m3dWpShow').onchange=e=>{d3.toggleWorkPlaneVisible(e.target.checked);};
2539
+ document.getElementById('m3dWpOff').addEventListener('keydown',ev=>{ev.stopPropagation();if(ev.key==='Enter'){ev.preventDefault();const xy=wpMenu.querySelector('button[data-wpp=xy]');if(xy)xy.click();}});} // Enter in the offset = apply XY (the common case); stopPropagation keeps editor shortcuts out
2163
2540
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
2164
2541
  view3d=on;
2165
2542
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
@@ -2177,7 +2554,7 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
2177
2554
  async function setView(on){
2178
2555
  if(on){
2179
2556
  if(dimMode||dimChain||dimSplitMode){dimMode=dimChain=dimSplitMode=false;selDimIds.clear();setDimMode();document.body.classList.remove('dimsplit');} // entering 3D disarms the 2D dim/chain/split tools so their X/Y/F/S/Esc keys don't double-fire with the 3D tool
2180
- if(cmTool){disarmCm();render();} // Move/Copy is 2D-only until the working-plane slicedisarm so its keys don't shadow the 3D tools
2557
+ if(cmTool){cmDraft=null;cmPrevClear();cmHudClose();} // Move/Copy stays armed across the view switchonly the in-progress 2D pick is dropped (it's in plan px)
2181
2558
  if(selIds.size)sel3dDimIds.clear(); // a member selected in 2D wins over a stale 3D-dim selection
2182
2559
  if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
2183
2560
  try{
@@ -2192,7 +2569,8 @@ async function setView(on){
2192
2569
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
2193
2570
  toast('Could not open 3D view: '+((e&&e.message)||e));
2194
2571
  }
2195
- }else{applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();}
2572
+ }else{if(cmTool&&window.Steel3DView&&window.Steel3DView.cmClear3d)window.Steel3DView.cmClear3d(); // 3D→2D: drop the mm-space pick the same way (tool stays armed)
2573
+ applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();}
2196
2574
  }
2197
2575
  document.getElementById('vt2d').onclick=()=>{if(view3d)setView(false);};
2198
2576
  document.getElementById('vt3d').onclick=()=>{if(!view3d)setView(true);};
@@ -2648,6 +3026,9 @@ function setPlan(i){C.active=i;P=C.plans[i];
2648
3026
  if(!Array.isArray(P.segments))P.segments=[];
2649
3027
  if(!Array.isArray(P.dims))P.dims=[];
2650
3028
  sanitizeFrame(P); // tolerate a hand-edited/AI-written frame: normalize or drop to global
3029
+ // grid: drop a malformed hand-edited/AI-written grid at the load boundary (like sanitizeFrame) —
3030
+ // the renderers assume a finite [x,y] origin; the spacing strings report their own errors inline.
3031
+ if(P.grid!=null&&(typeof P.grid!=='object'||Array.isArray(P.grid)||!Array.isArray(P.grid.origin)||P.grid.origin.length<2||!isFinite(P.grid.origin[0])||!isFinite(P.grid.origin[1]))){console.warn('discarding malformed grid on '+P.sheet);P.grid=null;}
2651
3032
  // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
2652
3033
  // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
2653
3034
  // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
@@ -2671,6 +3052,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
2671
3052
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
2672
3053
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
2673
3054
  dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
3055
+ if(gridMode||gridPick){gridMode=false;gridPick=false;document.body.classList.remove('gridpick');} // grid panel/pick-origin disarm per plan like the other takeover tools (a leaked pick would set the origin in the WRONG sheet's display space)
2674
3056
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
2675
3057
  disarmCm(); // Move/Copy resets per plan too (its counts persist — they're session prefs, not plan data)
2676
3058
  defaultTOS=(P.default_tos!=null?P.default_tos:198);