@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.
- package/dist/floless-server.cjs +227 -5
- package/dist/schemas/drawing.vector.v1.schema.json +92 -4
- package/dist/schemas/steel.takeoff.v1.schema.json +30 -5
- package/dist/skills/floless-app-steel-takeoff/SKILL.md +29 -1
- package/dist/web/grid-core.js +441 -0
- package/dist/web/steel-3d-core.js +49 -3
- package/dist/web/steel-3d-view.js +424 -10
- package/dist/web/steel-editor.html +734 -20
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|
|
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||[]);}
|
|
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(
|
|
616
|
-
function doRedo(){if(!redo.length)return;undo.push(
|
|
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"" 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" 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=>({'<':'<','>':'>','&
|
|
|
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 & 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();}
|
|
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{
|
|
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
|
})();}
|