@floless/app 0.37.1 → 0.39.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.39.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.39.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,21 @@
|
|
|
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} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
|
|
150
|
+
circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
|
|
151
|
+
.dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
|
|
152
|
+
body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
|
|
144
153
|
body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
|
|
145
154
|
body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
|
|
146
155
|
circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
147
156
|
text.numtx{fill:#fff;font:bold 12px system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
|
|
148
157
|
#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}
|
|
158
|
+
#dupB.ok,#revB.ok{background:#166534;border-color:#16a34a;color:#dcfce7}
|
|
150
159
|
header select#planSel{width:auto;min-width:210px;padding:5px 8px;font-weight:600}
|
|
151
160
|
/* 2D|3D view toggle — segmented, far-left; active segment filled (--panel) with a --brand underline */
|
|
152
161
|
#viewToggle{display:inline-flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;flex:none}
|
|
@@ -188,12 +197,17 @@
|
|
|
188
197
|
<button id=undoB title="Undo (Ctrl+Z)">↶</button>
|
|
189
198
|
<button id=redoB title="Redo (Ctrl+Y / Ctrl+Shift+Z)">↷</button>
|
|
190
199
|
<button id=mAdd title="Toggle add-member mode">Add member</button>
|
|
200
|
+
<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
201
|
<button id=askAiBtn>Ask AI ▸</button>
|
|
192
202
|
<div id=moreWrap>
|
|
193
203
|
<button id=moreBtn title="More actions" aria-haspopup=menu aria-expanded=false aria-label="More actions">⋯</button>
|
|
194
204
|
<div id=moreMenu role=menu>
|
|
205
|
+
<div class=mlabel>Dimensions</div>
|
|
206
|
+
<button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
|
|
207
|
+
<hr>
|
|
195
208
|
<div class=mlabel>Review</div>
|
|
196
209
|
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
210
|
+
<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
211
|
<button id=mrgB title="Merge collinear segments — joins same-profile, end-to-end runs into one member. Undoable.">Merge collinear</button>
|
|
198
212
|
<hr>
|
|
199
213
|
<div class=mlabel>Reference</div>
|
|
@@ -397,6 +411,8 @@ function showAiUpdateBanner(message) {
|
|
|
397
411
|
function main() {
|
|
398
412
|
let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
|
|
399
413
|
let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
|
|
414
|
+
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
|
|
415
|
+
let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off}, and a counter for unique ids on rapid clicks
|
|
400
416
|
let selIds=new Set();
|
|
401
417
|
let undo=[], redo=[];
|
|
402
418
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
@@ -448,9 +464,15 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
|
448
464
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
449
465
|
const autofillTOS=syncDefaults;
|
|
450
466
|
// profs is per-plan — computed in setPlan()
|
|
451
|
-
function snapshot(){return JSON.stringify(P.members);}
|
|
467
|
+
function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
|
|
452
468
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
453
|
-
function apply(json){
|
|
469
|
+
function apply(json){const d=JSON.parse(json);
|
|
470
|
+
if(Array.isArray(d))P.members=d; // legacy member-only snapshot (e.g. the auto-dedupe push in setPlan)
|
|
471
|
+
else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;}
|
|
472
|
+
selIds=new Set([...selIds].filter(id=>byId(id)));
|
|
473
|
+
selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
|
|
474
|
+
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)
|
|
475
|
+
scheduleSave();render();sync3D();}
|
|
454
476
|
function doUndo(){if(!undo.length)return;redo.push(snapshot());apply(undo.pop());}
|
|
455
477
|
function doRedo(){if(!redo.length)return;undo.push(snapshot());apply(redo.pop());}
|
|
456
478
|
function edit(fn){const pv=snapshot();fn();pushUndo(pv);render();sync3D();}
|
|
@@ -465,7 +487,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
465
487
|
else el.textContent=msg||'Auto-save on';}
|
|
466
488
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
467
489
|
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);}}
|
|
490
|
+
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
491
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
470
492
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
471
493
|
// fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
|
|
@@ -494,7 +516,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
494
516
|
if(d.custom_details)C.custom_details=d.custom_details;
|
|
495
517
|
if(d.profile_colors)C.profile_colors=d.profile_colors;
|
|
496
518
|
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;}});
|
|
519
|
+
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
520
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
499
521
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
500
522
|
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 +527,48 @@ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y
|
|
|
505
527
|
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
|
|
506
528
|
function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
|
|
507
529
|
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};}
|
|
530
|
+
// --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
|
|
531
|
+
// y -> the dim-line X; free -> signed perpendicular distance from the a->b baseline. ---
|
|
532
|
+
function dimGeo(a,b,axis,off){
|
|
533
|
+
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])};}
|
|
534
|
+
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])};}
|
|
535
|
+
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)
|
|
536
|
+
const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
|
|
537
|
+
return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
|
|
538
|
+
// measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
|
|
539
|
+
function dimValueText(px){return fmtFtIn(px/FT*12);}
|
|
540
|
+
// the offset a click at pointer p implies, given the two anchors + axis.
|
|
541
|
+
function dimOffFromPointer(a,b,axis,p){
|
|
542
|
+
if(axis==='x')return p[1];
|
|
543
|
+
if(axis==='y')return p[0];
|
|
544
|
+
const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
|
|
545
|
+
return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
|
|
546
|
+
// a sensible offset for the stage-1 preview (before the 3rd click picks one): clear of the anchors by a constant screen distance.
|
|
547
|
+
function dimDefaultOff(a,b,axis){
|
|
548
|
+
if(axis==='x')return Math.min(a[1],b[1])-28/zoom; // X dim line sits above the higher anchor
|
|
549
|
+
if(axis==='y')return Math.min(a[0],b[0])-28/zoom; // Y dim line sits left of the leftmost anchor
|
|
550
|
+
return -28/zoom;} // free: a constant perpendicular offset
|
|
551
|
+
// one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
|
|
552
|
+
// screen); the chip rect/text + end dots are sized in 1/zoom and re-synced by updateBadges on zoom.
|
|
553
|
+
function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off);
|
|
554
|
+
const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
|
|
555
|
+
const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
|
|
556
|
+
// a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
|
|
557
|
+
// horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
|
|
558
|
+
// 8° of horizontal it stays flat.
|
|
559
|
+
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;}
|
|
560
|
+
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]}"/>`;
|
|
561
|
+
s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
|
|
562
|
+
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}"/>`;
|
|
563
|
+
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}"/>`
|
|
564
|
+
+`<text class="dimtx ${c}" x="${g.mid[0]}" y="${g.mid[1]}" style="font-size:${13/zoom}px">${txt}</text>`;
|
|
565
|
+
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
|
|
566
|
+
return s;}
|
|
567
|
+
function renderDims(){if(!dimsVisible||!Array.isArray(P.dims))return '';
|
|
568
|
+
let s=P.dims.map(d=>dimSvg(d,{id:d.id,sel:selDimIds.has(d.id)})).join('');
|
|
569
|
+
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)
|
|
570
|
+
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}"/>`;
|
|
571
|
+
return s;}
|
|
508
572
|
function setGeo(){document.body.classList.toggle('geo',!!geoMode);}
|
|
509
573
|
// 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
574
|
// Multi: trim/extend the nearest end of EVERY given member to the (tA,tB) target line; members
|
|
@@ -518,7 +582,7 @@ function doSplit(m,pt){const pv=snapshot();ensureMeta(m);const base=JSON.parse(J
|
|
|
518
582
|
m.wp=[a,q];if(m.ends)m.ends=[m.ends[0],mk()]; // first half keeps its start end, fresh cut end
|
|
519
583
|
const c=JSON.parse(JSON.stringify(base));c.id='m'+Date.now();c.wp=[q.slice(),b];
|
|
520
584
|
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();}
|
|
585
|
+
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
586
|
// --- duplicates: members with coincident geometry (same two work-points, order-independent, ~3px tol) ---
|
|
523
587
|
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
588
|
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 +608,15 @@ function _mAble(a,b){if((a.profile||'').toUpperCase()!==(b.profile||'').toUpperC
|
|
|
544
608
|
const s=_mShare(a,b);if(!s)return null;return _mStraight(a,b,s)&&_mElev(a,b,s)?s:null;}
|
|
545
609
|
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
610
|
return dy<=dx*t?'H':dx<=dy*t?'V':'D';}
|
|
611
|
+
// A beam is drawn the canonical way when its work-line runs left→right (mostly-horizontal) or
|
|
612
|
+
// bottom→up (steeper/skew). Display space is y-down, so "up" = a smaller y. Returns true when
|
|
613
|
+
// start→end points the WRONG way — swapping its ends (P) would make it canonical. 45° split:
|
|
614
|
+
// |run| ≥ |rise| ⇒ judge left/right, else up/down. (A zero-length beam has no direction.)
|
|
615
|
+
function isReversed(m){if(!m||!m.wp||m.wp.length<2)return false;
|
|
616
|
+
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;
|
|
617
|
+
return Math.abs(dx)>=Math.abs(dy)?dx<0:dy>0;}
|
|
618
|
+
// Ids of the active plan's beams (not columns) drawn the wrong way — the "Reversed beams" selection.
|
|
619
|
+
function reversedBeams(){return P.members.filter(m=>m.role!=='column'&&isReversed(m)).map(m=>m.id);}
|
|
547
620
|
function mergeCollinearJS(members){
|
|
548
621
|
const idx=members.map((_,i)=>i).filter(i=>_mElig(members[i]));
|
|
549
622
|
const adj=new Map();for(const i of idx)adj.set(i,[]);
|
|
@@ -593,7 +666,7 @@ function fitToWindow(){applyZoom(Math.min(stage.clientWidth/EXTX,stage.clientHei
|
|
|
593
666
|
function doDup(){const arr=selArr();if(!arr.length)return;const o=12,pv=snapshot(),ns=new Set();
|
|
594
667
|
let i=0;for(const s of arr){const id='m'+Date.now()+'_'+(i++);const c=JSON.parse(JSON.stringify(s));
|
|
595
668
|
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();}
|
|
669
|
+
selIds=ns;selDimIds.clear();mode='sel';setMode();pushUndo(pv);render();}
|
|
597
670
|
function esc(s){return String(s).replace(/[<>&"]/g,c=>({'<':'<','>':'>','&':'&','"':'"'}[c]));}
|
|
598
671
|
function render(){
|
|
599
672
|
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 +686,24 @@ function render(){
|
|
|
613
686
|
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
687
|
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
688
|
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>`;});}}
|
|
689
|
+
s+=renderDims();
|
|
616
690
|
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
691
|
if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
|
|
618
692
|
}
|
|
619
693
|
function updDup(){const n=redundantDups().length;
|
|
620
694
|
document.getElementById('dpc').textContent=n;document.getElementById('dupStat').classList.toggle('has',n>0);
|
|
621
695
|
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+')'):'
|
|
696
|
+
b.disabled=false;b.classList.toggle('alert',n>0);b.textContent=n>0?('Duplicates ('+n+')'):'Check duplicates';}
|
|
623
697
|
// keep selection-number badges constant on screen while zooming; the end dots grow with zoom (epR) so
|
|
624
698
|
// they stay readable against the thick member line — both must track zoom live as the user zooms.
|
|
625
699
|
function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.dataset.gn-1)/2)*R*2;
|
|
626
700
|
svg.querySelectorAll('circle.numbg').forEach(c=>{c.setAttribute('cx',(+c.dataset.bx)+ox(c));c.setAttribute('r',R);});
|
|
627
701
|
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()));
|
|
702
|
+
svg.querySelectorAll('circle.handle,circle.dimhandle').forEach(h=>h.setAttribute('r',epR()));
|
|
703
|
+
const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
|
|
704
|
+
svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
|
|
705
|
+
svg.querySelectorAll('rect.dimchip').forEach(r=>{const w=(+r.dataset.w)/zoom,h=(+r.dataset.h)/zoom;
|
|
706
|
+
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
707
|
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
708
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
631
709
|
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 +718,18 @@ const VARIES=Symbol('varies');
|
|
|
640
718
|
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
719
|
function panel(){
|
|
642
720
|
const p=document.getElementById('panel');
|
|
721
|
+
if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
|
|
722
|
+
<div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
|
|
723
|
+
<div class=sect style="margin-top:12px">Direction</div>
|
|
724
|
+
<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>
|
|
725
|
+
<div class=sect style="margin-top:12px">Mode</div>
|
|
726
|
+
<div class=seg2><button id=dimChainB class="${dimChain?'on':''}" title="Chain (C) — keep adding dimensions from the last point; Esc/Enter ends the chain">⛓ Chain</button></div>
|
|
727
|
+
<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>`;
|
|
728
|
+
document.getElementById('dimAxFree').onclick=()=>{dimSetAxis('free');dimRefreshPrev();panel();};
|
|
729
|
+
document.getElementById('dimAxX').onclick=()=>{dimSetAxis('x');dimRefreshPrev();panel();};
|
|
730
|
+
document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
|
|
731
|
+
document.getElementById('dimChainB').onclick=()=>toggleDimChain();
|
|
732
|
+
return;}
|
|
643
733
|
if(mode==='add'){
|
|
644
734
|
p.innerHTML=`<span class=badge>Add member</span>
|
|
645
735
|
<div class=sect>Current profile</div>
|
|
@@ -823,12 +913,73 @@ function snap(x,y){const tol=SNAP_PX/zoom;let bp=null,bd=tol;
|
|
|
823
913
|
return {x:sx,y:sy,hit:false};}
|
|
824
914
|
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
915
|
function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove();}
|
|
916
|
+
// --- Dimension tool: armed mode + 3-click placement (anchor, anchor, offset). Shares the editor's
|
|
917
|
+
// buildSnap/snap/snapMark stack. The in-progress preview lives in its own <g> (render() rebuilds
|
|
918
|
+
// svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
|
|
919
|
+
const SVGNS='http://www.w3.org/2000/svg';
|
|
920
|
+
function setDimMode(){document.body.classList.toggle('dimon',dimMode);document.getElementById('dimB').classList.toggle('on',dimMode);
|
|
921
|
+
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
|
|
922
|
+
dimDraft=null;dimChainPrev=null;dimPrevClear();}
|
|
923
|
+
function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
|
|
924
|
+
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};}
|
|
925
|
+
return {x,y,raw:[q.x,q.y],hit:false};}
|
|
926
|
+
function dimClick(e){const s=dimSnapAt(e);
|
|
927
|
+
if(dimChainPrev){const a=dimChainPrev.point,b=[s.x,s.y],axis=dimChainPrev.axis,off=dimChainPrev.off; // CHAIN: each click adds the next dim from the previous point, on the same axis + offset line
|
|
928
|
+
const span=axis==='x'?Math.abs(b[0]-a[0]):axis==='y'?Math.abs(b[1]-a[1]):Math.hypot(b[0]-a[0],b[1]-a[1]);
|
|
929
|
+
if(span<1){dimPrev(e);return;} // ignore a zero / sub-pixel click — don't commit a degenerate dim
|
|
930
|
+
edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off});});
|
|
931
|
+
dimChainPrev={point:b,axis,off};dimPrev(e);return;} // keep going from b; Esc/Enter ends the chain
|
|
932
|
+
if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
|
|
933
|
+
if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
|
|
934
|
+
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
|
|
935
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw); // click 3 → offset/side from raw pointer
|
|
936
|
+
const a=dimDraft.a,b=dimDraft.b;
|
|
937
|
+
edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off});}); // undoable; render() clears the preview
|
|
938
|
+
if(dimChain)dimChainPrev={point:b,axis,off}; // chain mode → continue from b along this same offset line
|
|
939
|
+
dimDraft=null;}
|
|
940
|
+
function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
|
|
941
|
+
function dimPrev(e){if(!dimMode){dimPrevClear();return;}
|
|
942
|
+
{const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();} // keep the panel hint live (chain / stage state)
|
|
943
|
+
if(dimChainPrev){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear(); // chain: preview the next segment from the last point at the shared axis + offset
|
|
944
|
+
let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
|
|
945
|
+
g.innerHTML=dimSvg({a:dimChainPrev.point,b:[s.x,s.y],axis:dimChainPrev.axis,off:dimChainPrev.off},{preview:true});return;}
|
|
946
|
+
if(!dimDraft){const s=dimSnapAt(e);s.hit?snapMark(s.x,s.y):snapClear();const og=document.getElementById('dimPrevG');if(og)og.remove();return;} // before click 1: show the snap marker for the prospective FIRST anchor (no dim drawn yet)
|
|
947
|
+
let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
|
|
948
|
+
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
|
|
949
|
+
const b=[s.x,s.y];dimLastPtr=b;
|
|
950
|
+
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)
|
|
951
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax)},{preview:true});return;}
|
|
952
|
+
const q=toSvg(e);dimLastPtr=[q.x,q.y];snapClear();
|
|
953
|
+
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
|
|
954
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y]); // stage 2: full dim rides the pointer
|
|
955
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off},{preview:true});dimDraft._ax=ax;}
|
|
956
|
+
function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
|
|
957
|
+
const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
|
|
958
|
+
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)
|
|
959
|
+
function dimHintText(){if(dimChainPrev)return 'Chaining — click the next point to add another dimension from the last one. Enter or Esc ends the chain.';
|
|
960
|
+
const a={free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
|
|
961
|
+
return 'Dimension — '+a+(dimChain?' · Chain ON':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
|
|
962
|
+
function toggleDimChain(){dimChain=!dimChain;if(!dimChain){dimChainPrev=null;dimPrevClear();}render();} // Chain (C): toggle continuous dimensioning; turning it off ends any running chain (render() repaints the panel)
|
|
963
|
+
let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
|
|
964
|
+
function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
|
|
965
|
+
const g=document.getElementById('dimPrevG');if(!g)return;
|
|
966
|
+
if(!dimDraft.b){const b=dimLastPtr; // stage 1: redraw the forming dim with the just-changed axis (key/button), no mouse move needed
|
|
967
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis)},{preview:true});return;}
|
|
968
|
+
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
|
|
969
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off},{preview:true});}
|
|
826
970
|
// themed transient toast (baseline tokens — never a native alert)
|
|
827
971
|
function toast(msg){let t=document.getElementById('toast');
|
|
828
972
|
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
973
|
t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
|
|
830
974
|
// --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
|
|
831
975
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
976
|
+
if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
|
|
977
|
+
if(t.dataset.dimend!=null&&mode==='sel'){const id=t.dataset.dim,end=+t.dataset.dimend;buildSnap(null);drag={type:'dimend',id,end,pre:snapshot()};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} // drag a selected dim's anchor handle → re-measure (snaps)
|
|
978
|
+
if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
|
|
979
|
+
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
|
|
980
|
+
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)
|
|
981
|
+
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
|
|
982
|
+
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
983
|
if(geoMode==='split'&&selIds.size===1){const id=[...selIds][0],m=byId(id);
|
|
833
984
|
const q=toSvg(e),raw=projPt([q.x,q.y],m.wp[0],m.wp[1]);
|
|
834
985
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]); // clicked on the member?
|
|
@@ -858,14 +1009,17 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
858
1009
|
const p=toSvg(e),items=selArr().map(m=>({id:m.id,o0:m.wp[0].slice(),o1:m.wp[1].slice()}));
|
|
859
1010
|
buildSnap(new Set(items.map(it=>it.id))); // snap a dragged line to other members'/segments' endpoints (grid lines)
|
|
860
1011
|
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);
|
|
1012
|
+
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
1013
|
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
863
1014
|
drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
864
1015
|
svg.addEventListener('pointermove',e=>{
|
|
1016
|
+
if(dimMode){dimPrev(e);return;}
|
|
865
1017
|
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
1018
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
|
|
867
1019
|
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
1020
|
if(!drag)return;const p=toSvg(e);
|
|
1021
|
+
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
|
|
1022
|
+
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
1023
|
if(drag.type==='draw'){let x=p.x,y=p.y;
|
|
870
1024
|
if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
|
|
871
1025
|
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 +1048,28 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
894
1048
|
const id='m'+Date.now(),pv=snapshot();P.members.push(ensureMeta({id,profile:addProfile,wp:[a,b],angle:o,rfi:(WT[addProfile]==null)}));
|
|
895
1049
|
if(addProfile&&!profs.includes(addProfile)){profs.push(addProfile);profs.sort();}pushUndo(pv);}
|
|
896
1050
|
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);
|
|
1051
|
+
if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
|
|
1052
|
+
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);
|
|
1053
|
+
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
1054
|
drag.rect.remove();drag=null;render();return;}
|
|
900
1055
|
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
|
|
901
1056
|
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();};
|
|
1057
|
+
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
|
|
1058
|
+
document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
|
|
1059
|
+
function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
|
|
1060
|
+
document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
|
|
903
1061
|
document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
|
|
904
1062
|
if(!ids.length){const b=document.getElementById('dupB');b.dataset.flash='1';b.classList.add('ok');b.textContent='No duplicates ✓';
|
|
905
1063
|
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
|
|
1064
|
+
if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();}; // select the redundant copies → review (numbered) → Delete
|
|
1065
|
+
document.getElementById('revB').onclick=()=>{const ids=reversedBeams(); // beams drawn against the canonical direction
|
|
1066
|
+
if(!ids.length){const b=document.getElementById('revB');b.dataset.flash='1';b.classList.add('ok');b.textContent='All aligned ✓';
|
|
1067
|
+
setTimeout(()=>{delete b.dataset.flash;b.classList.remove('ok');b.textContent='Reversed beams';},1500);return;}
|
|
1068
|
+
if(mode==='add'){mode='sel';setMode();}geoMode=null;setGeo();selDimIds.clear();selIds=new Set(ids);fitToWindow();render();
|
|
1069
|
+
toast(ids.length+' reversed beam'+(ids.length>1?'s':'')+' selected — press P to swap direction');}; // select reversed → review → P swaps them
|
|
907
1070
|
document.getElementById('mrgB').onclick=()=>{const r=mergeCollinearJS(P.members); // conservative; result is undoable (Ctrl+Z)
|
|
908
1071
|
if(!r.mergedAway){toast('Nothing to merge');return;}
|
|
909
|
-
edit(()=>{P.members=r.members;selIds=new Set();});
|
|
1072
|
+
edit(()=>{P.members=r.members;selIds=new Set();selDimIds.clear();});
|
|
910
1073
|
toast('Merged '+r.mergedAway+' segment'+(r.mergedAway===1?'':'s')+' into '+r.runs+' member'+(r.runs===1?'':'s'));};
|
|
911
1074
|
document.getElementById('undoB').onclick=doUndo;
|
|
912
1075
|
document.getElementById('redoB').onclick=doRedo;
|
|
@@ -921,8 +1084,16 @@ addEventListener('keydown',e=>{
|
|
|
921
1084
|
if(e.key==='Escape'&&confOpen()){closeConf();return;}
|
|
922
1085
|
if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
|
|
923
1086
|
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)
|
|
1087
|
+
if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
|
|
1088
|
+
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
|
|
924
1089
|
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(()=>{
|
|
1090
|
+
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)
|
|
1091
|
+
if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
|
|
1092
|
+
if(selIds.size){P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();}});return;}
|
|
1093
|
+
if(!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='d'){e.preventDefault();dimMode=!dimMode;setDimMode();render();return;} // D — toggle the Dimension tool
|
|
1094
|
+
if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
|
|
1095
|
+
if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
|
|
1096
|
+
if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
|
|
926
1097
|
if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
|
|
927
1098
|
if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
|
|
928
1099
|
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 +1102,7 @@ addEventListener('keydown',e=>{
|
|
|
931
1102
|
if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
|
|
932
1103
|
else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
|
|
933
1104
|
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();}
|
|
1105
|
+
else if(k==='a'&&!inForm){e.preventDefault();selDimIds.clear();selIds=new Set(P.members.map(m=>m.id));render();}
|
|
935
1106
|
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
1107
|
// zoom bar
|
|
937
1108
|
document.getElementById('zIn').onclick=()=>zoomCentered(zoom*1.25);
|
|
@@ -950,8 +1121,8 @@ moreMenu.addEventListener('click',e=>{if(e.target.closest('button'))closeMore();
|
|
|
950
1121
|
const view3dApi={
|
|
951
1122
|
// POST the in-progress contract so the model reflects unsaved edits; throws on a bad contract.
|
|
952
1123
|
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
|
|
1124
|
+
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)
|
|
1125
|
+
onSelectMany:(ids)=>{selDimIds.clear();selIds=new Set(ids||[]);if(mode==='add'){mode='sel';setMode();}render();}, // 3D box-select → shared selection
|
|
955
1126
|
getMembers:()=>P.members, // raw members (wp) for snap geometry
|
|
956
1127
|
ptPerFt:()=>(P&&P.pt_per_ft>0?P.pt_per_ft:1),
|
|
957
1128
|
defaultTosMm:()=>(defaultTOS!=null?defaultTOS*25.4:0), // editor stores default T.O.S in inches
|
|
@@ -1143,7 +1314,7 @@ function openRFI(){const g=document.getElementById('rfiGrid');const list=rfiList
|
|
|
1143
1314
|
:'<div class=hint>No unresolved members — every member has a resolved AISC size. ✓</div>';
|
|
1144
1315
|
g.querySelectorAll('input.combo[data-mid]').forEach(inp=>inp.onchange=e=>{const m=byId(inp.dataset.mid);if(!m)return;
|
|
1145
1316
|
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);});
|
|
1317
|
+
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
1318
|
document.getElementById('rfiModal').style.display='flex';}
|
|
1148
1319
|
function closeRFI(){document.getElementById('rfiModal').style.display='none';}
|
|
1149
1320
|
function rfiOpen(){return document.getElementById('rfiModal').style.display==='flex';}
|
|
@@ -1250,7 +1421,7 @@ function wireConf(){
|
|
|
1250
1421
|
document.querySelectorAll('#confFilter [data-band]').forEach(b=>b.onclick=()=>{confBand=b.dataset.band;renderConf();});
|
|
1251
1422
|
document.querySelectorAll('#confFilter [data-fcat]').forEach(b=>b.onclick=()=>{confCat=b.dataset.fcat;renderConf();});
|
|
1252
1423
|
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);});}
|
|
1424
|
+
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
1425
|
document.getElementById('confStat').onclick=openConf;
|
|
1255
1426
|
document.getElementById('confTarget').onchange=e=>{ // per-read target override → contract.target_confidence (never the .flo)
|
|
1256
1427
|
const raw=String(e.target.value).trim();
|
|
@@ -1320,6 +1491,7 @@ document.addEventListener('keydown',e=>{if(!comboInput||comboPop.style.display==
|
|
|
1320
1491
|
function setPlan(i){C.active=i;P=C.plans[i];
|
|
1321
1492
|
if(!Array.isArray(P.members))P.members=[];
|
|
1322
1493
|
if(!Array.isArray(P.segments))P.segments=[];
|
|
1494
|
+
if(!Array.isArray(P.dims))P.dims=[];
|
|
1323
1495
|
// clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
|
|
1324
1496
|
// committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
|
|
1325
1497
|
// box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
|
|
@@ -1342,6 +1514,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1342
1514
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
1343
1515
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
1344
1516
|
selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
|
|
1517
|
+
dimMode=false;dimChain=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan; setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
1345
1518
|
defaultTOS=(P.default_tos!=null?P.default_tos:198);
|
|
1346
1519
|
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
1520
|
if(!P.autofilled){autofillTOS();P.autofilled=true;}
|