@floless/app 0.61.0 → 0.63.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.
@@ -129,10 +129,36 @@
129
129
  #comboPop .opt{padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);white-space:nowrap}
130
130
  #comboPop .opt:hover,#comboPop .opt.active{background:#334155}
131
131
  /* "More" overflow menu — themed popup (reuses the #comboPop look); flat rows keep each button's id + handler. */
132
- #moreWrap{position:relative;display:inline-flex}
132
+ /* Move/Copy split buttons + transform-suite chrome — all within the locked baseline tokens. */
133
+ .cmwrap{position:relative;display:inline-flex}
134
+ .cmwrap>button:first-child{border-radius:6px 0 0 6px}
135
+ .cmwrap .cmcaret{border-radius:0 6px 6px 0;border-left:0;padding:0 5px;min-width:22px;color:var(--mut)}
136
+ .cmwrap>button.on,.cmwrap .cmcaret.on{background:var(--brand);border-color:var(--brand);color:#fff}
137
+ .cmmenu{display:none;position:absolute;left:0;top:calc(100% + 6px);min-width:200px;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}
138
+ .cmmenu.open{display:block}
139
+ .cmmenu button{display:flex;justify-content:space-between;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-radius:0;padding:7px 12px;color:var(--text);white-space:nowrap}
140
+ .cmmenu button:hover{background:#334155}
141
+ .cmmenu .mkbd{color:var(--mut);font-size:11px}
142
+ .cmghost{stroke-dasharray:6 4;opacity:.4;pointer-events:none;vector-effect:non-scaling-stroke;stroke-width:3}
143
+ .cmghostbox{fill:none;stroke:#22d3ee;stroke-dasharray:4 4;opacity:.5;pointer-events:none;vector-effect:non-scaling-stroke}
144
+ .cmrub{stroke:#22d3ee;stroke-width:1.5;stroke-dasharray:5 4;pointer-events:none;vector-effect:non-scaling-stroke}
145
+ .cmarrow{fill:#22d3ee;pointer-events:none}
146
+ .cmchip{fill:var(--panel);stroke:#22d3ee;opacity:.85;pointer-events:none} /* .85: reads "in progress", full opacity stays reserved for committed dims */
147
+ .cmtx{fill:var(--text);text-anchor:middle;dominant-baseline:central;opacity:.85;pointer-events:none;font-family:system-ui}
148
+ #cmHud{position:fixed;z-index:70;display:none;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--brand);border-radius:8px;padding:6px 8px;box-shadow:0 6px 20px rgba(0,0,0,.55);font:12px system-ui;color:var(--mut)}
149
+ #cmHud.err{border-color:#fca5a5}
150
+ #cmHud input{width:110px;height:24px;background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:5px;padding:0 7px;font:12px system-ui}
151
+ #cmHud input:focus{outline:none;border-color:var(--brand)}
152
+ #cmHud.err input{border-color:#fca5a5}
153
+ #lvModal .lvrow{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid transparent;border-radius:7px;cursor:pointer}
154
+ #lvModal .lvrow:hover{background:var(--line)}
155
+ #lvModal .lvrow.pick{background:var(--line);border-color:var(--brand)}
156
+ #lvModal .lvrow.cur{opacity:.45;cursor:default}
157
+ #lvModal .lvrow .lvtos{margin-left:auto;color:var(--mut);font-variant-numeric:tabular-nums;font-size:12px;white-space:nowrap}
158
+ #moreWrap{position:relative;display:inline-flex}
133
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}
134
160
  #moreMenu.open{display:block}
135
- #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}
136
162
  #moreMenu hr{border:0;border-top:1px solid var(--line);margin:4px 0}
137
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}
138
164
  #moreMenu button:hover{background:#334155}
@@ -165,6 +191,22 @@
165
191
  text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
166
192
  line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
167
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 */
168
210
  body.dimsplit line.member,body.dimsplit line.seg,body.dimsplit line.dim,body.dimsplit line.dimwit,body.dimsplit image{cursor:crosshair}
169
211
  .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
170
212
  body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
@@ -205,6 +247,10 @@
205
247
  .m3dmenu label{display:flex;align-items:center;gap:7px;padding:7px 12px;color:var(--text);font-size:12px;cursor:pointer;white-space:nowrap}
206
248
  .m3dmenu label:hover{background:#334155}
207
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)}
208
254
  /* thin cluster divider in the 3D toolbar */
209
255
  #m3dBar .tb-sep{width:1px;height:18px;background:var(--line);flex:0 0 auto;align-self:center}
210
256
  /* Themed tooltip — replaces native title= so no OS-default tooltip leaks the dark theme. */
@@ -252,10 +298,16 @@
252
298
  #m3dLegend .lrow.flash{background:rgba(59,130,246,.12)}
253
299
  .leg-drag-ghost{position:fixed;pointer-events:none;z-index:70;background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:3px 8px;display:flex;align-items:center;gap:7px;font:12px system-ui;color:var(--text);width:200px;opacity:.88;box-shadow:0 4px 16px rgba(0,0,0,.6)}
254
300
  #m3dLegend .ldiv{height:1px;background:var(--line);margin:5px 2px}
255
- #m3dCube{position:absolute;right:12px;bottom:12px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
301
+ #m3dCube{position:absolute;right:12px;top:56px;width:84px;height:84px;display:none;z-index:6;cursor:pointer;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))} /* top-right (Revit-style), below the toolbar row */
302
+ /* Tekla-style world-axis triad, bottom-right (where the cube used to sit). Passive readout
303
+ (pointer-events:none) — orientation is the ViewCube's job; this only SHOWS where world X/Y/Z point. */
304
+ #m3dAxes{position:absolute;right:15px;bottom:12px;width:78px;height:78px;display:none;z-index:5;pointer-events:none;filter:drop-shadow(0 6px 14px rgba(0,0,0,.5))}
256
305
  </style>
257
306
  <script type="importmap">{"imports":{"three":"./vendor/three.module.js","three/addons/":"./vendor/","three-bvh-csg":"./vendor/three-bvh-csg.module.js"}}</script>
258
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>
259
311
  </head><body>
260
312
  <header>
261
313
  <b>Steel Model</b>
@@ -272,6 +324,21 @@
272
324
  <button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
273
325
  <button id=mAdd title="Toggle add-member mode">Add member</button>
274
326
  <button id=dimB title="Dimension tool (D) — click two snapped points, then a third to place. Default Free (aligned); hold Shift to lock to an axis, X/Y force horizontal/vertical, F free.">⊢ Dimension</button>
327
+ <div class=cmwrap>
328
+ <button id=mvB title="Move (M) — pick a base point, then a destination. Type a distance or dx,dy after the first pick for an exact move.">↔ Move</button><button id=mvCaret class=cmcaret aria-haspopup=menu aria-expanded=false aria-label="Move options">▾</button>
329
+ <div class=cmmenu id=mvMenu role=menu>
330
+ <button id=mvTwoB>Move — two points <span class=mkbd>M</span></button>
331
+ <button id=mvLevelB>Move to level…</button>
332
+ </div>
333
+ </div>
334
+ <div class=cmwrap>
335
+ <button id=cpB title="Copy (C) — pick a base point, then a destination. Set ×N in the panel for a linear row; type a value for an exact offset.">⧉ Copy</button><button id=cpCaret class=cmcaret aria-haspopup=menu aria-expanded=false aria-label="Copy options">▾</button>
336
+ <div class=cmmenu id=cpMenu role=menu>
337
+ <button id=cpTwoB>Copy — two points <span class=mkbd>C</span></button>
338
+ <button id=cpArrB>Copy array…</button>
339
+ <button id=cpLevelB>Copy to level…</button>
340
+ </div>
341
+ </div>
275
342
  <button id=askAiBtn>Ask AI ▸</button>
276
343
  <div id=moreWrap>
277
344
  <button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
@@ -280,6 +347,10 @@
280
347
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
281
348
  <button id=calloutToggleB title="Show or hide the clickable callout bubbles (section / elevation / detail references) on the plan">Hide callouts</button>
282
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>
283
354
  <div class=mlabel>Coordinate system</div>
284
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>
285
356
  <button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
@@ -333,6 +404,22 @@
333
404
  <button data-clip=clear class=mdanger data-tip="Remove every clip">Clear all clips</button>
334
405
  </div>
335
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>
336
423
  <div class=m3dwrap>
337
424
  <button id=m3dWork data-tip="Working area — bound the view to a box (Tekla-style)">Work area ▾</button>
338
425
  <div id=m3dWorkMenu class=m3dmenu role=menu>
@@ -345,6 +432,7 @@
345
432
  </div>
346
433
  <div id=m3dLegend></div>
347
434
  <div id=m3dCube data-tip="Click a face for that view · right-drag to orbit"></div>
435
+ <div id=m3dAxes></div>
348
436
  <div id=zoombar>
349
437
  <button id=zOut title="Zoom out">−</button>
350
438
  <input id=zRange type=range min=10 max=400 step=1 value=100>
@@ -535,6 +623,7 @@ let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
535
623
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
536
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
537
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
538
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).
539
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
540
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
@@ -582,6 +671,31 @@ function swapMemberEnds(m){
582
671
  if(Array.isArray(m.ends)&&m.ends.length>=2)m.ends=[m.ends[1],m.ends[0]];
583
672
  if(m.col){const b=(m.col.bos!=null?m.col.bos:0),t=(m.col.tos!=null?m.col.tos:defaultTOS);m.col.bos=t;m.col.tos=b;m.col.tosDef=false;}
584
673
  return m;}
674
+ // --- Move/Copy transform core (spec §4.1): offsets are [dx,dy,dzIn] — dx/dy plan px, dz inches. ---
675
+ let cmSeq=0;
676
+ function cloneMember(m){const c=JSON.parse(JSON.stringify(m));c.id='m'+Date.now()+'_c'+(cmSeq++);return c;}
677
+ // Rigid translate: wp shift; dz materializes the level default first then shifts both ends together
678
+ // (same contract as the 3D vertical-drag path) — a slope is preserved, tosDef flips off only when dz≠0.
679
+ function translateMembers(ms,off){const dz=off[2]||0;for(const m of ms){ensureMeta(m);
680
+ m.wp[0]=[m.wp[0][0]+off[0],m.wp[0][1]+off[1]];m.wp[1]=[m.wp[1][0]+off[0],m.wp[1][1]+off[1]];
681
+ if(dz){if(m.role==='column'){m.col.tos=(m.col.tos!=null?m.col.tos:defaultTOS)+dz;m.col.bos=(m.col.bos!=null?m.col.bos:0)+dz;m.col.tosDef=false;}
682
+ else m.ends.forEach(en=>{en.tos=(en.tos!=null?en.tos:defaultTOS)+dz;en.tosDef=false;});}}}
683
+ function linearOffsets(v,n){const o=[];for(let k=1;k<=n;k++)o.push([v[0]*k,v[1]*k,(v[2]||0)*k]);return o;}
684
+ // Rectangular array: counts INCLUDE the original position; (0,0) is skipped so the grid adds n*m-1 copies.
685
+ function arrayOffsets(vA,nA,vB,nB){const o=[];for(let i=0;i<nA;i++)for(let j=0;j<nB;j++){if(!i&&!j)continue;
686
+ o.push([vA[0]*i+vB[0]*j,vA[1]*i+vB[1]*j,(vA[2]||0)*i+(vB[2]||0)*j]);}return o;}
687
+ // HUD input → {dist} (inches, along the live direction) or {comp:[dx,dy,dz] inches}. null = unparseable.
688
+ function parseVec(s){s=String(s==null?'':s).trim();if(!s)return null;
689
+ if(s.includes(',')){const p=s.split(',').map(t=>t.trim()===''?0:parseLen(t));if(p.some(x=>x==null)||p.length>3)return null;
690
+ return {comp:[p[0]||0,p[1]||0,p[2]||0]};}
691
+ const d=parseLen(s);return d==null?null:{dist:d};}
692
+ // To-level TOS mapping (spec §4.5): default-follow ends land on the target level automatically;
693
+ // explicit overrides shift by Δ so relative offsets survive. Columns keep their height (bos shifts by Δ).
694
+ function retargetTos(m,srcDef,dstDef){const d=dstDef-srcDef;ensureMeta(m);
695
+ if(m.role==='column'){if(m.col.tosDef!==false)m.col.tos=dstDef;else if(m.col.tos!=null)m.col.tos+=d;
696
+ if(m.col.bos!=null)m.col.bos+=d;}
697
+ else for(const en of m.ends){if(en.tosDef!==false)en.tos=dstDef;else if(en.tos!=null)en.tos+=d;}
698
+ return m;}
585
699
  // best-effort: framing plans carry TOS at the level UNO — assume the L2 datum +16'-6" (198"); each end's
586
700
  // 'default' checkbox links it to this value (auto-updates when changed); uncheck to override.
587
701
  let defaultTOS=198, addProfile='';
@@ -597,23 +711,52 @@ const autofillTOS=syncDefaults;
597
711
  // live on one foundation sheet; the reader's ids are unique within the set that gets detailed). The one
598
712
  // unsupported edge is two DIFFERENT sheets each detailing a column that happens to share an id — then the
599
713
  // slice would over-match. Tracked as a reader-side id-uniqueness hardening. dims3d rides its own persistence.
600
- 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
715
+ // Cross-plan undo (spec §4.5): a commit that also touches ANOTHER plan captures that plan's member
716
+ // slice too; undo/redo restore it by sheet. The entry lives on the CURRENT plan's stack — undoing
717
+ // from the plan where the command ran is the supported path (same per-plan-stack rule as everything).
718
+ function snapshotWith(T){const base=JSON.parse(snapshot());base.others=[{sheet:T.sheet,members:T.members}];return JSON.stringify(base);}
719
+ // Mirror an undo entry's coverage when building the opposite stack's entry — a plain snapshot()
720
+ // would silently drop the other plan's slice and redo would corrupt it.
721
+ function snapshotLike(json){let d=null;try{d=JSON.parse(json);}catch(_){console.error('undo: unparsable stack entry — cross-plan coverage may be reduced');}
722
+ if(!d||!Array.isArray(d.others))return snapshot();
723
+ const base=JSON.parse(snapshot());
724
+ base.others=d.others.map(o=>{const tp=C.plans.find(p=>p.sheet===o.sheet);return {sheet:o.sheet,members:tp?tp.members:[]};});
725
+ return JSON.stringify(base);}
726
+ // The mirror entry for the TARGET plan's own stack: shaped as if T were current (so a later undo
727
+ // FROM T applies cleanly), with an others-slice pointing back at the source — undoing the arrival
728
+ // from the target side restores the source plan too (a moved member is never stranded/lost).
729
+ // extraIds = ids MOVING onto T (a move keeps ids, so their joints must ride this slice — else the
730
+ // apply-from-T own-filter drops them and the member returns to the source stripped of connections).
731
+ // Per-plan stacks are not cross-invalidated: if the op was already undone from the other side, this
732
+ // mirror degrades to a benign one-step no-op undo (never data loss).
733
+ function snapshotForPlan(T,src,extraIds){const tm=new Set([...(T.members||[]).map(m=>m.id),...(extraIds||[])]);
734
+ return JSON.stringify({members:T.members||[],dims:T.dims||[],frame:T.frame||null,
735
+ joints:(C.joints||[]).filter(j=>j&&tm.has(j.main)),
736
+ clips:(window.Steel3DView&&window.Steel3DView.clipState)?window.Steel3DView.clipState():null,
737
+ others:[{sheet:src.sheet,members:src.members}]});}
738
+ function pushUndoFor(T,entry){(T.undo=T.undo||[]).push(entry);if(T.undo.length>200)T.undo.shift();(T.redo=T.redo||[]).length=0;}
601
739
  function refreshDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshDims)window.Steel3DView.refreshDims();} // repaint the 3D dim lines/labels from C.dims3d
