@floless/app 0.39.0 → 0.40.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.40.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.40.0" : void 0 });
|
|
52870
52870
|
}
|
|
52871
52871
|
|
|
52872
52872
|
// oauth-presets.ts
|
|
@@ -68,7 +68,8 @@
|
|
|
68
68
|
"details": { "type": "array", "items": { "$ref": "#/$defs/detail" } },
|
|
69
69
|
"tos_callouts": { "type": "array", "items": { "$ref": "#/$defs/tos_callout" } },
|
|
70
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
|
+
"dims": { "type": "array", "items": { "$ref": "#/$defs/dim" }, "description": "Draft-only on-drawing dimensions (editor annotations). NOT baked into the lock / 3D / IFC / BOM." },
|
|
72
|
+
"frame": { "$ref": "#/$defs/frame" }
|
|
72
73
|
}
|
|
73
74
|
},
|
|
74
75
|
"segment": {
|
|
@@ -90,8 +91,19 @@
|
|
|
90
91
|
"id": { "type": "string" },
|
|
91
92
|
"a": { "$ref": "#/$defs/point2", "description": "First snapped anchor, display space." },
|
|
92
93
|
"b": { "$ref": "#/$defs/point2", "description": "Second snapped anchor, display space." },
|
|
93
|
-
"axis": { "enum": ["free", "x", "y"], "description": "free = aligned distance hypot(b-a); x =
|
|
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." }
|
|
94
|
+
"axis": { "enum": ["free", "x", "y"], "description": "free = aligned distance hypot(b-a); x = along local-X; y = along local-Y. With no frame (rot 0) x/y are screen-horizontal/vertical." },
|
|
95
|
+
"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. Stored in the dim's own (rot-rotated) frame." },
|
|
96
|
+
"rot": { "type": "number", "description": "Local-frame bearing (radians) baked at creation; 0 = global axes. x/y measure along this rotated frame so resetting the plan frame never moves placed dims." }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
"frame": {
|
|
100
|
+
"type": ["object", "null"],
|
|
101
|
+
"description": "Optional per-plan local coordinate frame for skewed framing. null/absent = global axes. Affects only editor ortho-snapping + X/Y dimensions; NOT baked into the lock / 3D / IFC / BOM.",
|
|
102
|
+
"required": ["o", "u"],
|
|
103
|
+
"additionalProperties": true,
|
|
104
|
+
"properties": {
|
|
105
|
+
"o": { "$ref": "#/$defs/point2", "description": "Frame origin, display space (glyph anchor)." },
|
|
106
|
+
"u": { "$ref": "#/$defs/point2", "description": "Unit local-X direction [ux,uy], display space. Local-Y = [uy,-ux]." }
|
|
95
107
|
}
|
|
96
108
|
},
|
|
97
109
|
"label": {
|
|
@@ -73,6 +73,7 @@
|
|
|
73
73
|
.aithumb{position:relative;display:inline-flex} .aithumb img{height:60px;border-radius:4px;border:1px solid var(--line);background:#fff;display:block}
|
|
74
74
|
.aithumb button{position:absolute;top:-5px;right:-5px;width:16px;height:16px;padding:0;border-radius:8px;font-size:10px;line-height:16px;text-align:center;background:#7f1d1d;border-color:#991b1b;color:#fecaca}
|
|
75
75
|
#saveStat.dirty{color:#fbbf24} #saveStat.ok{color:#86efac} #saveStat.err{color:#fca5a5}
|
|
76
|
+
#csStat.local{border:1px solid #22d3ee;background:rgba(34,211,238,.10);border-radius:4px;padding:1px 6px} /* lift the chip out of the muted row when a local frame is active (reuses the dim/accent cyan — no new colour) */
|
|
76
77
|
#rfiStat{cursor:pointer;text-decoration:underline dotted} #rfiStat:hover b{color:#fff}
|
|
77
78
|
#confStat{cursor:pointer;text-decoration:underline dotted} #confStat:hover b{filter:brightness(1.25)}
|
|
78
79
|
.conf-cats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;padding:14px;border-bottom:1px solid var(--line)}
|
|
@@ -123,6 +124,8 @@
|
|
|
123
124
|
#moreMenu button:hover{background:#334155}
|
|
124
125
|
#moreMenu button.mdanger{color:#fca5a5}
|
|
125
126
|
#moreMenu button.mdanger:hover{background:#7f1d1d;color:#fecaca}
|
|
127
|
+
#moreMenu button:disabled{opacity:.4;cursor:default;background:transparent}
|
|
128
|
+
#moreMenu button.on{color:var(--brand)}
|
|
126
129
|
button:focus-visible{outline:2px solid var(--brand);outline-offset:2px}
|
|
127
130
|
#srcStat{max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
128
131
|
#detailNewForm input#dnName{margin:2px 0 10px}
|
|
@@ -150,6 +153,8 @@
|
|
|
150
153
|
circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
|
|
151
154
|
.dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
|
|
152
155
|
body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
|
|
156
|
+
body.csaxison #svg,body.csaxison line.member,body.csaxison line.seg,body.csaxison image{cursor:crosshair}
|
|
157
|
+
g.csglyph text{user-select:none}
|
|
153
158
|
body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
|
|
154
159
|
body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
|
|
155
160
|
circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
|
|
@@ -191,6 +196,7 @@
|
|
|
191
196
|
<span class=stat>Members <b id=mc>0</b></span><span class=stat>Weight <b id=wt>0</b> tons · <b id=wtlb>0</b> lb</span>
|
|
192
197
|
<span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
|
|
193
198
|
<span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
|
|
199
|
+
<span class=stat id=csStat title="Coordinate system — Global by default. Set a local frame from the ⋯ menu for skewed framing.">Axes <b>Global</b></span>
|
|
194
200
|
<span class=stat id=confStat title="Confidence report — AI-read score vs. target. Click to review element evidence." style="display:none">Confidence <b id=confPct>—</b><span id=confTgt></span></span>
|
|
195
201
|
<span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
|
|
196
202
|
<span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
|
|
@@ -205,6 +211,10 @@
|
|
|
205
211
|
<div class=mlabel>Dimensions</div>
|
|
206
212
|
<button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
|
|
207
213
|
<hr>
|
|
214
|
+
<div class=mlabel>Coordinate system</div>
|
|
215
|
+
<button id=csSetB title="Define a local X axis by clicking two points (origin, then X-direction). Y follows at 90°. Orthogonal drawing and X/Y dimensions then snap to this frame — for skewed framing.">↺ Set local axes…</button>
|
|
216
|
+
<button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
|
|
217
|
+
<hr>
|
|
208
218
|
<div class=mlabel>Review</div>
|
|
209
219
|
<button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
|
|
210
220
|
<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>
|
|
@@ -412,7 +422,8 @@ function main() {
|
|
|
412
422
|
let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
|
|
413
423
|
let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
|
|
414
424
|
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
|
|
425
|
+
let csaxisMode=false, csDraft=null; // Local coordinate system "set axes" tool: armed flag + in-progress origin [x,y] (null until click 1). P.frame={o,u} holds the committed local frame (null = global).
|
|
426
|
+
let dimChain=false, dimChainPrev=null, dimSeq=0; // chained "continuous" dimensioning: toggle, the running {point,axis,off,rot}, and a counter for unique ids on rapid clicks
|
|
416
427
|
let selIds=new Set();
|
|
417
428
|
let undo=[], redo=[];
|
|
418
429
|
const byId=id=>P.members.find(m=>m.id===id);
|
|
@@ -464,11 +475,12 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
|
|
|
464
475
|
else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
|
|
465
476
|
const autofillTOS=syncDefaults;
|
|
466
477
|
// profs is per-plan — computed in setPlan()
|
|
467
|
-
function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
|
|
478
|
+
function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});}
|
|
468
479
|
function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
|
|
469
480
|
function apply(json){const d=JSON.parse(json);
|
|
470
481
|
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;}
|
|
482
|
+
else{P.members=d.members||[];if(Array.isArray(d.dims))P.dims=d.dims;if('frame' in d)P.frame=d.frame||null;} // 'frame' present → restore (incl. null = global); absent (legacy snapshot) leaves it alone
|
|
483
|
+
updCS();
|
|
472
484
|
selIds=new Set([...selIds].filter(id=>byId(id)));
|
|
473
485
|
selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
|
|
474
486
|
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)
|
|
@@ -487,7 +499,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
|
|
|
487
499
|
else el.textContent=msg||'Auto-save on';}
|
|
488
500
|
function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
|
|
489
501
|
custom_details:C.custom_details, profile_colors:C.profile_colors, target_confidence:C.target_confidence,
|
|
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);}}
|
|
502
|
+
plans:C.plans.map(p=>({sheet:p.sheet,members:p.members,default_tos:p.default_tos,details:p.details,dims:p.dims,frame:p.frame||null}))}));setSaved('ok');}catch(e){setSaved('err');console.error('local autosave failed',e);}}
|
|
491
503
|
// --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
|
|
492
504
|
// localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
|
|
493
505
|
// fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
|
|
@@ -516,7 +528,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
|
|
|
516
528
|
if(d.custom_details)C.custom_details=d.custom_details;
|
|
517
529
|
if(d.profile_colors)C.profile_colors=d.profile_colors;
|
|
518
530
|
if(d.target_confidence!=null)C.target_confidence=d.target_confidence;
|
|
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;}});
|
|
531
|
+
C.plans.forEach(p=>{const s=by[p.sheet];if(s){if(Array.isArray(s.members))p.members=s.members;if(s.default_tos!=null)p.default_tos=s.default_tos;if(Array.isArray(s.details))p.details=s.details;if(Array.isArray(s.dims))p.dims=s.dims;if('frame' in s)p.frame=s.frame||null;p.autofilled=true;}});
|
|
520
532
|
if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
|
|
521
533
|
function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
|
|
522
534
|
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];}
|
|
@@ -527,36 +539,74 @@ function lineX(p1,p2,p3,p4){const x1=p1[0],y1=p1[1],x2=p2[0],y2=p2[1],x3=p3[0],y
|
|
|
527
539
|
const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
|
|
528
540
|
function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
|
|
529
541
|
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};}
|
|
542
|
+
// --- local coordinate frame: P.frame={o:[ox,oy],u:[ux,uy]} (unit local-X); local-Y v=[uy,-ux] (right-hand,
|
|
543
|
+
// plan convention X-right→Y-up in y-down screen). null = global. The math is direction-only (origin o is
|
|
544
|
+
// just the glyph anchor). frameRot() = the frame's bearing baked into each dim's `rot`. ---
|
|
545
|
+
function frameRot(){return P.frame?Math.atan2(P.frame.u[1],P.frame.u[0]):0;}
|
|
546
|
+
// Lock free point (x,y) to the dominant axis through anchor (ax,ay). No frame → screen H/V (today,
|
|
547
|
+
// bit-identical). Frame → nearer of local-X / local-Y. Returns the locked [x,y].
|
|
548
|
+
function orthoLock(ax,ay,x,y){
|
|
549
|
+
if(!P.frame)return Math.abs(x-ax)>=Math.abs(y-ay)?[x,ay]:[ax,y];
|
|
550
|
+
const ux=P.frame.u[0],uy=P.frame.u[1],vx=uy,vy=-ux,dx=x-ax,dy=y-ay; // v = local-Y
|
|
551
|
+
const du=dx*ux+dy*uy,dv=dx*vx+dy*vy; // components along local axes
|
|
552
|
+
return Math.abs(du)>=Math.abs(dv)?[ax+du*ux,ay+du*uy]:[ax+dv*vx,ay+dv*vy];}
|
|
553
|
+
// rotate p about the world origin by angle r (rad). rotPt(p,-rot) maps display→a frame whose local-X is
|
|
554
|
+
// horizontal; rotPt(p,+rot) maps back. Used to run the existing screen-aligned dim math inside a rotated frame.
|
|
555
|
+
function rotPt(p,r){const c=Math.cos(r),s=Math.sin(r);return [p[0]*c-p[1]*s,p[0]*s+p[1]*c];}
|
|
556
|
+
// Which axis ('x'|'y') the span a→p is closer to — local components when a frame is set, else screen dx/dy.
|
|
557
|
+
// Drives the Dimension tool's Shift / momentary axis lock so it agrees with the active frame.
|
|
558
|
+
function dominantAxis(a,p){const dx=p[0]-a[0],dy=p[1]-a[1];
|
|
559
|
+
if(!P.frame)return Math.abs(dx)>=Math.abs(dy)?'x':'y';
|
|
560
|
+
const ux=P.frame.u[0],uy=P.frame.u[1],du=dx*ux+dy*uy,dv=dx*uy-dy*ux; // along local-X (u) vs local-Y (v=[uy,-ux])
|
|
561
|
+
return Math.abs(du)>=Math.abs(dv)?'x':'y';}
|
|
562
|
+
// Guard a frame that arrived from outside the UI (hand-edited contract / AI writeback): the math assumes a
|
|
563
|
+
// finite, non-zero, unit u. Drop a malformed frame to global; normalize a non-unit direction. (The schema's
|
|
564
|
+
// minimal validator can't express unit-length, so we sanitize at the load boundary — UI-set frames are clean.)
|
|
565
|
+
function sanitizeFrame(p){const f=p&&p.frame;if(!f)return;
|
|
566
|
+
const okPt=a=>Array.isArray(a)&&a.length>=2&&isFinite(a[0])&&isFinite(a[1]);
|
|
567
|
+
if(!okPt(f.u)||!okPt(f.o)){p.frame=null;return;}
|
|
568
|
+
const L=Math.hypot(f.u[0],f.u[1]);
|
|
569
|
+
if(!(L>1e-6)){p.frame=null;return;} // zero-length → no usable axis → global
|
|
570
|
+
if(Math.abs(L-1)>1e-6)f.u=[f.u[0]/L,f.u[1]/L];} // normalize a non-unit direction
|
|
530
571
|
// --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
|
|
531
572
|
// y -> the dim-line X; free -> signed perpendicular distance from the a->b baseline. ---
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
573
|
+
// `rot` (rad) = the dim's baked local frame; 0 = global (screen-aligned x/y, today's behavior). For x/y
|
|
574
|
+
// we rotate the anchors into the frame (local-X horizontal), run the original screen-aligned logic, then
|
|
575
|
+
// rotate the produced geometry back — so x measures along local-X, y along local-Y. `free` is frame-independent.
|
|
576
|
+
function dimGeo(a,b,axis,off,rot){rot=rot||0;
|
|
577
|
+
if(axis==='free'){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)
|
|
578
|
+
const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
|
|
579
|
+
return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
|
|
580
|
+
const A=rot?rotPt(a,-rot):a,B=rot?rotPt(b,-rot):b; // into the local frame (no-op when rot 0)
|
|
581
|
+
let g;
|
|
582
|
+
if(axis==='x'){const y=off;g={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])};}
|
|
583
|
+
else{const x=off;g={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])};} // axis y
|
|
584
|
+
if(rot){const f=p=>rotPt(p,rot);g.p1=f(g.p1);g.p2=f(g.p2);g.mid=f(g.mid);g.w=g.w.map(s=>[f(s[0]),f(s[1])]);} // back to display space (witness `from` maps back to the original anchor)
|
|
585
|
+
return g;}
|
|
538
586
|
// measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
|
|
539
587
|
function dimValueText(px){return fmtFtIn(px/FT*12);}
|
|
540
|
-
// the offset a click at pointer p implies, given the two anchors + axis.
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if(axis==='
|
|
544
|
-
|
|
545
|
-
|
|
588
|
+
// the offset a click at pointer p implies, given the two anchors + axis (+ baked frame rot). For x/y the
|
|
589
|
+
// pointer is read in the local frame so the stored off is in that frame (consistent with dimGeo).
|
|
590
|
+
function dimOffFromPointer(a,b,axis,p,rot){rot=rot||0;
|
|
591
|
+
if(axis==='free'){const dx=b[0]-a[0],dy=b[1]-a[1],L=Math.hypot(dx,dy)||1,nx=-dy/L,ny=dx/L;
|
|
592
|
+
return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
|
|
593
|
+
const P_=rot?rotPt(p,-rot):p;
|
|
594
|
+
return axis==='x'?P_[1]:P_[0];}
|
|
546
595
|
// 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==='
|
|
549
|
-
|
|
550
|
-
return -28/zoom;
|
|
596
|
+
function dimDefaultOff(a,b,axis,rot){rot=rot||0;
|
|
597
|
+
if(axis==='free')return -28/zoom; // free: a constant perpendicular offset
|
|
598
|
+
const A=rot?rotPt(a,-rot):a,B=rot?rotPt(b,-rot):b; // x/y: clear of the anchors in the LOCAL frame (off is local)
|
|
599
|
+
if(axis==='x')return Math.min(A[1],B[1])-28/zoom; // X dim line sits "above" the anchors (local-Y)
|
|
600
|
+
return Math.min(A[0],B[0])-28/zoom;} // Y dim line sits "left" of the anchors (local-X)
|
|
551
601
|
// one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
|
|
552
602
|
// 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);
|
|
603
|
+
function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);
|
|
554
604
|
const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
|
|
555
605
|
const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
|
|
556
606
|
// a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
|
|
557
607
|
// horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
|
|
558
608
|
// 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;}
|
|
609
|
+
let ang=0;if(d.axis==='free'||d.rot){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;} // free + framed x/y rotate the chip to the (slanted) dim line; screen-aligned x/y (rot 0) stay flat
|
|
560
610
|
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
611
|
s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
|
|
562
612
|
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}"/>`;
|
|
@@ -687,7 +737,8 @@ function render(){
|
|
|
687
737
|
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}"`;
|
|
688
738
|
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
739
|
s+=renderDims();
|
|
690
|
-
|
|
740
|
+
if(P.frame)s+=axisGlyphSvg(P.frame.o,P.frame.u,false); // local-axes glyph at the origin (only when a frame is set; removed on reset)
|
|
741
|
+
svg.innerHTML=s; document.getElementById('profiles').innerHTML=profs.map(p=>`<option value="${esc(p)}">`).join(''); document.getElementById('details').innerHTML=(P.details||[]).map(d=>`<option value="${esc(d.text)}">`).join(''); stats(); panel(); updUR(); updDup(); updConf(); updCS();
|
|
691
742
|
if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
|
|
692
743
|
}
|
|
693
744
|
function updDup(){const n=redundantDups().length;
|
|
@@ -703,7 +754,8 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
|
|
|
703
754
|
const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
|
|
704
755
|
svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
|
|
705
756
|
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);});
|
|
757
|
+
r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
|
|
758
|
+
const cg=svg.querySelector('g.csglyph');if(cg&&P.frame)cg.outerHTML=axisGlyphSvg(P.frame.o,P.frame.u,false);} // glyph is sized in 1/zoom → regenerate on zoom (like the dim chips)
|
|
707
759
|
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]);});}
|
|
708
760
|
function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
|
|
709
761
|
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]);}}
|
|
@@ -718,6 +770,9 @@ const VARIES=Symbol('varies');
|
|
|
718
770
|
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;}
|
|
719
771
|
function panel(){
|
|
720
772
|
const p=document.getElementById('panel');
|
|
773
|
+
if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
|
|
774
|
+
<div class=hint id=csHint style="margin-top:8px">${csDraft?'Click the <b>second</b> point — the local-X direction. Y follows at 90°.':'Click the <b>origin</b> point to start.'}</div>
|
|
775
|
+
<div class=hint style="margin-top:8px">Two clicks define the frame; snaps to member ends & grid (<b>Alt</b> off). <b>Esc</b> cancels.</div>`;return;}
|
|
721
776
|
if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
|
|
722
777
|
<div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
|
|
723
778
|
<div class=sect style="margin-top:12px">Direction</div>
|
|
@@ -918,61 +973,112 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
|
|
|
918
973
|
// svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
|
|
919
974
|
const SVGNS='http://www.w3.org/2000/svg';
|
|
920
975
|
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
|
|
976
|
+
if(dimMode){if(mode==='add'){mode='sel';setMode();}if(csaxisMode){csaxisMode=false;setCsMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();dimsVisible=true;updDimToggle();} // arming clears conflicting modes (add/set-axes) + ensures dims are visible — covers the D-key path too, not just the button
|
|
922
977
|
dimDraft=null;dimChainPrev=null;dimPrevClear();}
|
|
923
978
|
function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
|
|
924
979
|
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
980
|
return {x,y,raw:[q.x,q.y],hit:false};}
|
|
926
981
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
dimChainPrev={point:b,axis,off};dimPrev(e);return;} // keep going from b; Esc/Enter ends the chain
|
|
982
|
+
if(dimChainPrev){const a=dimChainPrev.point,b=[s.x,s.y],axis=dimChainPrev.axis,off=dimChainPrev.off,rot=dimChainPrev.rot||0; // CHAIN: each click adds the next dim from the previous point, on the same axis + offset line + baked frame
|
|
983
|
+
if(dimGeo(a,b,axis,off,rot).px<1){dimPrev(e);return;} // ignore a zero / sub-pixel click (measured length in the frame) — don't commit a degenerate dim
|
|
984
|
+
edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off,rot});});
|
|
985
|
+
dimChainPrev={point:b,axis,off,rot};dimPrev(e);return;} // keep going from b; Esc/Enter ends the chain
|
|
932
986
|
if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
|
|
933
987
|
if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
|
|
934
988
|
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
|
|
989
|
+
const rot=axis==='free'?0:frameRot(); // bake the active frame so a later Reset/redefine never moves this dim (free is frame-independent)
|
|
990
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw,rot); // click 3 → offset/side from raw pointer
|
|
936
991
|
const a=dimDraft.a,b=dimDraft.b;
|
|
937
|
-
edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off});});
|
|
938
|
-
if(dimChain)dimChainPrev={point:b,axis,off};
|
|
992
|
+
edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off,rot});}); // undoable; render() clears the preview
|
|
993
|
+
if(dimChain)dimChainPrev={point:b,axis,off,rot}; // chain mode → continue from b along this same offset line + baked frame
|
|
939
994
|
dimDraft=null;}
|
|
940
995
|
function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
|
|
941
996
|
function dimPrev(e){if(!dimMode){dimPrevClear();return;}
|
|
942
997
|
{const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();} // keep the panel hint live (chain / stage state)
|
|
943
998
|
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
999
|
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;}
|
|
1000
|
+
g.innerHTML=dimSvg({a:dimChainPrev.point,b:[s.x,s.y],axis:dimChainPrev.axis,off:dimChainPrev.off,rot:dimChainPrev.rot||0},{preview:true});return;}
|
|
946
1001
|
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
1002
|
let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
|
|
948
1003
|
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
1004
|
const b=[s.x,s.y];dimLastPtr=b;
|
|
950
|
-
const ax=e.shiftKey?(
|
|
951
|
-
|
|
1005
|
+
const ax=e.shiftKey?dominantAxis(dimDraft.a,b):dimDraft.axis; // X/Y/F + Shift force already apply while picking the 2nd point (value shown live); Shift dominant-axis follows the local frame
|
|
1006
|
+
const rot=ax==='free'?0:frameRot();
|
|
1007
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax,rot),rot},{preview:true});return;}
|
|
952
1008
|
const q=toSvg(e);dimLastPtr=[q.x,q.y];snapClear();
|
|
953
|
-
const ax=e.shiftKey?(
|
|
954
|
-
const
|
|
955
|
-
|
|
1009
|
+
const ax=e.shiftKey?dominantAxis(dimDraft.a,dimDraft.b):dimDraft.axis; // Shift = momentary dominant-axis lock (local-frame aware)
|
|
1010
|
+
const rot=ax==='free'?0:frameRot();
|
|
1011
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y],rot); // stage 2: full dim rides the pointer
|
|
1012
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off,rot},{preview:true});dimDraft._ax=ax;}
|
|
956
1013
|
function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
|
|
957
1014
|
const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
|
|
958
1015
|
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
1016
|
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
|
|
961
|
-
|
|
1017
|
+
const loc=!!(P&&P.frame);
|
|
1018
|
+
const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
|
|
1019
|
+
return 'Dimension — '+a+(dimChain?' · Chain ON':'')+(loc?' · local axes':'')+'. Click 1: start · Click 2: end · Click 3: place. Shift=lock axis · X/Y/F force · Alt=no snap · Esc=cancel.';}
|
|
962
1020
|
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
1021
|
let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
|
|
964
1022
|
function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
|
|
965
1023
|
const g=document.getElementById('dimPrevG');if(!g)return;
|
|
1024
|
+
const rot=dimDraft.axis==='free'?0:frameRot();
|
|
966
1025
|
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});}
|
|
1026
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis,rot),rot},{preview:true});return;}
|
|
1027
|
+
const off=dimOffFromPointer(dimDraft.a,dimDraft.b,dimDraft.axis,dimLastPtr,rot);dimDraft._ax=dimDraft.axis; // stage 2: keep the offset the pointer last set; sync _ax so the 3rd-click commit uses the axis now on screen
|
|
1028
|
+
g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off,rot},{preview:true});}
|
|
1029
|
+
// --- Local coordinate system "set axes" tool: two snapped clicks (origin, then X-direction) define
|
|
1030
|
+
// P.frame={o,u}. Shares buildSnap/snap/snapMark; preview lives in its own <g> (render() rebuilds wholesale). ---
|
|
1031
|
+
function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
|
|
1032
|
+
const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
|
|
1033
|
+
if(csaxisMode){if(mode==='add'){mode='sel';setMode();}if(dimMode){dimMode=false;setDimMode();}geoMode=null;setGeo();selIds.clear();selDimIds.clear();} // arming disarms conflicting tools + clears selection
|
|
1034
|
+
csDraft=null;csPrevClear();}
|
|
1035
|
+
function csSnapAt(e){const q=toSvg(e);if(e.altKey)return [q.x,q.y];buildSnap(null);const sn=snap(q.x,q.y);return [sn.x,sn.y];}
|
|
1036
|
+
function csClick(e){const p=csSnapAt(e);
|
|
1037
|
+
if(!csDraft){csDraft=p;csPrev(e);panel();return;} // click 1 → origin (refresh the panel hint to "click the 2nd point")
|
|
1038
|
+
const dx=p[0]-csDraft[0],dy=p[1]-csDraft[1],L=Math.hypot(dx,dy); // click 2 → X-direction
|
|
1039
|
+
if(L<1e-6){toast('Pick two distinct points for the X axis');return;} // degenerate → stay armed
|
|
1040
|
+
const o=csDraft.slice(),u=[dx/L,dy/L];
|
|
1041
|
+
csaxisMode=false;setCsMode();
|
|
1042
|
+
edit(()=>{P.frame={o,u};}); // undoable + persisted; render() draws the glyph
|
|
1043
|
+
toast('Local axes set ('+Math.round(Math.atan2(u[1],u[0])*180/Math.PI)+'°) — ortho & X/Y dims now follow them');}
|
|
1044
|
+
function csPrevClear(){const g=document.getElementById('csPrevG');if(g)g.remove();snapClear();}
|
|
1045
|
+
function csPrev(e){if(!csaxisMode){csPrevClear();return;}
|
|
1046
|
+
const p=csSnapAt(e);(!e.altKey)?snapMark(p[0],p[1]):snapClear();
|
|
1047
|
+
let g=document.getElementById('csPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='csPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
|
|
1048
|
+
g.innerHTML=csDraft?axisGlyphSvg(csDraft,[p[0]-csDraft[0],p[1]-csDraft[1]],true):'';} // before click 1 → just the snap marker; after → rubber-band axes toward the pointer
|
|
1049
|
+
function resetFrame(){if(!P.frame)return;edit(()=>{P.frame=null;});toast('Axes reset to global');}
|
|
1050
|
+
// One coordinate-axes glyph → SVG string. `dir` = any vector along local-X (normalized here). Screen-constant
|
|
1051
|
+
// size (÷zoom), re-synced on zoom by updateBadges. Tekla axis colours: X = red, Y = green, origin = Z blue
|
|
1052
|
+
// (local-Y = [uy,-ux], plan X-right→Y-up; in plan view Z points out of screen, so it's the origin dot).
|
|
1053
|
+
function axisGlyphSvg(o,dir,preview){
|
|
1054
|
+
const L=Math.hypot(dir[0],dir[1]);let ux=1,uy=0;if(L>1e-6){ux=dir[0]/L;uy=dir[1]/L;}
|
|
1055
|
+
const vx=uy,vy=-ux,len=46/zoom,hd=9/zoom,lab=15/zoom,sw=2.5/zoom,halo=sw+3/zoom,ox=o[0],oy=o[1];
|
|
1056
|
+
const xt=[ox+ux*len,oy+uy*len],yt=[ox+vx*len,oy+vy*len];
|
|
1057
|
+
const lx=[xt[0]+ux*lab,xt[1]+uy*lab],ly=[yt[0]+vx*lab,yt[1]+vy*lab]; // label anchors just past each tip
|
|
1058
|
+
const barbs=(tip,ax,ay)=>{const px=-ay,py=ax; // arrowhead: tip + two barbs back along (ax,ay)
|
|
1059
|
+
return `${tip[0]},${tip[1]} ${tip[0]-ax*hd+px*hd*0.5},${tip[1]-ay*hd+py*hd*0.5} ${tip[0]-ax*hd-px*hd*0.5},${tip[1]-ay*hd-py*hd*0.5}`;};
|
|
1060
|
+
// dark halo under each arm so the X/Y axis stays readable where a same-colour member runs through it
|
|
1061
|
+
const arm=(tip,col)=>`<line x1="${ox}" y1="${oy}" x2="${tip[0]}" y2="${tip[1]}" stroke="#0b1220" stroke-width="${halo}" stroke-linecap="round"/>`
|
|
1062
|
+
+`<line x1="${ox}" y1="${oy}" x2="${tip[0]}" y2="${tip[1]}" stroke="${col}" stroke-width="${sw}"/>`;
|
|
1063
|
+
const r=7/zoom; // label backing chip (dark, axis-coloured edge — like the dim chip), keeps the letter off the drawing
|
|
1064
|
+
const tag=(c,col,txt)=>`<rect x="${c[0]-r}" y="${c[1]-r}" width="${2*r}" height="${2*r}" rx="${2/zoom}" fill="#111827" stroke="${col}" stroke-width="${1/zoom}"/>`
|
|
1065
|
+
+`<text x="${c[0]}" y="${c[1]}" fill="${col}" font-weight="700" style="font-size:${lab}px" text-anchor="middle" dominant-baseline="central">${txt}</text>`;
|
|
1066
|
+
return `<g class="csglyph"${preview?' opacity="0.85"':''} pointer-events="none">`
|
|
1067
|
+
+arm(xt,'#ef4444')+`<polygon points="${barbs(xt,ux,uy)}" fill="#ef4444"/>`
|
|
1068
|
+
+arm(yt,'#22c55e')+`<polygon points="${barbs(yt,vx,vy)}" fill="#22c55e"/>`
|
|
1069
|
+
+`<circle cx="${ox}" cy="${oy}" r="${4/zoom}" fill="#3b82f6" stroke="#e2e8f0" stroke-width="${1.5/zoom}"/>`
|
|
1070
|
+
+tag(lx,'#ef4444','X')+tag(ly,'#22c55e','Y')+`</g>`;}
|
|
1071
|
+
// header chip + the More-menu "Reset to global" enabled state — reflect the current frame.
|
|
1072
|
+
function updCS(){const s=document.getElementById('csStat');
|
|
1073
|
+
if(s){const f=P&&P.frame;s.innerHTML='Axes <b>'+(f?(Math.round(Math.atan2(f.u[1],f.u[0])*180/Math.PI)+'° local'):'Global')+'</b>';s.classList.toggle('local',!!f);s.title=f?'Local coordinate frame active — ortho draw & X/Y dimensions follow it. Reset via the ⋯ menu.':'Global axes (default). Set a local frame from the ⋯ menu for skewed framing.';}
|
|
1074
|
+
const r=document.getElementById('csResetB');if(r)r.disabled=!(P&&P.frame);}
|
|
970
1075
|
// themed transient toast (baseline tokens — never a native alert)
|
|
971
1076
|
function toast(msg){let t=document.getElementById('toast');
|
|
972
1077
|
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);}
|
|
973
1078
|
t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
|
|
974
1079
|
// --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
|
|
975
1080
|
svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
1081
|
+
if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
|
|
976
1082
|
if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
|
|
977
1083
|
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
1084
|
if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
|
|
@@ -1013,21 +1119,22 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
|
|
|
1013
1119
|
const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
|
|
1014
1120
|
drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
|
|
1015
1121
|
svg.addEventListener('pointermove',e=>{
|
|
1122
|
+
if(csaxisMode){csPrev(e);return;}
|
|
1016
1123
|
if(dimMode){dimPrev(e);return;}
|
|
1017
1124
|
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]);
|
|
1018
1125
|
const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
|
|
1019
1126
|
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;}
|
|
1020
1127
|
if(!drag)return;const p=toSvg(e);
|
|
1021
1128
|
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;}
|
|
1129
|
+
if(drag.type==='dim'){const d=P.dims.find(x=>x.id===drag.id);if(d){d.off=dimOffFromPointer(d.a,d.b,d.axis,[p.x,p.y],d.rot);render();}return;}
|
|
1023
1130
|
if(drag.type==='draw'){let x=p.x,y=p.y;
|
|
1024
|
-
if(e.shiftKey){
|
|
1131
|
+
if(e.shiftKey){[x,y]=orthoLock(drag.x0,drag.y0,x,y);snapClear();} // ortho draw — local frame when set, else screen H/V
|
|
1025
1132
|
else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
|
|
1026
1133
|
drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
|
|
1027
1134
|
if(drag.type==='marquee'){const x=Math.min(drag.x0,p.x),y=Math.min(drag.y0,p.y),w=Math.abs(p.x-drag.x0),h=Math.abs(p.y-drag.y0);
|
|
1028
1135
|
drag.rect.setAttribute('x',x);drag.rect.setAttribute('y',y);drag.rect.setAttribute('width',w);drag.rect.setAttribute('height',h);drag.cur=[x,y,x+w,y+h];return;}
|
|
1029
1136
|
if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
|
|
1030
|
-
if(e.shiftKey){
|
|
1137
|
+
if(e.shiftKey){[dx,dy]=orthoLock(0,0,dx,dy);snapClear();} // ortho move — lock the delta to the local frame (or screen H/V)
|
|
1031
1138
|
else if(!e.altKey){let best=null,bd=SNAP_PX/zoom; // shift the group so a moving endpoint lands on a snap target (endpoint OR line)
|
|
1032
1139
|
for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
|
|
1033
1140
|
for(const q of snapPts){const d=Math.hypot(q[0]-nx,q[1]-ny);if(d<bd){bd=d;best={q,cx:q[0]-nx,cy:q[1]-ny};}}
|
|
@@ -1039,7 +1146,7 @@ svg.addEventListener('pointermove',e=>{
|
|
|
1039
1146
|
if(selIds.size<=2)for(const m of selArr())updateHandles(m);return;}
|
|
1040
1147
|
// endpoint
|
|
1041
1148
|
const m=byId(drag.id);if(!m)return;let x=p.x,y=p.y;
|
|
1042
|
-
if(e.shiftKey){
|
|
1149
|
+
if(e.shiftKey){[x,y]=orthoLock(drag.anchor[0],drag.anchor[1],x,y);snapClear();} // ortho endpoint — local frame when set, else screen H/V
|
|
1043
1150
|
else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
|
|
1044
1151
|
m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
|
|
1045
1152
|
svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
@@ -1050,12 +1157,14 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
|
|
|
1050
1157
|
drag=null;render();return;}
|
|
1051
1158
|
if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
|
|
1052
1159
|
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)
|
|
1160
|
+
if(dimsVisible)for(const d of P.dims){const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);if(rectHit(g.p1,g.p2,r)||rectHit(g.w[0][0],g.w[0][1],r)||rectHit(g.w[1][0],g.w[1][1],r))selDimIds.add(d.id);}} // area-select grabs dims by their VISIBLE geometry — the dim line + its two witness lines (not the invisible anchor span)
|
|
1054
1161
|
drag.rect.remove();drag=null;render();return;}
|
|
1055
1162
|
if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
|
|
1056
1163
|
function setMode(){document.body.classList.toggle('add',mode==='add');document.getElementById('mAdd').classList.toggle('on',mode==='add');}
|
|
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
|
|
1058
|
-
document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
|
|
1164
|
+
document.getElementById('mAdd').onclick=()=>{if(dimMode){dimMode=false;setDimMode();}if(csaxisMode){csaxisMode=false;setCsMode();}selDimIds.clear();mode=(mode==='add'?'sel':'add');if(mode==='add')selIds.clear();setMode();render();}; // entering add/sel disarms the Dimension + set-axes tools (else they keep eating canvas clicks) and drops any dim selection
|
|
1165
|
+
document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();};
|
|
1166
|
+
document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
|
|
1167
|
+
document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
|
|
1059
1168
|
function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
|
|
1060
1169
|
document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
|
|
1061
1170
|
document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
|
|
@@ -1084,6 +1193,7 @@ addEventListener('keydown',e=>{
|
|
|
1084
1193
|
if(e.key==='Escape'&&confOpen()){closeConf();return;}
|
|
1085
1194
|
if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
|
|
1086
1195
|
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)
|
|
1196
|
+
if(e.key==='Escape'&&csaxisMode){if(csDraft){csDraft=null;csPrevClear();}else{csaxisMode=false;setCsMode();}render();return;} // 1st Esc drops the in-progress origin, 2nd exits set-axes
|
|
1087
1197
|
if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
|
|
1088
1198
|
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
|
|
1089
1199
|
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;}
|
|
@@ -1492,6 +1602,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1492
1602
|
if(!Array.isArray(P.members))P.members=[];
|
|
1493
1603
|
if(!Array.isArray(P.segments))P.segments=[];
|
|
1494
1604
|
if(!Array.isArray(P.dims))P.dims=[];
|
|
1605
|
+
sanitizeFrame(P); // tolerate a hand-edited/AI-written frame: normalize or drop to global
|
|
1495
1606
|
// clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
|
|
1496
1607
|
// committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
|
|
1497
1608
|
// box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
|
|
@@ -1514,7 +1625,8 @@ function setPlan(i){C.active=i;P=C.plans[i];
|
|
|
1514
1625
|
profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
|
|
1515
1626
|
undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
|
|
1516
1627
|
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)
|
|
1628
|
+
dimMode=false;dimChain=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
|
|
1629
|
+
csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
|
|
1518
1630
|
defaultTOS=(P.default_tos!=null?P.default_tos:198);
|
|
1519
1631
|
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)
|
|
1520
1632
|
if(!P.autofilled){autofillTOS();P.autofilled=true;}
|
|
@@ -1529,6 +1641,37 @@ const _restored=restoreSaved(); // bring back this brows
|
|
|
1529
1641
|
setPlan(C.active==null?0:C.active);
|
|
1530
1642
|
setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
|
|
1531
1643
|
try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on open so shortcuts work before the first click
|
|
1644
|
+
// --- ?selftest=1 — cheap regression guard for the only non-trivial new math (local frame). Logs PASS/FAIL
|
|
1645
|
+
// to the console and a toast; never runs in normal use. (ponytail: one runnable check for the frame logic.)
|
|
1646
|
+
if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
|
|
1647
|
+
const fails=[];const ok=(c,m)=>{if(!c)fails.push(m);};
|
|
1648
|
+
const near=(a,b,t=1e-6)=>Math.abs(a-b)<=t;const pnear=(p,q,t=1e-6)=>near(p[0],q[0],t)&&near(p[1],q[1],t);
|
|
1649
|
+
const sf=P.frame;
|
|
1650
|
+
// 1) orthoLock — global (no frame): snaps to screen H/V
|
|
1651
|
+
P.frame=null;
|
|
1652
|
+
ok(pnear(orthoLock(0,0,3,1),[3,0]),'orthoLock global x-dominant');
|
|
1653
|
+
ok(pnear(orthoLock(0,0,1,3),[0,3]),'orthoLock global y-dominant');
|
|
1654
|
+
// 2) orthoLock — frame with local-X pointing down u=(0,1), local-Y v=(1,0)
|
|
1655
|
+
P.frame={o:[0,0],u:[0,1]};
|
|
1656
|
+
ok(pnear(orthoLock(0,0,3,1),[3,0]),'orthoLock framed locks to local-Y');
|
|
1657
|
+
ok(pnear(orthoLock(0,0,1,3),[0,3]),'orthoLock framed locks to local-X');
|
|
1658
|
+
ok(dominantAxis([0,0],[1,3])==='x'&&dominantAxis([0,0],[3,1])==='y','dominantAxis framed');
|
|
1659
|
+
// 3) dim rot — an x dim under a 90° frame measures along local-X (here 0), y measures 10
|
|
1660
|
+
P.frame=null;
|
|
1661
|
+
ok(near(dimGeo([0,0],[10,0],'x',0,0).px,10),'dimGeo x rot0');
|
|
1662
|
+
ok(near(dimGeo([0,0],[10,0],'x',0,Math.PI/2).px,0),'dimGeo x rot90 → along local-X');
|
|
1663
|
+
ok(near(dimGeo([0,0],[10,0],'y',0,Math.PI/2).px,10),'dimGeo y rot90 → along local-Y');
|
|
1664
|
+
ok(near(dimGeo([0,0],[3,4],'free',0,Math.PI/2).px,5),'dimGeo free rot-independent');
|
|
1665
|
+
// 4) witness "from" maps back to the anchor after the rotate-out
|
|
1666
|
+
ok((()=>{const g=dimGeo([2,3],[8,3],'x',5,0.5);return pnear(g.w[0][0],[2,3],1e-9);})(),'dim witness anchor round-trip');
|
|
1667
|
+
// 5) sanitizeFrame — normalize a non-unit u, drop zero-length / malformed (external/AI contract safety)
|
|
1668
|
+
{const tp={frame:{o:[0,0],u:[3,0]}};sanitizeFrame(tp);ok(tp.frame&&near(Math.hypot(tp.frame.u[0],tp.frame.u[1]),1),'sanitizeFrame normalizes non-unit u');}
|
|
1669
|
+
{const tz={frame:{o:[0,0],u:[0,0]}};sanitizeFrame(tz);ok(tz.frame===null,'sanitizeFrame drops zero-length u');}
|
|
1670
|
+
{const tb={frame:{o:[0,0],u:[1]}};sanitizeFrame(tb);ok(tb.frame===null,'sanitizeFrame drops malformed u');}
|
|
1671
|
+
P.frame=sf; // restore
|
|
1672
|
+
const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
|
|
1673
|
+
console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
|
|
1674
|
+
})();}
|
|
1532
1675
|
// --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
|
|
1533
1676
|
(function(){
|
|
1534
1677
|
const src=C.source;if(!src||typeof src!=='object')return;
|