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