602
740
  function refreshOverlayDims3d(){if(window.Steel3DView&&window.Steel3DView.refreshOverlayDims)window.Steel3DView.refreshOverlayDims();} // re-derive the legend DIMENSIONS overlays from C.dim_overlays
603
741
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
604
742
  function apply(json){const d=JSON.parse(json);
605
743
  if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
606
- 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;
607
- 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||[]);}} // 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.
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;
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||[]);}
746
+ if(Array.isArray(d.others))for(const o of d.others){const tp=C.plans.find(p=>p.sheet===o.sheet);
747
+ if(tp&&tp!==P)tp.members=(o.members||[]).map(ensureMeta);
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.
608
749
  updCS();
609
750
  selIds=new Set([...selIds].filter(id=>byId(id)));
610
751
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
611
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
612
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)
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
613
756
  if(window.Steel3DView&&window.Steel3DView.setClipState&&'clips' in d)window.Steel3DView.setClipState(d.clips); // restore clips + work area alongside the contract
614
757
  scheduleSave();render();sync3D();}
615
- function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
616
- function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
758
+ function doUndo(){if(!undo.length)return;redo.push(snapshotLike(undo[undo.length-1]));apply(undo.pop());}
759
+ function doRedo(){if(!redo.length)return;undo.push(snapshotLike(redo[redo.length-1]));apply(redo.pop());}
617
760
  function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
