@floless/app 0.37.1 → 0.38.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
CHANGED
|
@@ -52856,7 +52856,7 @@ function appVersion() {
|
|
|
52856
52856
|
return resolveVersion({
|
|
52857
52857
|
isSea: isSea2(),
|
|
52858
52858
|
sqVersionXml: readSqVersionXml(),
|
|
52859
|
-
define: true ? "0.
|
|
52859
|
+
define: true ? "0.38.0" : void 0,
|
|
52860
52860
|
pkgVersion: readPkgVersion()
|
|
52861
52861
|
});
|
|
52862
52862
|
}
|
|
@@ -52866,7 +52866,7 @@ function resolveChannel(s) {
|
|
|
52866
52866
|
return "dev";
|
|
52867
52867
|
}
|
|
52868
52868
|
function appChannel() {
|
|
52869
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52869
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.38.0" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// oauth-presets.ts
|
|
@@ -53752,7 +53752,19 @@ function bakeContractIntoApp(sourcePath, contract) {
|
|
|
53752
53752
|
if (idx < 0) idx = items.findIndex((it) => nodeOf(it)?.agent === "steel-takeoff-us");
|
|
53753
53753
|
if (idx < 0) throw new Error("no contract-emitting node to bake the contract into");
|
|
53754
53754
|
doc2.setIn(["nodes", idx, "config", "contract"], contract.type);
|
|
53755
|
-
|
|
53755
|
+
const plans = contract.plans;
|
|
53756
|
+
const baked = Array.isArray(plans) ? {
|
|
53757
|
+
...contract,
|
|
53758
|
+
plans: plans.map((p) => {
|
|
53759
|
+
if (p && typeof p === "object") {
|
|
53760
|
+
const clone = { ...p };
|
|
53761
|
+
delete clone.dims;
|
|
53762
|
+
return clone;
|
|
53763
|
+
}
|
|
53764
|
+
return p;
|
|
53765
|
+
})
|
|
53766
|
+
} : contract;
|
|
53767
|
+
doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
|
|
53756
53768
|
(0, import_node_fs17.writeFileSync)(sourcePath, doc2.toString());
|
|
53757
53769
|
}
|
|
53758
53770
|
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"labels": { "type": "array", "items": { "$ref": "#/$defs/label" } },
|
|
68
68
|
"details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
|
|
69
69
|
"tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
|
|
70
|
-
"members": { "type": "array", "items": { "$ref": "#/$defs/member" } }
|
|
70
|
+
"members": { "type": "array", "items": { "$ref": "#/$defs/member" } },
|
|
71
|
+
"dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." }
|
|
71
72
|
}
|
|
72
73
|
},
|
|
73
74
|
"segment": {
|
|
@@ -81,6 +82,18 @@
|
|
|
81
82
|
"o": { "enum": ["H", "V", "D"], "description": "Orientation: horizontal / vertical / diagonal." }
|
|
82
83
|
}
|
|
83
84
|
},
|
|
85
|
+
"dim": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"required": ["id", "a", "b", "axis", "off"],
|
|
88
|
+
"additionalProperties": true,
|
|
89
|
+
"properties": {
|
|
90
|
+
"id": { "type": "string" },
|
|
91
|
+
"a": { "$ref": "#/$defs/point2", "description": "First snapped anchor, display space." },
|
|
92
|
+
"b": { "$ref": "#/$defs/point2", "description": "Second snapped anchor, display space." },
|
|
93
|
+
"axis": { "enum": ["free", "x", "y"], "description": "free = aligned distance hypot(b-a); x = |b.x-a.x|; y = |b.y-a.y|." },
|
|
94
|
+
"off": { "type": "number", "description": "Dimension-line offset: for axis x the dim-line Y; for axis y the dim-line X; for free the signed perpendicular distance from the a-b baseline." }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
84
97
|
"label": {
|
|
85
98
|
"type": "object",
|
|
86
99
|
"required": ["text", "disp"],
|
|
@@ -141,12 +141,20 @@
|
|
|
141
141
|
#lbImg{position:absolute;left:50%;top:50%;max-width:86vw;max-height:80vh;transform-origin:center;user-select:none;-webkit-user-drag:none}
|
|
142
142
|
line.draw{stroke:var(--brand);stroke-width:6;stroke-linecap:round;stroke-dasharray:8 5;opacity:.85;pointer-events:none}
|
|
143
143
|
circle.snapmk{fill:none;stroke:#22d3ee;stroke-width:2;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
144
|
+
line.dim{stroke:#22d3ee;stroke-width:2.5;vector-effect:non-scaling-stroke;pointer-events:stroke;cursor:pointer}
|
|
145
|
+
line.dimwit{stroke:#22d3ee;stroke-width:1;opacity:.85;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
146
|
+
circle.dimend{fill:#22d3ee;pointer-events:none}
|
|
147
|
+
rect.dimchip{fill:#111827;stroke:#22d3ee;stroke-width:1;vector-effect:non-scaling-stroke;cursor:pointer}
|
|
148
|
+
text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
|
|
149
|
+
line.dim.dimsel{stroke:#f59e0b} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
|
|
150
|
+
.dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
|
|
151
|
+
body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
|
|
144
152
|
body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
|
|
145
153
|
body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
|
|
146
154
|
circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
147
155
|
text.numtx{fill:#fff;font:bold 12px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
|
|
148
156
|
#dupStat.has b{color:#fca5a5} #dupB.alert{background:#7f1d1d;border-color:#991b1b} #dupB.alert:hover{background:#991b1b}
|
|
149
|
-
#dupB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
|
|
157
|
+
#dupB.ok,#revB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
|
|
150
158
|
header select#planSel{width:auto;min-width:210px;padding:5px 8px;font-weight:600}
|
|
151
159
|
/* 2D|3D view toggle — segmented, far-left; active segment filled (--panel) with a --brand underline */
|
|
152
160
|
#viewToggle{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;flex:none}
|
|
@@ -188,12 +196,17 @@
|
|
|
188
196
|
<button id=undoB title="Undo (Ctrl+Z)">↶</button>
|
|
189
197
|
<button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
|
|
190
198
|
<button id=mAdd title="Toggle add-member mode">Add member</button>
|
|
199
|
+
<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>
|
|
191
200
|
<button id=askAiBtn>Ask AI ▸</button>
|
|
192
201
|
<div id=moreWrap>
|
|
193
202
|
<button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
|
|
194
203
|
<div id=moreMenu role=menu>
|
|
204
|
+
<div class=mlabel>Dimensions</div>
|
|
205
|
+
<button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
|
|
206
|
+
<hr>
|
|
195
207
|
<div class=mlabel>Review</div>
|
|
196
208
|
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
209
|
+
<button id=revB title="Select beams drawn the wrong way — a mostly-horizontal beam should run left→right, a steep/skew beam bottom→up. Review, then press P to swap their direction.">Reversed beams</button>
|
|
197
210
|
<button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
|
|
198
211
|
<hr>
|
|
199
212
|
<div class=mlabel>Reference</div>
|
|
@@ -397,6 +410,7 @@ function showAiUpdateBanner(message) {
|
|
|
397
410
|
function main() {
|
|
398
411
|
let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
|
|
399
412
|
let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
|
|
413
|
+
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
|
|
400
414
|
let selIds=new Set();
|
|
401
415
|
let undo=[], redo=[];
|
|
402
416
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
@@ -448,9 +462,14 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
|
448
462
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
449
463
|
const autofillTOS=syncDefaults;
|
|
450
464
|
// profs is per-plan — computed in setPlan()
|
|
451
|
-
function snapshot(){return JSON.stringify(P.members);}
|
|
465
|
+
function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
|
|
452
466
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
453
|
-
function apply(json){
|
|
467
|
+
function apply(json){const d=JSON.parse(json);
|
|
468
|
+
if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
|
|
469
|
+
else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;}
|
|
470
|
+
selIds=new Set([...selIds].filter(id=>byId(id)));
|
|
471
|
+
selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
|
|
472
|
+
scheduleSave();render();sync3D();}
|
|
454
473
|
function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
|
|
455
474
|
function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
|
|
456
475
|
function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
|
|
@@ -465,7 +484,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
465
484
|
else el.textContent=msg||'Auto-save on';}
|
|
466
485
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
467
486
|
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
|
|
468
|
-
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
487
|
+
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
469
488
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
470
489
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
471
490
|
// fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
|
|
@@ -494,7 +513,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
494
513
|
if(d.custom_details)C.custom_details=d.custom_details;
|
|
495
514
|
if(d.profile_colors)C.profile_colors=d.profile_colors;
|
|
496
515
|
if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
|
|
497
|
-
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;p.autofilled=true;}});
|
|
516
|
+
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;p.autofilled=true;}});
|
|
498
517
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
499
518
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
500
519
|
function colorFor(p){if(C.profile_colors[p])return C.profile_colors[p];let i=profs.indexOf(p);return PAL[((i%PAL.length)+PAL.length)%PAL.length];}
|
|
@@ -505,6 +524,45 @@ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y
|
|
|
505
524
|
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
|
|
506
525
|
function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
|
|
507
526
|
let t=((p[0]-a[0])*vx+(p[1]-a[1])*vy)/L2;t=Math.max(0,Math.min(1,t));return {pt:[a[0]+t*vx,a[1]+t*vy],t};}
|
|
527
|
+
// --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
|
|
528
|
+
// y -> the dim-line X; free -> signed perpendicular distance from the a->b baseline. ---
|
|
529
|
+
function dimGeo(a,b,axis,off){
|
|
530
|
+
if(axis==='x'){const y=off;return {p1:[a[0],y],p2:[b[0],y],w:[[a,[a[0],y]],[b,[b[0],y]]],mid:[(a[0]+b[0])/2,y],px:Math.abs(b[0]-a[0])};}
|
|
531
|
+
if(axis==='y'){const x=off;return {p1:[x,a[1]],p2:[x,b[1]],w:[[a,[x,a[1]]],[b,[x,b[1]]]],mid:[x,(a[1]+b[1])/2],px:Math.abs(b[1]-a[1])};}
|
|
532
|
+
const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy),D=L||1,nx=-dy/D,ny=dx/D; // free: parallel-offset along the normal (D guards a zero-length divide; L stays the true measured length so a 0-length dim reads 0'-0", not 1px)
|
|
533
|
+
const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
|
|
534
|
+
return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
|
|
535
|
+
// measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
|
|
536
|
+
function dimValueText(px){return fmtFtIn(px/FT*12);}
|
|
537
|
+
// the offset a click at pointer p implies, given the two anchors + axis.
|
|
538
|
+
function dimOffFromPointer(a,b,axis,p){
|
|
539
|
+
if(axis==='x')return p[1];
|
|
540
|
+
if(axis==='y')return p[0];
|
|
541
|
+
const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
|
|
542
|
+
return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
|
|
543
|
+
// a sensible offset for the stage-1 preview (before the 3rd click picks one): clear of the anchors by a constant screen distance.
|
|
544
|
+
function dimDefaultOff(a,b,axis){
|
|
545
|
+
if(axis==='x')return Math.min(a[1],b[1])-28/zoom; // X dim line sits above the higher anchor
|
|
546
|
+
if(axis==='y')return Math.min(a[0],b[0])-28/zoom; // Y dim line sits left of the leftmost anchor
|
|
547
|
+
return -28/zoom;} // free: a constant perpendicular offset
|
|
548
|
+
// one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
|
|
549
|
+
// screen); the chip rect/text + end dots are sized in 1/zoom and re-synced by updateBadges on zoom.
|
|
550
|
+
function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off);
|
|
551
|
+
const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
|
|
552
|
+
const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
|
|
553
|
+
// a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
|
|
554
|
+
// horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
|
|
555
|
+
// 8° of horizontal it stays flat.
|
|
556
|
+
let ang=0;if(d.axis==='free'){ang=Math.atan2(g.p2[1]-g.p1[1],g.p2[0]-g.p1[0])*180/Math.PI;if(ang>90)ang-=180;else if(ang<-90)ang+=180;if(Math.abs(ang)<8)ang=0;}
|
|
557
|
+
let s='';for(const w of g.w)s+=`<line class="dimwit ${c}" x1="${w[0][0]}" y1="${w[0][1]}" x2="${w[1][0]}" y2="${w[1][1]}"/>`;
|
|
558
|
+
s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
|
|
559
|
+
s+=`<circle class="dimend ${c}" cx="${g.p1[0]}" cy="${g.p1[1]}" r="${3/zoom}"/><circle class="dimend ${c}" cx="${g.p2[0]}" cy="${g.p2[1]}" r="${3/zoom}"/>`;
|
|
560
|
+
const chip=`<rect class="dimchip ${c}"${dd} data-cx="${g.mid[0]}" data-cy="${g.mid[1]}" data-w="${cw}" data-h="${ch}" x="${g.mid[0]-cw/2/zoom}" y="${g.mid[1]-ch/2/zoom}" width="${cw/zoom}" height="${ch/zoom}" rx="${4/zoom}"/>`
|
|
561
|
+
+`<text class="dimtx ${c}" x="${g.mid[0]}" y="${g.mid[1]}" style="font-size:${13/zoom}px">${txt}</text>`;
|
|
562
|
+
s+=ang?`<g transform="rotate(${ang.toFixed(2)} ${g.mid[0]} ${g.mid[1]})">${chip}</g>`:chip; // rotate the chip group around the dim-line midpoint
|
|
563
|
+
return s;}
|
|
564
|
+
function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
|
|
565
|
+
return P.dims.map(d=>dimSvg(d,{id:d.id,sel:selDimIds.has(d.id)})).join('');}
|
|
508
566
|
function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
|
|
509
567
|
// nearest end → intersection: extends when the line is past the end, trims when it crosses the member. ponytail: keeps the larger piece on a deep overshoot — undo+drag if that's wrong.
|
|
510
568
|
// Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
|
|
@@ -518,7 +576,7 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
|
|
|
518
576
|
m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
|
|
519
577
|
const c=JSON.parse(JSON.stringify(base));c.id='m'+Date.now();c.wp=[q.slice(),b];
|
|
520
578
|
if(c.ends)c.ends=[mk(),base.ends?base.ends[1]:mk()]; // second half keeps the original far end
|
|
521
|
-
c.rfi=(WT[c.profile]==null);P.members.push(c);selIds=new Set([m.id,c.id]);geoMode=null;setGeo();pushUndo(pv);render();}
|
|
579
|
+
c.rfi=(WT[c.profile]==null);P.members.push(c);selIds=new Set([m.id,c.id]);selDimIds.clear();geoMode=null;setGeo();pushUndo(pv);render();}
|
|
522
580
|
// --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
|
|
523
581
|
function dupKey(m){const r=p=>Math.round(p[0]/3)+','+Math.round(p[1]/3);const a=r(m.wp[0]),b=r(m.wp[1]);return a<b?a+'|'+b:b+'|'+a;}
|
|
524
582
|
function dupScore(m){let s=0;if(WT[m.profile]!=null)s+=2;if(m.profile&&!/^MF/i.test(m.profile))s+=1;return s;} // keep the most-resolved copy
|
|
@@ -544,6 +602,15 @@ function _mAble(a,b){if((a.profile||'').toUpperCase()!==(b.profile||'').toUpperC
|
|
|
544
602
|
const s=_mShare(a,b);if(!s)return null;return _mStraight(a,b,s)&&_mElev(a,b,s)?s:null;}
|
|
545
603
|
function _mAngle(wp){const dx=Math.abs(wp[1][0]-wp[0][0]),dy=Math.abs(wp[1][1]-wp[0][1]),t=Math.tan(MRG_ANG);
|
|
546
604
|
return dy<=dx*t?'H':dx<=dy*t?'V':'D';}
|
|
605
|
+
// A beam is drawn the canonical way when its work-line runs left→right (mostly-horizontal) or
|
|
606
|
+
// bottom→up (steeper/skew). Display space is y-down, so "up" = a smaller y. Returns true when
|
|
607
|
+
// start→end points the WRONG way — swapping its ends (P) would make it canonical. 45° split:
|
|
608
|
+
// |run| ≥ |rise| ⇒ judge left/right, else up/down. (A zero-length beam has no direction.)
|
|
609
|
+
function isReversed(m){if(!m||!m.wp||m.wp.length<2)return false;
|
|
610
|
+
const dx=m.wp[1][0]-m.wp[0][0],dy=m.wp[1][1]-m.wp[0][1];if(dx===0&&dy===0)return false;
|
|
611
|
+
return Math.abs(dx)>=Math.abs(dy)?dx<0:dy>0;}
|
|
612
|
+
// Ids of the active plan's beams (not columns) drawn the wrong way — the "Reversed beams" selection.
|
|
613
|
+
function reversedBeams(){return P.members.filter(m=>m.role!=='column'&&isReversed(m)).map(m=>m.id);}
|
|
547
614
|
function mergeCollinearJS(members){
|
|
548
615
|
const idx=members.map((_,i)=>i).filter(i=>_mElig(members[i]));
|
|
549
616
|
const adj=new Map();for(const i of idx)adj.set(i,[]);
|
|
@@ -593,7 +660,7 @@ function fitToWindow(){applyZoom(Math.min(stage.clientWidth/EXTX,stage.clientHei
|
|
|
593
660
|
function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot(),ns=new Set();
|
|
594
661
|
let i=0;for(const s of arr){const id='m'+Date.now()+'_'+(i++);const c=JSON.parse(JSON.stringify(s));
|
|
595
662
|
c.id=id;c.wp=[[s.wp[0][0]+o,s.wp[0][1]+o],[s.wp[1][0]+o,s.wp[1][1]+o]];P.members.push(c);ns.add(id);}
|
|
596
|
-
selIds=ns;mode='sel';setMode();pushUndo(pv);render();}
|
|
663
|
+
selIds=ns;selDimIds.clear();mode='sel';setMode();pushUndo(pv);render();}
|
|
597
664
|
function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'<','>':'>','&':'&','"':'"'}[c]));}
|
|
598
665
|
function render(){
|
|
599
666
|
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
|
|
@@ -613,19 +680,24 @@ function render(){
|
|
|
613
680
|
selM.forEach((m,idx)=>{const c=mid(m),k=Math.round(c[0]/8)+','+Math.round(c[1]/8);(grp[k]=grp[k]||[]).push({idx,c});});
|
|
614
681
|
for(const k in grp){const a=grp[k],n=a.length;a.forEach((it,j)=>{const x=it.c[0]+(j-(n-1)/2)*R*2,y=it.c[1];const d=`data-bx="${it.c[0]}" data-fi="${j}" data-gn="${n}"`;
|
|
615
682
|
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>`;});}}
|
|
683
|
+
s+=renderDims();
|
|
616
684
|
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();
|
|
617
685
|
if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
|
|
618
686
|
}
|
|
619
687
|
function updDup(){const n=redundantDups().length;
|
|
620
688
|
document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
|
|
621
689
|
const b=document.getElementById('dupB');if(b.dataset.flash)return; // don't clobber transient "none" feedback
|
|
622
|
-
b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'
|
|
690
|
+
b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Check duplicates';}
|
|
623
691
|
// keep selection-number badges constant on screen while zooming; the end dots grow with zoom (epR) so
|
|
624
692
|
// they stay readable against the thick member line — both must track zoom live as the user zooms.
|
|
625
693
|
function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
|
|
626
694
|
svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
|
|
627
695
|
svg.querySelectorAll('text.numtx').forEach(t=>{t.setAttribute('x',(+t.dataset.bx)+ox(t));t.style.fontSize=F+'px';});
|
|
628
|
-
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));
|
|
696
|
+
svg.querySelectorAll('circle.handle').forEach(h=>h.setAttribute('r',epR()));
|
|
697
|
+
const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
|
|
698
|
+
svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
|
|
699
|
+
svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
|
|
700
|
+
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});}
|
|
629
701
|
function updateHandles(m){svg.querySelectorAll(`circle.handle[data-mid="${m.id}"]`).forEach(h=>{const i=+h.dataset.h;h.setAttribute('cx',m.wp[i][0]);h.setAttribute('cy',m.wp[i][1]);});}
|
|
630
702
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
631
703
|
if(ln){ln.setAttribute('x1',m.wp[0][0]);ln.setAttribute('y1',m.wp[0][1]);ln.setAttribute('x2',m.wp[1][0]);ln.setAttribute('y2',m.wp[1][1]);}}
|
|
@@ -640,6 +712,15 @@ const VARIES=Symbol('varies');
|
|
|
640
712
|
function agg(list,get){if(!list.length)return undefined;const f=get(list[0]);for(let i=1;i<list.length;i++)if(get(list[i])!==f)return VARIES;return f;}
|
|
641
713
|
function panel(){
|
|
642
714
|
const p=document.getElementById('panel');
|
|
715
|
+
if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
|
|
716
|
+
<div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
|
|
717
|
+
<div class=sect style="margin-top:12px">Direction</div>
|
|
718
|
+
<div class=seg2><button id=dimAxFree class="${dimAxis==='free'?'on':''}">Free</button><button id=dimAxX class="${dimAxis==='x'?'on':''}">X</button><button id=dimAxY class="${dimAxis==='y'?'on':''}">Y</button></div>
|
|
719
|
+
<div class=hint style="margin-top:8px">Snaps to grid intersections, member ends, and onto member lines — same as drawing. <b>Alt</b> turns snap off. <b>Esc</b> cancels.</div>`;
|
|
720
|
+
document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
|
|
721
|
+
document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
|
|
722
|
+
document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
|
|
723
|
+
return;}
|
|
643
724
|
if(mode==='add'){
|
|
644
725
|
p.innerHTML=`<span class=badge>Add member</span>
|
|
645
726
|
<div class=sect>Current profile</div>
|
|
@@ -823,12 +904,59 @@ function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
|
|
|
823
904
|
return {x:sx,y:sy,hit:false};}
|
|
824
905
|
function snapMark(x,y){let c=document.getElementById('snapMark');if(!c){c=document.createElementNS('http://www.w3.org/2000/svg','circle');c.id='snapMark';c.setAttribute('class','snapmk');svg.appendChild(c);}c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r',6/zoom);}
|
|
825
906
|
function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
|
|
907
|
+
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
908
|
+
// buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
|
|
909
|
+
// svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
|
|
910
|
+
const SVGNS='http://www.w3.org/2000/svg';
|
|
911
|
+
function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
|
|
912
|
+
if(dimMode){if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes + ensures dims are visible
|
|
913
|
+
dimDraft=null;dimPrevClear();}
|
|
914
|
+
function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
|
|
915
|
+
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};}
|
|
916
|
+
return {x,y,raw:[q.x,q.y],hit:false};}
|
|
917
|
+
function dimClick(e){const s=dimSnapAt(e);
|
|
918
|
+
if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
|
|
919
|
+
if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
|
|
920
|
+
const axis=dimDraft._ax||dimDraft.axis; // commit the axis the preview last showed: _ax is cached every move (incl. the click-2 preview), so a Shift press/release between move and click can't make the commit disagree with what was on screen
|
|
921
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw); // click 3 → offset/side from raw pointer
|
|
922
|
+
const a=dimDraft.a,b=dimDraft.b;
|
|
923
|
+
edit(()=>{P.dims.push({id:'d'+Date.now(),a,b,axis,off});}); // undoable; render() clears the preview
|
|
924
|
+
dimDraft=null;}
|
|
925
|
+
function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
|
|
926
|
+
function dimPrev(e){if(!dimMode||!dimDraft){dimPrevClear();return;}
|
|
927
|
+
let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
|
|
928
|
+
if(!dimDraft.b){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear(); // stage 1: live CONSTRAINED preview from a → the tentative 2nd point
|
|
929
|
+
const b=[s.x,s.y];dimLastPtr=b;
|
|
930
|
+
const ax=e.shiftKey?(Math.abs(b[0]-dimDraft.a[0])>=Math.abs(b[1]-dimDraft.a[1])?'x':'y'):dimDraft.axis; // X/Y/F + Shift force already apply while picking the 2nd point (value shown live)
|
|
931
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax)},{preview:true});return;}
|
|
932
|
+
const q=toSvg(e);dimLastPtr=[q.x,q.y];snapClear();
|
|
933
|
+
const ax=e.shiftKey?(Math.abs(dimDraft.b[0]-dimDraft.a[0])>=Math.abs(dimDraft.b[1]-dimDraft.a[1])?'x':'y'):dimDraft.axis; // Shift = momentary dominant-axis lock
|
|
934
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y]); // stage 2: full dim rides the pointer
|
|
935
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off},{preview:true});dimDraft._ax=ax;}
|
|
936
|
+
function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
|
|
937
|
+
const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
|
|
938
|
+
for(const [id,a] of [['dimAxFree','free'],['dimAxX','x'],['dimAxY','y']]){const b=document.getElementById(id);if(b)b.classList.toggle('on',dimAxis===a);}} // keep the Free/X/Y control in sync whether the axis changed by button OR keyboard (x/y/f)
|
|
939
|
+
function dimHintText(){const a={free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
|
|
940
|
+
return 'Dimension — '+a+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
|
|
941
|
+
let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
|
|
942
|
+
function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
|
|
943
|
+
const g=document.getElementById('dimPrevG');if(!g)return;
|
|
944
|
+
if(!dimDraft.b){const b=dimLastPtr; // stage 1: redraw the forming dim with the just-changed axis (key/button), no mouse move needed
|
|
945
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis)},{preview:true});return;}
|
|
946
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr);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
|
|
947
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off},{preview:true});}
|
|
826
948
|
// themed transient toast (baseline tokens — never a native alert)
|
|
827
949
|
function toast(msg){let t=document.getElementById('toast');
|
|
828
950
|
if(!t){t=document.createElement('div');t.id='toast';t.style.cssText='position:fixed;left:50%;bottom:18px;transform:translateX(-50%);background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:8px;padding:8px 14px;box-shadow:0 6px 20px rgba(0,0,0,.5);z-index:60;font:13px system-ui;opacity:0;transition:opacity .2s;pointer-events:none';document.body.appendChild(t);}
|
|
829
951
|
t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
|
|
830
952
|
// --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
|
|
831
953
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
954
|
+
if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
|
|
955
|
+
if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
|
|
956
|
+
if(e.ctrlKey||e.metaKey){selDimIds.has(did)?selDimIds.delete(did):selDimIds.add(did);render();return;} // Ctrl+click toggles this dim within the dim selection
|
|
957
|
+
selDimIds=new Set([did]);const d=P.dims.find(x=>x.id===did); // plain click → exactly this dim (collapses any multi-selection, so Delete removes only it)
|
|
958
|
+
if(d){drag={type:'dim',id:d.id,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();}render();return;} // drag the dim line to slide its offset
|
|
959
|
+
if(selDimIds.size){selDimIds.clear();render();} // ANY other click (member / handle / empty / marquee-start, incl. Ctrl) is dim-exclusive → drop the dim selection so Delete never hits a stale dim
|
|
832
960
|
if(geoMode==='split'&&selIds.size===1){const id=[...selIds][0],m=byId(id);
|
|
833
961
|
const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
|
|
834
962
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
|
|
@@ -858,14 +986,16 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
858
986
|
const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
|
|
859
987
|
buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
|
|
860
988
|
drag={type:'move',start:[p.x,p.y],items,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;}
|
|
861
|
-
if((t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e);
|
|
989
|
+
if((t===svg||t.tagName==='image'||t.classList.contains('seg'))&&mode==='sel'){const p=toSvg(e); // t===svg → drag on bare canvas (no raster) still box-selects
|
|
862
990
|
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
863
991
|
drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
864
992
|
svg.addEventListener('pointermove',e=>{
|
|
993
|
+
if(dimMode){dimPrev(e);return;}
|
|
865
994
|
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]);
|
|
866
995
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
|
|
867
996
|
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]);}else snapClear();}return;}
|
|
868
997
|
if(!drag)return;const p=toSvg(e);
|
|
998
|
+
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]);render();}return;}
|
|
869
999
|
if(drag.type==='draw'){let x=p.x,y=p.y;
|
|
870
1000
|
if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
|
|
871
1001
|
else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
|
|
@@ -894,19 +1024,28 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
894
1024
|
const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(WT[addProfile]==null)}));
|
|
895
1025
|
if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
|
|
896
1026
|
drag=null;render();return;}
|
|
897
|
-
if(drag.type==='marquee'){if(!drag.add)selIds.clear();
|
|
898
|
-
const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2))for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
|
|
1027
|
+
if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
|
|
1028
|
+
const r=drag.cur;if(r&&(r[2]-r[0]>2||r[3]-r[1]>2)){for(const m of P.members)if(rectHit(m.wp[0],m.wp[1],r))selIds.add(m.id);
|
|
1029
|
+
if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off);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)
|
|
899
1030
|
drag.rect.remove();drag=null;render();return;}
|
|
900
1031
|
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
|
|
901
1032
|
function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
|
|
902
|
-
document.getElementById('mAdd').onclick=()=>{mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();};
|
|
1033
|
+
document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();}; // entering add/sel disarms the Dimension tool (else dimMode keeps eating canvas clicks) and drops any dim selection
|
|
1034
|
+
document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
|
|
1035
|
+
function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
|
|
1036
|
+
document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
|
|
903
1037
|
document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
|
|
904
1038
|
if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
|
|
905
1039
|
setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');updDup();},1500);return;}
|
|
906
|
-
if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
|
|
1040
|
+
if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
|
|
1041
|
+
document.getElementById('revB').onclick=()=>{const ids=reversedBeams(); // beams drawn against the canonical direction
|
|
1042
|
+
if(!ids.length){const b=document.getElementById('revB');b.dataset.flash='1';b.classList.add('ok');b.textContent='All aligned ✓';
|
|
1043
|
+
setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');b.textContent='Reversed beams';},1500);return;}
|
|
1044
|
+
if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();
|
|
1045
|
+
toast(ids.length+' reversed beam'+(ids.length>1?'s':'')+' selected — press P to swap direction');}; // select reversed → review → P swaps them
|
|
907
1046
|
document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members); // conservative; result is undoable (Ctrl+Z)
|
|
908
1047
|
if(!r.mergedAway){toast('Nothing to merge');return;}
|
|
909
|
-
edit(()=>{P.members=r.members;selIds=new Set();});
|
|
1048
|
+
edit(()=>{P.members=r.members;selIds=new Set();selDimIds.clear();});
|
|
910
1049
|
toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
|
|
911
1050
|
document.getElementById('undoB').onclick=doUndo;
|
|
912
1051
|
document.getElementById('redoB').onclick=doRedo;
|
|
@@ -921,8 +1060,14 @@ addEventListener('keydown',e=>{
|
|
|
921
1060
|
if(e.key==='Escape'&&confOpen()){closeConf();return;}
|
|
922
1061
|
if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
|
|
923
1062
|
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)
|
|
1063
|
+
if(e.key==='Escape'&&dimMode){if(dimDraft){dimDraft=null;dimPrevClear();}else{dimMode=false;setDimMode();}render();return;} // 1st Esc drops the in-progress dim, 2nd exits the tool
|
|
924
1064
|
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;}
|
|
925
|
-
if((e.key==='Delete'||e.key==='Backspace')&&selIds.size&&!inForm){e.preventDefault();edit(()=>{
|
|
1065
|
+
if((e.key==='Delete'||e.key==='Backspace')&&(selDimIds.size||selIds.size)&&!inForm){e.preventDefault();edit(()=>{ // deletes everything selected — dims and/or members (a marquee can hold both)
|
|
1066
|
+
if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
|
|
1067
|
+
if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
|
|
1068
|
+
if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
|
|
1069
|
+
if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
|
|
1070
|
+
if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}}
|
|
926
1071
|
if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
|
|
927
1072
|
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
|
|
928
1073
|
if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
|
|
@@ -931,7 +1076,7 @@ addEventListener('keydown',e=>{
|
|
|
931
1076
|
if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
|
|
932
1077
|
else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
|
|
933
1078
|
else if(k==='d'&&selIds.size){e.preventDefault();doDup();}
|
|
934
|
-
else if(k==='a'&&!inForm){e.preventDefault();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
1079
|
+
else if(k==='a'&&!inForm){e.preventDefault();selDimIds.clear();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
935
1080
|
else if(k==='s'){e.preventDefault();persist();(window.flushContract?window.flushContract():Promise.resolve()).then(()=>toast('Saved ✓')).catch(()=>toast('Save failed'));}}); // edits autosave; Ctrl+S just flushes now (no browser "save page" dialog)
|
|
936
1081
|
// zoom bar
|
|
937
1082
|
document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
|
|
@@ -950,8 +1095,8 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
|
|
|
950
1095
|
const view3dApi={
|
|
951
1096
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
952
1097
|
fetchScene:async()=>{const r=await fetch('/api/contract/'+encodeURIComponent(APP_ID)+'/scene',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({contract:C})});const j=await r.json();if(!j.ok)throw new Error(j.error||'scene render failed');return j.scene;}, // endpoint shape: {ok, scene, skipped}
|
|
953
|
-
onSelect:(id,additive)=>{if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection
|
|
954
|
-
onSelectMany:(ids)=>{selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
|
|
1098
|
+
onSelect:(id,additive)=>{selDimIds.clear();if(!id){selIds=new Set();}else if(additive){selIds.has(id)?selIds.delete(id):selIds.add(id);}else{selIds=new Set([id]);}render();}, // 3D pick → shared selection (clears any 2D dim selection)
|
|
1099
|
+
onSelectMany:(ids)=>{selDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
|
|
955
1100
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
956
1101
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
957
1102
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
@@ -1143,7 +1288,7 @@ function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList
|
|
|
1143
1288
|
:'<div class=hint>No unresolved members — every member has a resolved AISC size. ✓</div>';
|
|
1144
1289
|
g.querySelectorAll('input.combo[data-mid]').forEach(inp=>inp.onchange=e=>{const m=byId(inp.dataset.mid);if(!m)return;
|
|
1145
1290
|
const v=e.target.value.toUpperCase().replace(/ /g,'');edit(()=>{m.profile=v;m.rfi=(WT[v]==null);if(v&&!profs.includes(v)){profs.push(v);profs.sort();}});openRFI();});
|
|
1146
|
-
g.querySelectorAll('button[data-loc]').forEach(b=>b.onclick=()=>{const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeRFI();render();zoomToMember(m);});
|
|
1291
|
+
g.querySelectorAll('button[data-loc]').forEach(b=>b.onclick=()=>{const m=byId(b.dataset.loc);if(!m)return;selDimIds.clear();selIds=new Set([m.id]);geoMode=null;setGeo();closeRFI();render();zoomToMember(m);});
|
|
1147
1292
|
document.getElementById('rfiModal').style.display='flex';}
|
|
1148
1293
|
function closeRFI(){document.getElementById('rfiModal').style.display='none';}
|
|
1149
1294
|
function rfiOpen(){return document.getElementById('rfiModal').style.display==='flex';}
|
|
@@ -1250,7 +1395,7 @@ function wireConf(){
|
|
|
1250
1395
|
document.querySelectorAll('#confFilter [data-band]').forEach(b=>b.onclick=()=>{confBand=b.dataset.band;renderConf();});
|
|
1251
1396
|
document.querySelectorAll('#confFilter [data-fcat]').forEach(b=>b.onclick=()=>{confCat=b.dataset.fcat;renderConf();});
|
|
1252
1397
|
document.querySelectorAll('#confBody tr.confrow').forEach(r=>r.onclick=e=>{if(e.target.closest('button'))return;const id=r.dataset.mid;confExpand=confExpand===id?null:id;renderConf();});
|
|
1253
|
-
document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
|
|
1398
|
+
document.querySelectorAll('#confBody button[data-loc]').forEach(b=>b.onclick=e=>{e.stopPropagation();const pi=+b.dataset.pi;if(pi!==C.active)setPlan(pi);const m=byId(b.dataset.loc);if(!m)return;selDimIds.clear();selIds=new Set([m.id]);geoMode=null;setGeo();closeConf();render();zoomToMember(m);});}
|
|
1254
1399
|
document.getElementById('confStat').onclick=openConf;
|
|
1255
1400
|
document.getElementById('confTarget').onchange=e=>{ // per-read target override → contract.target_confidence (never the .flo)
|
|
1256
1401
|
const raw=String(e.target.value).trim();
|
|
@@ -1320,6 +1465,7 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
|
|
|
1320
1465
|
function setPlan(i){C.active=i;P=C.plans[i];
|
|
1321
1466
|
if(!Array.isArray(P.members))P.members=[];
|
|
1322
1467
|
if(!Array.isArray(P.segments))P.segments=[];
|
|
1468
|
+
if(!Array.isArray(P.dims))P.dims=[];
|
|
1323
1469
|
// clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
|
|
1324
1470
|
// committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
|
|
1325
1471
|
// box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
|
|
@@ -1342,6 +1488,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1342
1488
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
1343
1489
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
1344
1490
|
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
1491
|
+
dimMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan; setDimMode syncs the button/body.dimon classes + clears any draft/preview (dimsVisible persists across plans)
|
|
1345
1492
|
defaultTOS=(P.default_tos!=null?P.default_tos:198);
|
|
1346
1493
|
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)
|
|
1347
1494
|
if(!P.autofilled){autofillTOS();P.autofilled=true;}
|