618
761
  // --- auto-save: edits persist to this browser (localStorage). Raster stays embedded; only members + TOS are stored. ---
619
762
  const LSKEY = 'steeltakeoff:edits:v1:' + APP_ID;
@@ -626,7 +769,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
626
769
  else el.textContent=msg||'Auto-save on';}
627
770
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
628
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,
629
- 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);}}
630
773
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
631
774
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
632
775
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
@@ -655,7 +798,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
655
798
  if(d.custom_details)C.custom_details=d.custom_details;
656
799
  if(d.profile_colors)C.profile_colors=d.profile_colors;
657
800
  if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
658
- 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;}});
659
802
  if(Array.isArray(d.dims3d))C.dims3d=d.dims3d; // restore model-global 3D dims from the local draft
660
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)
661
804
  if(Array.isArray(d.joints))C.joints=d.joints; // restore model-global connection joints (base plates) from the local draft
@@ -749,12 +892,201 @@ function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
749
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)
750
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}"/>`;
751
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
+ }
752
1084
  function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
753
1085
  // --- repeat last command (AutoCAD/Tekla): when NO tool is active, Space/Enter re-invokes the last command ---
754
1086
  let lastCmd=null;
755
1087
  function setLastCmd(label,run){lastCmd={label,run};}
756
1088
  function anyToolActive(){
757
- if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||mode==='add'||picking)return true; // 2D tools armed
1089
+ if(dimMode||dimChain||dimSplitMode||geoMode||csaxisMode||gridMode||gridPick||mode==='add'||picking||cmTool)return true; // 2D tools armed (cmTool = Move/Copy)
758
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
759
1091
  return false;
760
1092
  }
@@ -860,6 +1192,7 @@ function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'&lt;','>':'&gt;','&
860
1192
  function render(){
861
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
862
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)
863
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]}"/>`;
864
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})"`:'';
865
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}/>`;}
@@ -893,7 +1226,7 @@ function render(){
893
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>`;});}}
894
1227
  s+=renderDims();
895
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)
896
- 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();
897
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)
898
1231
  }
899
1232
  function updDup(){const n=redundantDups().length;
@@ -908,6 +1241,8 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
908
1241
  svg.querySelectorAll('circle.handle,circle.dimhandle').forEach(h=>h.setAttribute('r',epR()));
909
1242
  const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
910
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');
911
1246
  svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
912
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);});
913
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)
@@ -926,9 +1261,20 @@ function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for
926
1261
  function panel(){
927
1262
  const p=document.getElementById('panel');
928
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;}
929
1265
  if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
930
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>
931
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;}
1268
+ if(cmTool){const nsel=selIds.size;
1269
+ p.innerHTML=`<span class=badge>${cmTool==='move'?'Move':(cmArrayMode?'Copy array':'Copy')}</span>
1270
+ <div class=hint id=cmHint style="margin-top:8px">${esc(cmHintText())}</div>
1271
+ ${cmTool==='copy'&&!cmArrayMode?`<div class=sect style="margin-top:12px">Copies</div><input id=cmN inputmode=numeric value="${cmCount}" autocomplete=off><div class=hint style="margin-top:4px">×N places a row — each copy one step further along the pick (linear).</div>`:''}
1272
+ ${cmArrayMode?`<div class=sect style="margin-top:12px">Array counts</div><div class=elab>Direction A</div><input id=cmNA inputmode=numeric value="${cmCountA}" autocomplete=off><div class=elab>Direction B</div><input id=cmNB inputmode=numeric value="${cmCountB}" autocomplete=off><div class=hint id=cmCapHint style="margin-top:4px">Counts include the original — 4 × 6 fills a 4-by-6 grid (max 100).</div>`:''}
1273
+ <div class=hint style="margin-top:8px">${nsel} member${nsel===1?'':'s'} selected. <b>Shift</b>=straight · <b>X/Y/F</b> axis · <b>Alt</b>=no snap · type after the first pick for an exact value · <b>Esc</b> cancels. Connections don't copy.</div>`;
1274
+ const wireN=(id,set)=>{const i=document.getElementById(id);if(i)i.onchange=e=>{const n=Math.max(1,Math.min(99,Math.round(Number(e.target.value)||1)));set(n);e.target.value=n;
1275
+ const ch=document.getElementById('cmCapHint');if(ch)ch.style.color=(cmArrayMode&&cmCountA*cmCountB>100)?'#fca5a5':'';cmRefreshPrev();};};
1276
+ wireN('cmN',n=>cmCount=n);wireN('cmNA',n=>cmCountA=n);wireN('cmNB',n=>cmCountB=n);
1277
+ return;}
932
1278
  if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
933
1279
  <div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
934
1280
  <div class=sect style="margin-top:12px">Direction</div>
@@ -956,7 +1302,7 @@ function panel(){
956
1302
  <div class=hint id=pickHint style="margin-top:4px;font-style:italic">or click a label in the drawing</div>
957
1303
  <div class=divrow><hr></div>
958
1304
  <div class=sect>Draw a member</div>
959
- <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>`;
960
1306
  const ap=document.getElementById('addProf');
961
1307
  ap.oninput=ap.onchange=e=>{addProfile=e.target.value.toUpperCase().replace(/ /g,'');};
962
1308
  return;}
@@ -1237,12 +1583,14 @@ function rectHit(p0,p1,r){
1237
1583
  // --- snap to existing member/segment/dimension endpoints + MIDPOINTS (△); members lie on grids → snaps to grid intersections; Alt = off ---
1238
1584
  const SNAP_PX=9; let snapPts=[], snapSegs=[], snapMids=[], lastSnapKind='end'; // lastSnapKind drives the marker glyph: 'mid' → triangle, else circle
1239
1585
  const _mid=(a,b)=>[(a[0]+b[0])/2,(a[1]+b[1])/2];
1240
- 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
1241
1587
  snapPts=[];snapSegs=[];snapMids=[];
1242
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]));}
1243
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));}
1244
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)
1245
- 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);}}}
1246
1594
  // nearest point lying ON another member/segment LINE (perpendicular foot, clamped to the segment) within tol
1247
1595
  function nearestOnLine(x,y,tol){let bp=null,bd=tol;
1248
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;}}
@@ -1268,7 +1616,7 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
1268
1616
  // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
1269
1617
  const SVGNS='http://www.w3.org/2000/svg';
1270
1618
  function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
1271
- if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes) + ensures dims are visible — covers the D-key path too, not just the button
1619
+ if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes/move-copy) + ensures dims are visible — covers the D-key path too, not just the button
1272
1620
  dimDraft=null;dimChainPrev=null;dimPrevClear();}
1273
1621
  function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
1274
1622
  if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;return {x,y,raw:[q.x,q.y],hit:sn.hit};}
@@ -1341,11 +1689,187 @@ function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
1341
1689
  g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis,rot),rot},{preview:true});return;}
1342
1690
  const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr,rot);dimDraft._ax=dimDraft.axis; // stage 2: keep the offset the pointer last set; sync _ax so the 3rd-click commit uses the axis now on screen
1343
1691
  g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off,rot},{preview:true});}
1692
+ // --- Move/Copy engine (spec §3): one pick→preview→commit pipeline drives two-point, linear ×N and
1693
+ // array N×M. Same shell as the Dimension tool: armed flag routes the pointer handlers, the preview
1694
+ // lives in its own <g> (no render() until commit), commits go through edit()/pushUndo. ---
1695
+ let cmTool=null,cmArrayMode=false,cmDraft=null,cmAxis='free',cmCount=1,cmCountA=2,cmCountB=2,cmLastPtr=null,cmLastEvt=null;
1696
+ // cmDraft: {base:[x,y], vA:null} — array mode sets vA=[dx,dy,0] once direction A is picked/typed
1697
+ function setCmUi(){document.body.classList.toggle('cmon',!!cmTool);
1698
+ document.getElementById('mvB').classList.toggle('on',cmTool==='move');
1699
+ document.getElementById('mvCaret').classList.toggle('on',cmTool==='move');
1700
+ document.getElementById('cpB').classList.toggle('on',cmTool==='copy');
1701
+ document.getElementById('cpCaret').classList.toggle('on',cmTool==='copy');}
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 off — works in 2D and (on the working plane) in 3D
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
1707
+ if(!selIds.size){toast('Select members first, then '+(tool==='move'?'Move':'Copy'));return;}
1708
+ if(mode==='add'){mode='sel';setMode();if(window.Steel3DView&&window.Steel3DView.drClear3d)window.Steel3DView.drClear3d();}
1709
+ if(dimMode){dimMode=false;setDimMode();}
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
1712
+ geoMode=null;setGeo();selDimIds.clear();dimSplitMode=false;picking=false;pickEnd=null;
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)
1715
+ cmTool=tool;cmArrayMode=!!array&&tool==='copy';cmDraft=null;cmAxis='free';setCmUi();render();
1716
+ setLastCmd(tool==='move'?'Move':(cmArrayMode?'Copy array':'Copy'),()=>{if(!cmTool)armCm(tool,array);});}
1717
+ function cmHintText(){if(!cmDraft)return (cmTool==='move'?'Move':'Copy')+' — click the base point (a snapped end, grid or line point).';
1718
+ if(cmArrayMode&&!cmDraft.vA)return 'Click the direction-A point — or type its spacing.';
1719
+ if(cmArrayMode)return 'Click the direction-B point (grid previews live) — or type its spacing.';
1720
+ return 'Click the destination — or just start typing an exact distance / dx,dy. The tool stays armed; Esc finishes.';}
1721
+ // picks: snap (Alt off) → Shift ortho from the base → X/Y axis force projects onto the (local-frame) axis
1722
+ function cmSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y,hit=false;
1723
+ if(e.shiftKey&&cmDraft){[x,y]=orthoLock(cmDraft.base[0],cmDraft.base[1],x,y);snapClear();}
1724
+ else if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;hit=sn.hit;}
1725
+ if(cmDraft&&cmAxis!=='free'){const b=cmDraft.base,u=P.frame?P.frame.u:[1,0],v=[u[1],-u[0]],dx=x-b[0],dy=y-b[1];
1726
+ if(cmAxis==='x'){const du=dx*u[0]+dy*u[1];x=b[0]+du*u[0];y=b[1]+du*u[1];}
1727
+ else{const dv=dx*v[0]+dy*v[1];x=b[0]+dv*v[0];y=b[1]+dv*v[1];}hit=false;}
1728
+ return {x,y,hit};}
1729
+ function cmOffsetsFor(v){if(cmArrayMode)return cmDraft&&cmDraft.vA?arrayOffsets(cmDraft.vA,cmCountA,v,cmCountB):linearOffsets(v,Math.max(0,cmCountA-1)); // stage A previews countA-1 NEW copies — counts include the original, so the ghost count matches the commit
1730
+ return cmTool==='copy'?linearOffsets(v,cmCount):[v];}
1731
+ function cmSelBbox(){let x0=1e15,y0=1e15,x1=-1e15,y1=-1e15;for(const m of selArr())for(const p of m.wp){x0=Math.min(x0,p[0]);y0=Math.min(y0,p[1]);x1=Math.max(x1,p[0]);y1=Math.max(y1,p[1]);}return [x0,y0,x1,y1];}
1732
+ function cmPrevClear(){const g=document.getElementById('cmPrevG');if(g)g.remove();snapClear();}
1733
+ function cmPrev(e){if(!cmTool)return;cmLastEvt=e;
1734
+ {const h=document.getElementById('cmHint');if(h)h.textContent=cmHintText();}
1735
+ let g=document.getElementById('cmPrevG');
1736
+ const s=cmSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear();
1737
+ if(!cmDraft){if(g)g.remove();return;} // before the base pick: marker only
1738
+ cmLastPtr=[s.x,s.y];
1739
+ if(!g){g=document.createElementNS(SVGNS,'g');g.id='cmPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
1740
+ g.innerHTML=cmPrevSvg([s.x-cmDraft.base[0],s.y-cmDraft.base[1],0],[s.x,s.y]);}
1741
+ function cmRefreshPrev(){if(cmTool&&cmDraft&&cmLastEvt)cmPrev(cmLastEvt);}
1742
+ // ghosts: every selected member at every offset (cap 600 lines → per-offset bbox), then the rubber
1743
+ // line + arrowhead + ft-in chip. Array stage B keeps direction A's rubber dimmed for context.
1744
+ function cmPrevSvg(v,cur){const sel=selArr(),offs=cmOffsetsFor(v);let s='';
1745
+ const many=sel.length*offs.length>600,bb=many?cmSelBbox():null;
1746
+ for(const off of offs){
1747
+ if(many){s+=`<rect class=cmghostbox x="${bb[0]+off[0]}" y="${bb[1]+off[1]}" width="${bb[2]-bb[0]}" height="${bb[3]-bb[1]}"/>`;continue;}
1748
+ for(const m of sel)s+=`<line class=cmghost stroke="${esc(colorFor(m.profile))}" x1="${+m.wp[0][0]+off[0]}" y1="${+m.wp[0][1]+off[1]}" x2="${+m.wp[1][0]+off[0]}" y2="${+m.wp[1][1]+off[1]}"/>`;} // + coerces: a malformed contract string can't reach the attribute
1749
+ const b=cmDraft.base;
1750
+ if(cmArrayMode&&cmDraft.vA)s+=cmRubSvg(b,[b[0]+cmDraft.vA[0],b[1]+cmDraft.vA[1]],true);
1751
+ s+=cmRubSvg(b,cur,false);
1752
+ return s;}
1753
+ function cmRubSvg(a,c,dimmed){const L=Math.hypot(c[0]-a[0],c[1]-a[1]);if(L<1e-6)return '';
1754
+ const ux=(c[0]-a[0])/L,uy=(c[1]-a[1])/L,ah=10/zoom;
1755
+ const p1=[c[0]-ah*ux+ah*.45*uy,c[1]-ah*uy-ah*.45*ux],p2=[c[0]-ah*ux-ah*.45*uy,c[1]-ah*uy+ah*.45*ux];
1756
+ const mid=[(a[0]+c[0])/2,(a[1]+c[1])/2],txt=esc(fmtFtIn(L/FT*12)),cw=txt.length*7+14,ch=17;
1757
+ return `<line class=cmrub${dimmed?' style="opacity:.35"':''} x1="${a[0]}" y1="${a[1]}" x2="${c[0]}" y2="${c[1]}"/>`
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"/>`
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>`);}
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);
1763
+ if(!cmDraft){cmDraft={base:[s.x,s.y],vA:null};cmPrev(e);panel();return;}
1764
+ const v=[s.x-cmDraft.base[0],s.y-cmDraft.base[1],0];
1765
+ if(Math.hypot(v[0],v[1])<0.5)return; // ignore a zero-distance double-click
1766
+ if(cmArrayMode&&!cmDraft.vA){cmDraft.vA=v;cmPrev(e);panel();return;} // array: this click fixes direction A
1767
+ cmCommit(v);}
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;}
1771
+ if(cmArrayMode&&cmCountA*cmCountB>100){toast('Array capped at 100 instances — lower the counts (two passes reach further)');return;}
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]
1773
+ edit(()=>{
1774
+ if(cmTool==='move'){translateMembers(sel,offs[0]);}
1775
+ else{const ns=new Set();
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);}
1777
+ selIds=ns;}}); // copy selects the new set (numbered badges)
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));
1780
+ cmHudClose();cmDraft=null;cmPrevClear();panel();} // tool stays armed for the next pick pair; Esc finishes
1781
+ function cmCommit(v){cmApplyOffsets(cmOffsetsFor(v));}
1782
+ // --- numeric HUD (spec §3.3): type after the base pick → exact distance or dx,dy[,dz] (dz inches).
1783
+ // Live parse echo while typing; unparseable Enter flags the chip red (inline, not just a toast). ---
1784
+ let cmHudEl=null;
1785
+ function cmHudIsOpen(){return !!(cmHudEl&&cmHudEl.style.display!=='none');}
1786
+ function cmHudClose(){if(cmHudEl){cmHudEl.style.display='none';cmHudEl.classList.remove('err');const i=cmHudEl.querySelector('input');if(i)i.value='';}try{stage.focus({preventScroll:true});}catch(_){}}
1787
+ function cmHudEcho(){const inp=cmHudEl.querySelector('input'),lbl=cmHudEl.querySelector('#cmHudLbl');
1788
+ const pv=parseVec(inp.value),n=(cmTool==='copy'&&!cmArrayMode&&cmCount>1)?(' ×'+cmCount):'';
1789
+ lbl.textContent=(pv?(pv.dist!=null?('= '+fmtFtIn(pv.dist)):('= '+pv.comp.map(fmtFtIn).join(', ')))+n:n.trim());
1790
+ cmHudEl.classList.remove('err');}
1791
+ function cmHudShow(seed){if(!cmDraft&&!(view3d&&window.Steel3DView&&window.Steel3DView.cmHasBase&&window.Steel3DView.cmHasBase()))return;
1792
+ if(!cmHudEl){cmHudEl=document.createElement('div');cmHudEl.id='cmHud';
1793
+ const inp=document.createElement('input');inp.placeholder="12'-6 · or dx,dy";inp.autocomplete='off';inp.spellcheck=false;
1794
+ const lbl=document.createElement('span');lbl.id='cmHudLbl';
1795
+ cmHudEl.append(inp,lbl);document.body.appendChild(cmHudEl);
1796
+ inp.addEventListener('input',cmHudEcho);
1797
+ inp.addEventListener('keydown',ev=>{ev.stopPropagation();
1798
+ if(ev.key==='Enter'){ev.preventDefault();cmHudCommit(inp.value);}
1799
+ else if(ev.key==='Escape'){ev.preventDefault();cmHudClose();}});}
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]);
1802
+ cmHudEl.style.left=Math.min(scr[0]+16,innerWidth-200)+'px';cmHudEl.style.top=Math.min(scr[1]+16,innerHeight-46)+'px';
1803
+ cmHudEl.style.display='flex';cmHudEl.classList.remove('err');
1804
+ const inp=cmHudEl.querySelector('input');inp.value=seed||'';cmHudEcho();inp.focus();
1805
+ try{inp.setSelectionRange(inp.value.length,inp.value.length);}catch(_){}}
1806
+ function cmHudCommit(str){const pv=parseVec(str);
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;}
1813
+ let v;
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
1815
+ const dxp=pv.comp[0]*FT/12,dyp=pv.comp[1]*FT/12;
1816
+ v=[dxp*u[0]+dyp*vv[0],dxp*u[1]+dyp*vv[1],pv.comp[2]||0];}
1817
+ else{if(!cmLastPtr){cmHudEl.classList.add('err');toast('Move the mouse to aim, or type dx,dy');return;}
1818
+ const dir=[cmLastPtr[0]-cmDraft.base[0],cmLastPtr[1]-cmDraft.base[1]],L=Math.hypot(dir[0],dir[1]);
1819
+ if(L<1e-6){cmHudEl.classList.add('err');toast('Move the mouse to aim, or type dx,dy');return;}
1820
+ const d=pv.dist*FT/12;v=[dir[0]/L*d,dir[1]/L*d,0];}
1821
+ if(Math.hypot(v[0],v[1])<0.5&&Math.abs(v[2]||0)<1e-9){cmHudEl.classList.add('err');toast('Zero distance — type a non-zero value');return;} // a pure-dz move is legit; a true zero would stack clones / push a no-op undo
1822
+ if(cmArrayMode&&!cmDraft.vA){cmDraft.vA=v;cmHudClose();cmRefreshPrev();panel();return;} // typed spacing commits direction A
1823
+ cmHudClose();cmCommit(v);}
1824
+ // --- Move/Copy to level (spec §4.5): themed plan picker, click-row selection (no native radios),
1825
+ // double-click commits. Cross-plan undo via snapshotWith. Joints stay behind (same as Ctrl+D). ---
1826
+ let lvModal=null;
1827
+ function lvOpen(){return !!lvModal;}
1828
+ function closeLevelModal(){if(lvModal){lvModal.remove();lvModal=null;}}
1829
+ function openLevelModal(tool){ // works from 2D and 3D — the commit is plan-data-level
1830
+ if(!selIds.size){toast('Select members first, then '+(tool==='move'?'Move':'Copy')+' to level');return;}
1831
+ if(C.plans.length<2){toast('Only one plan is loaded — nothing to '+tool+' to');return;}
1832
+ closeLevelModal();
1833
+ lvModal=document.createElement('div');lvModal.id='lvModal';lvModal.style.cssText='position:fixed;inset:0;z-index:20;display:flex;align-items:center;justify-content:center'; // same flex-centering + layer as the sibling modals — the toast (z 60) must stay visible above it
1834
+ const bd=document.createElement('div');bd.className='mbackdrop';bd.onclick=closeLevelModal;
1835
+ const pn=document.createElement('div');pn.className='mpanel';pn.style.maxWidth='440px';
1836
+ const hd=document.createElement('div');hd.className='mhead';
1837
+ const tt=document.createElement('b');tt.textContent=(tool==='move'?'Move':'Copy')+' '+selIds.size+' member'+(selIds.size===1?'':'s')+' to level';
1838
+ const xb=document.createElement('button');xb.textContent='✕';xb.onclick=closeLevelModal;
1839
+ hd.append(tt,xb);
1840
+ const body=document.createElement('div');body.style.cssText='padding:12px 14px;max-height:50vh;overflow:auto;display:flex;flex-direction:column;gap:2px';
1841
+ let pick=-1;const rows=[];
1842
+ const go=document.createElement('button');go.className='primary';go.disabled=true;go.textContent=tool==='move'?'Move here':'Copy here';
1843
+ C.plans.forEach((pl,i)=>{const row=document.createElement('div');row.className='lvrow'+(pl===P?' cur':'');
1844
+ const nm=document.createElement('span');nm.textContent=pl.sheet+(pl.title?' · '+pl.title:'')+(pl===P?' (current)':'');
1845
+ const ts=document.createElement('span');ts.className='lvtos';const nM=(pl.members||[]).length;ts.textContent=nM+' member'+(nM===1?'':'s')+' · TOS '+fmtFtIn(pl.default_tos!=null?pl.default_tos:198);
1846
+ row.append(nm,ts);
1847
+ if(pl!==P){row.onclick=()=>{pick=i;rows.forEach(r=>r.classList.remove('pick'));row.classList.add('pick');go.disabled=false;};
1848
+ row.ondblclick=()=>{pick=i;toLevelCommit(tool,i);closeLevelModal();};}
1849
+ rows.push(row);body.appendChild(row);});
1850
+ const note=document.createElement('div');note.className='hint';note.style.cssText='padding:0 14px 4px';
1851
+ note.textContent=tool==='move'?'A move keeps each member’s connections attached.':'Copies don’t carry connections — detail them on the target level.';
1852
+ const ft=document.createElement('div');ft.style.cssText='display:flex;justify-content:flex-end;gap:8px;padding:12px 14px;border-top:1px solid var(--line)';
1853
+ const cancel=document.createElement('button');cancel.textContent='Cancel';cancel.onclick=closeLevelModal;
1854
+ go.onclick=()=>{if(pick>=0){toLevelCommit(tool,pick);closeLevelModal();}};
1855
+ ft.append(cancel,go);pn.append(hd,body,note,ft);lvModal.append(bd,pn);document.body.appendChild(lvModal);}
1856
+ function toLevelCommit(tool,ti){const sel=selArr();const T=C.plans[ti];
1857
+ if(!sel.length){toast('Selection is empty — nothing to '+(tool==='move'?'move':'copy'));return;}
1858
+ if(!T||T===P)return;
1859
+ if(!Array.isArray(T.members))T.members=[];
1860
+ const dstDef=(T.default_tos!=null?T.default_tos:198);
1861
+ const pv=snapshotWith(T),pvT=snapshotForPlan(T,P,tool==='move'?[...selIds]:[]); // both captured pre-mutation; a move's ids ride the mirror so their joints survive a target-side undo
1862
+ const moved=[];
1863
+ for(const m of sel){const c=(tool==='copy')?cloneMember(m):m;retargetTos(c,defaultTOS,dstDef);moved.push(c);}
1864
+ if(tool==='move'){P.members=P.members.filter(m=>!selIds.has(m.id));selIds=new Set();}
1865
+ T.members.push(...moved);T.members.forEach(ensureMeta);
1866
+ pushUndo(pv);pushUndoFor(T,pvT);disarmCm();render();sync3D(); // disarm BEFORE render so the panel never advertises a dead tool
1867
+ toast((tool==='move'?'Moved ':'Copied ')+moved.length+' member'+(moved.length===1?'':'s')+' to '+T.sheet);}
1344
1868
  // --- Local coordinate system "set axes" tool: two snapped clicks (origin, then X-direction) define
1345
1869
  // P.frame={o,u}. Shares buildSnap/snap/snapMark; preview lives in its own <g> (render() rebuilds wholesale). ---
1346
1870
  function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
1347
1871
  const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
1348
- if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
1872
+ if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}if(cmTool)disarmCm();geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
1349
1873
  csDraft=null;csPrevClear();}
1350
1874
  function csSnapAt(e){const q=toSvg(e);if(e.altKey)return [q.x,q.y];buildSnap(null);const sn=snap(q.x,q.y);return [sn.x,sn.y];}
1351
1875
  function csClick(e){const p=csSnapAt(e);
@@ -1395,6 +1919,19 @@ function toast(msg){let t=document.getElementById('toast');
1395
1919
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1396
1920
  if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
1397
1921
  if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
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)
1398
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
1399
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)
1400
1937
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
@@ -1443,11 +1980,40 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1443
1980
  svg.addEventListener('pointermove',e=>{
1444
1981
  if(csaxisMode){csPrev(e);return;}
1445
1982
  if(dimMode){dimPrev(e);return;}
1983
+ if(cmTool){cmPrev(e);return;}
1446
1984
  if(dimSplitMode&&dimsVisible){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);sn.hit?snapMark(sn.x,sn.y):snapClear();}else snapClear();return;} // split mode → show the snap marker for the prospective split point
1447
1985
  if(geoMode==='split'&&selIds.size===1&&!drag){const m=byId([...selIds][0]);if(m){const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
1448
1986
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
1449
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)
1450
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;}
1451
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
1452
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;}
1453
2019
  if(drag.type==='draw'){let x=p.x,y=p.y;
@@ -1474,6 +2040,13 @@ svg.addEventListener('pointermove',e=>{
1474
2040
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
1475
2041
  m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
1476
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;}
1477
2050
  if(drag.type==='draw'){const a=[drag.x0,drag.y0],b=drag.cur||a;drag.line.remove();
1478
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');
1479
2052
  const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(_wt(addProfile)==null)}));
@@ -1484,8 +2057,17 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1484
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)
1485
2058
  drag.rect.remove();drag=null;render();return;}
1486
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'));}});
1487
2067
  function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
1488
- document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}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
1489
2071
  document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();setLastCmd('Dimension',()=>{if(!dimMode){dimMode=true;setDimMode();render();}});};
1490
2072
  document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
1491
2073
  document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
@@ -1493,6 +2075,8 @@ function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.tex
1493
2075
  document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
1494
2076
  function updCalloutToggle(){const b=document.getElementById('calloutToggleB');if(b)b.textContent=calloutsVisible?'Hide callouts':'Show callouts';}
1495
2077
  document.getElementById('calloutToggleB').onclick=()=>{calloutsVisible=!calloutsVisible;updCalloutToggle();render();};
2078
+ document.getElementById('gridEditB').onclick=()=>{setGridMode(!gridMode);render();};
2079
+ document.getElementById('gridToggleB').onclick=()=>gridSetVisible(!gridOn());
1496
2080
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
1497
2081
  if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
1498
2082
  setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
@@ -1511,6 +2095,8 @@ document.getElementById('redoB').onclick=doRedo;
1511
2095
  addEventListener('keydown',e=>{
1512
2096
  const inForm=/^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement||{}).tagName);
1513
2097
  if(e.key==='Escape'&&moreOpen()){closeMore();moreBtn.focus();return;}
2098
+ if(e.key==='Escape'&&(mvMenuC.isOpen()||cpMenuC.isOpen())){mvMenuC.close();cpMenuC.close();return;}
2099
+ if(lvOpen()){if(e.key==='Escape')closeLevelModal();return;} // the level modal is modal: no Delete/undo/tool keys mutate state underneath it
1514
2100
  if(e.key==='Escape'&&lightboxOpen()){closeLightbox();return;}
1515
2101
  if(e.key==='Escape'&&askAiIsOpen()){askAiClose();return;}
1516
2102
  if(e.key==='Escape'&&detailsOpen()){closeDetails();return;}
@@ -1521,10 +2107,16 @@ addEventListener('keydown',e=>{
1521
2107
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
1522
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)
1523
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)
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
1524
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
1525
2116
  if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1526
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
1527
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)
1528
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;}
1529
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)
1530
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
@@ -1535,6 +2127,15 @@ addEventListener('keydown',e=>{
1535
2127
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1536
2128
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1537
2129
  if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
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)
2137
+ if(ck==='m'){e.preventDefault();armCm('move',false);return;}
2138
+ if(ck==='c'){e.preventDefault();armCm('copy',false);return;}}
1538
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)
1539
2140
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
1540
2141
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();setLastCmd('Extend/Trim',()=>{geoMode='el';setGeo();render();});return;} // Extend/Trim — any selection
@@ -1558,6 +2159,21 @@ function moreOutside(e){if(!moreMenu.contains(e.target)&&e.target!==moreBtn)clos
1558
2159
  function closeMore(){moreMenu.classList.remove('open');moreBtn.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',moreOutside,true);}
1559
2160
  moreBtn.onclick=e=>{e.stopPropagation();if(moreOpen())closeMore();else{moreMenu.classList.add('open');moreBtn.setAttribute('aria-expanded','true');document.addEventListener('mousedown',moreOutside,true);}};
1560
2161
  moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();}); // an item's own handler runs (bubble) before this closes the menu
2162
+ // --- Move/Copy split-button dropdowns: same open/close discipline as the ⋯ menu ---
2163
+ function wireCmMenu(caretId,menuId){const b=document.getElementById(caretId),m=document.getElementById(menuId);
2164
+ const close=()=>{m.classList.remove('open');b.setAttribute('aria-expanded','false');document.removeEventListener('mousedown',out,true);};
2165
+ const out=e=>{if(!m.contains(e.target)&&e.target!==b)close();};
2166
+ b.onclick=e=>{e.stopPropagation();if(m.classList.contains('open'))close();else{m.classList.add('open');b.setAttribute('aria-expanded','true');document.addEventListener('mousedown',out,true);}};
2167
+ m.addEventListener('click',e=>{if(e.target.closest('button'))close();});
2168
+ return {close,isOpen:()=>m.classList.contains('open')};}
2169
+ const mvMenuC=wireCmMenu('mvCaret','mvMenu'),cpMenuC=wireCmMenu('cpCaret','cpMenu');
2170
+ document.getElementById('mvB').onclick=()=>armCm('move',false);
2171
+ document.getElementById('cpB').onclick=()=>armCm('copy',false);
2172
+ document.getElementById('mvTwoB').onclick=()=>armCm('move',false);
2173
+ document.getElementById('cpTwoB').onclick=()=>armCm('copy',false);
2174
+ document.getElementById('cpArrB').onclick=()=>armCm('copy',true);
2175
+ document.getElementById('mvLevelB').onclick=()=>openLevelModal('move');
2176
+ document.getElementById('cpLevelB').onclick=()=>openLevelModal('copy');
1561
2177
  // --- 2D|3D view toggle. 3D is rendered by Steel3DView (Three.js, loaded as a module → window). It
1562
2178
  // fetches the SAME scene the bake uses (/api/contract/:id/scene) so 2D and 3D show one contract. ---
1563
2179
  // Coped beams (auto, clash-driven) — cache the cut labels per member as the scene is fetched so the
@@ -1580,6 +2196,7 @@ const view3dApi={
1580
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
1581
2197
  onSplit:(id,wp)=>{const m=byId(id);if(m){doSplit(m,wp);sync3D();}}, // 3D Split → cut at the clicked point on the member
1582
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)
1583
2200
  getDimOverlays:()=>C.dim_overlays||{}, // the legend DIMENSIONS toggles {bolt_pitch,edge_clearance,cope_size} — the 3D view derives + filters overlays from this
1584
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.
1585
2202
  onAddDim3d:(dim)=>{C.dims3d.push(dim);scheduleSave();refreshDims3d();},
@@ -1595,6 +2212,31 @@ const view3dApi={
1595
2212
  selDim3d:()=>[...sel3dDimIds],
1596
2213
  newDim3dId:()=>'d3'+Date.now()+'_'+(dimSeq++), // same scheme as 2D dim ids (Date.now + dimSeq tie-breaker)
1597
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');},
1598
2240
  onClipsChange:()=>{build3DLegend();}, // a clip added / removed / toggled → rebuild the legend's Clip section
1599
2241
  beginClipEdit:()=>pushUndo(snapshot()), // a clip / work-area manipulation → push a pre-edit snapshot so Ctrl+Z/Y restores it
1600
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)
@@ -1787,6 +2429,18 @@ function build3DLegend(){const host=document.getElementById('m3dLegend');if(!hos
1787
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'}));
1788
2430
  if(!collapsedCats.has(ck))for(const k of dc.cats)addDimRow(k,DIM_LABEL[k],true);}
1789
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
+ }
1790
2444
  // CLIP — the active clip planes/boxes (a third axis: each HIDES geometry beyond it). Click a row to enable/
1791
2445
  // disable it (eye-style, like a hidden part); the × deletes. Empty state reads "(no clips)" so the section
1792
2446
  // looks available, not broken. Rebuilt by api.onClipsChange whenever a clip is added/removed/toggled.
@@ -1864,7 +2518,25 @@ function wire3DBar(){if(bar3dWired||!window.Steel3DView)return;bar3dWired=true;
1864
2518
  function workMenuClose(){workMenu.classList.remove('open');document.removeEventListener('mousedown',workMenuOutside,true);}
1865
2519
  workBtn.onclick=e=>{e.stopPropagation();if(workMenu.classList.contains('open'))workMenuClose();else{updateWorkBtn();workMenu.classList.add('open');document.addEventListener('mousedown',workMenuOutside,true);}};
1866
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
1867
- 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
1868
2540
  function applyViewState(on){ // flip the toggle + swap the canvases (no 3D side effects)
1869
2541
  view3d=on;
1870
2542
  const t2=document.getElementById('vt2d'),t3=document.getElementById('vt3d');
@@ -1876,11 +2548,13 @@ function applyViewState(on){ // flip the toggle + swap the canvases (
1876
2548
  document.getElementById('planSel').style.display=on?'none':''; // plan selector is 2D-only (3D shows the whole model)
1877
2549
  document.getElementById('m3dBar').style.display=on?'flex':'none';
1878
2550
  document.getElementById('m3dCube').style.display=on?'block':'none';
2551
+ document.getElementById('m3dAxes').style.display=on?'block':'none';
1879
2552
  if(!on)document.getElementById('m3dLegend').style.display='none'; // legend is shown by build3DLegend when entering 3D
1880
2553
  }
1881
2554
  async function setView(on){
1882
2555
  if(on){
1883
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
2557
+ if(cmTool){cmDraft=null;cmPrevClear();cmHudClose();} // Move/Copy stays armed across the view switch — only the in-progress 2D pick is dropped (it's in plan px)
1884
2558
  if(selIds.size)sel3dDimIds.clear(); // a member selected in 2D wins over a stale 3D-dim selection
1885
2559
  if(!window.Steel3DView){toast('3D view unavailable (renderer failed to load)');return;} // stay in 2D
1886
2560
  try{
@@ -1895,7 +2569,8 @@ async function setView(on){
1895
2569
  applyViewState(false);if(window.Steel3DView)window.Steel3DView.hide();
1896
2570
  toast('Could not open 3D view: '+((e&&e.message)||e));
1897
2571
  }
1898
- }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();}
1899
2574
  }
1900
2575
  document.getElementById('vt2d').onclick=()=>{if(view3d)setView(false);};
1901
2576
  document.getElementById('vt3d').onclick=()=>{if(!view3d)setView(true);};
@@ -2351,6 +3026,9 @@ function setPlan(i){C.active=i;P=C.plans[i];
2351
3026
  if(!Array.isArray(P.segments))P.segments=[];
2352
3027
  if(!Array.isArray(P.dims))P.dims=[];
2353
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;}
2354
3032
  // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
2355
3033
  // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
2356
3034
  // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
@@ -2374,7 +3052,9 @@ function setPlan(i){C.active=i;P=C.plans[i];
2374
3052
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
2375
3053
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
2376
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)
2377
3056
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
3057
+ disarmCm(); // Move/Copy resets per plan too (its counts persist — they're session prefs, not plan data)
2378
3058
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
2379
3059
  P.default_tos=defaultTOS; // make the default explicit so the 3D scene + bake use the SAME TOS the editor/dots do (contractToScene falls back to 0, not 198 — keeping them in sync stops the end dots floating off the steel)
2380
3060
  if(!P.autofilled){autofillTOS();P.autofilled=true;}
@@ -2428,6 +3108,40 @@ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
2428
3108
  const d1=snap(50,18);ok(d1.hit&&d1.kind==='mid'&&pnear([d1.x,d1.y],[50,20]),'snap → dimension-line midpoint (△)');
2429
3109
  buildSnap('td');const d2=snap(50,18);ok(!d2.hit,'snap → excluded dim cannot self-snap');
2430
3110
  P.members=sm;P.segments=ss;P.dims=sd;dimsVisible=sdv;zoom=sz;}
3111
+ // 7) Move/Copy transform core (spec §4.1)
3112
+ ok((()=>{const o=linearOffsets([10,0,2],3);return o.length===3&&pnear([o[2][0],o[2][1]],[30,0])&&near(o[2][2],6);})(),'linearOffsets scales all 3 comps');
3113
+ ok((()=>{const o=arrayOffsets([10,0,0],2,[0,5,0],3);return o.length===5&&o.every(q=>!(q[0]===0&&q[1]===0))&&pnear([o[4][0],o[4][1]],[10,10]);})(),'arrayOffsets n*m-1, no zero, grid sum');
3114
+ ok((()=>{const m={id:'t',role:'beam',wp:[[0,0],[10,0]],ends:[{tos:null,tosDef:true},{tos:100,tosDef:false}]};translateMembers([m],[5,-3,12]);
3115
+ return pnear(m.wp[0],[5,-3])&&pnear(m.wp[1],[15,-3])&&near(m.ends[0].tos,defaultTOS+12)&&m.ends[0].tosDef===false&&near(m.ends[1].tos,112);})(),'translateMembers rigid dz');
3116
+ ok((()=>{const m={id:'t2',role:'beam',wp:[[0,0],[10,0]],ends:[{tos:null,tosDef:true},{tos:null,tosDef:true}]};translateMembers([m],[5,0,0]);
3117
+ return m.ends[0].tosDef===true&&pnear(m.wp[0],[5,0]);})(),'translateMembers dz=0 keeps default-follow');
3118
+ ok((()=>{const m={id:'tc',role:'column',wp:[[0,0],[0,0]],ends:[{},{}],col:{bos:0,tos:198,tosDef:false}};translateMembers([m],[0,0,24]);
3119
+ return near(m.col.bos,24)&&near(m.col.tos,222);})(),'translateMembers column rigid (bos+tos)');
3120
+ ok((()=>{const src={id:'a',wp:[[1,2],[3,4]],ends:[{tos:1}]};const c=cloneMember(src);c.wp[0][0]=99;
3121
+ return c.id!=='a'&&src.wp[0][0]===1;})(),'cloneMember new id, deep copy');
3122
+ ok((()=>{const v=parseVec("16'");return !!v&&near(v.dist,192);})(),'parseVec single ft → inches');
3123
+ ok((()=>{const v=parseVec("12,-6,3");return !!v&&near(v.comp[0],12)&&near(v.comp[1],-6)&&near(v.comp[2],3);})(),'parseVec components');
3124
+ ok(parseVec('abc')===null&&parseVec('1,x')===null&&parseVec('')===null&&parseVec('1,2,3,4')===null,'parseVec rejects garbage');
3125
+ ok((()=>{const m={id:'c1',role:'column',wp:[[0,0],[0,0]],ends:[{},{}],col:{bos:0,tos:null,tosDef:true}};retargetTos(m,198,396);
3126
+ return near(m.col.tos,396)&&near(m.col.bos,198);})(),'retargetTos column keeps height');
3127
+ ok((()=>{const m={id:'b1',role:'beam',wp:[[0,0],[1,0]],ends:[{tos:null,tosDef:true},{tos:190,tosDef:false}]};retargetTos(m,198,396);
3128
+ return m.ends[0].tosDef===true&&near(m.ends[0].tos,396)&&near(m.ends[1].tos,388);})(),'retargetTos beam: default follows, override Δ-shifts');
3129
+ // 8) cross-plan undo slice (snapshotWith / others restore / redo mirror) — fake plans carry every
3130
+ // field apply()→render() touches; state is restored (and re-rendered) in finally.
3131
+ ok((()=>{const sp=C.plans,sA=C.active,sP=P,su=undo,sr=redo,ss=selIds,sj=C.joints;let r=false;
3132
+ try{const a1={id:'a1',wp:[[0,0],[1,0]]};
3133
+ const A={sheet:'__tA',members:[a1],segments:[],dims:[]},B={sheet:'__tB',members:[{id:'b1',wp:[[0,0],[1,0]]}],segments:[],dims:[]};
3134
+ C.plans=[A,B];P=A;undo=[];redo=[];selIds=new Set();C.joints=[{main:'a1',type:'__t'}];
3135
+ const pv=snapshotWith(B),pvT=snapshotForPlan(B,A,['a1']); // source entry + the target-side mirror (a MOVE carries a1's joints)
3136
+ A.members=[];B.members.push(a1); // the "commit": a1 MOVES A→B (same id, joint attached)
3137
+ const post=snapshotLike(pv); // what redo must restore
3138
+ apply(pv); r=A.members.length===1&&B.members.length===1; // undo restored BOTH
3139
+ apply(post); r=r&&A.members.length===0&&B.members.length===2; // redo restored BOTH
3140
+ P=B; apply(pvT); // undo FROM THE TARGET side
3141
+ r=r&&B.members.length===1&&A.members.length===1; // both plans restored (no stranded member)
3142
+ r=r&&C.joints.length===1&&C.joints[0].main==='a1'; // …and the moved member kept its connection
3143
+ }finally{C.plans=sp;C.active=sA;P=sP;undo=su;redo=sr;selIds=ss;C.joints=sj;render();}
3144
+ return r;})(),'cross-plan undo+redo restores both plans + moved joints');
2431
3145
  const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
2432
3146
  console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
2433
3147
  })();}