@floless/app 0.39.0 → 0.41.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.
@@ -52856,7 +52856,7 @@ function appVersion() {
52856
52856
  return resolveVersion({
52857
52857
  isSea: isSea2(),
52858
52858
  sqVersionXml: readSqVersionXml(),
52859
- define: true ? "0.39.0" : void 0,
52859
+ define: true ? "0.41.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.39.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.41.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 = |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." }
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}
@@ -148,8 +151,11 @@
148
151
  text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
149
152
  line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
150
153
  circle.dimhandle{fill:#22d3ee;stroke:#0b1220;stroke-width:2;cursor:grab}
154
+ body.dimsplit line.member,body.dimsplit line.seg,body.dimsplit line.dim,body.dimsplit line.dimwit,body.dimsplit image{cursor:crosshair}
151
155
  .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
152
156
  body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
157
+ body.csaxison #svg,body.csaxison line.member,body.csaxison line.seg,body.csaxison image{cursor:crosshair}
158
+ g.csglyph text{user-select:none}
153
159
  body.geo line.seg{opacity:.45;cursor:crosshair} body.geo line.member{cursor:crosshair}
154
160
  body.geo line.seg:hover{stroke:#22d3ee;opacity:1;stroke-width:4} body.geo line.member:hover{stroke-width:8}
155
161
  circle.numbg{fill:var(--brand);stroke:#fff;stroke-width:1.5;vector-effect:non-scaling-stroke;pointer-events:none}
@@ -191,6 +197,7 @@
191
197
  <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
198
  <span class=stat id=rfiStat title="Click to list unresolved members (RFI)">RFI <b id=rc>0</b></span>
193
199
  <span class=stat id=dupStat title="Overlapping/duplicate members (same geometry)">Dup <b id=dpc>0</b></span>
200
+ <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
201
  <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
202
  <span class=stat id=saveStat title="Edits auto-save in this browser (localStorage)">Saved</span>
196
203
  <span class=stat id=srcStat style="display:none"></span><span style="flex:1"></span>
@@ -205,6 +212,10 @@
205
212
  <div class=mlabel>Dimensions</div>
206
213
  <button id=dimToggleB title="Show or hide all placed dimensions on the plan">Hide dimensions</button>
207
214
  <hr>
215
+ <div class=mlabel>Coordinate system</div>
216
+ <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>
217
+ <button id=csResetB title="Return to the global X/Y axes (default)" disabled>Reset to global</button>
218
+ <hr>
208
219
  <div class=mlabel>Review</div>
209
220
  <button id=dupB title="Select duplicate (overlapping) members — review then Delete to dedupe">Duplicates</button>
210
221
  <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 +423,9 @@ function main() {
412
423
  let P, X0,Y0,X1,Y1, FT, RB64, EXTX, EXTY, profs; // per-plan, set by setPlan()
413
424
  let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoMode=null;
414
425
  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
426
+ 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).
427
+ 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
428
+ let dimSplitMode=false; // "add split point" mode on a selected dim — each click inserts a point and splits the dim segment under it into two
416
429
  let selIds=new Set();
417
430
  let undo=[], redo=[];
418
431
  const byId=id=>P.members.find(m=>m.id===id);
@@ -464,11 +477,12 @@ function syncDefaults(){for(const m of P.members){ensureMeta(m);
464
477
  else for(const en of m.ends)if(en.tosDef!==false&&defaultTOS!=null)en.tos=defaultTOS;}}
465
478
  const autofillTOS=syncDefaults;
466
479
  // profs is per-plan — computed in setPlan()
467
- function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[]});}
480
+ function snapshot(){return JSON.stringify({members:P.members,dims:P.dims||[],frame:P.frame||null});}
468
481
  function pushUndo(prev){undo.push(prev);if(undo.length>200)undo.shift();redo.length=0;scheduleSave();}
469
482
  function apply(json){const d=JSON.parse(json);
470
483
  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;}
484
+ 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
485
+ updCS();
472
486
  selIds=new Set([...selIds].filter(id=>byId(id)));
473
487
  selDimIds.forEach(id=>{if(!(P.dims||[]).some(x=>x.id===id))selDimIds.delete(id);}); // drop any dim selection the undo removed
474
488
  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 +501,7 @@ function setSaved(state,msg){const el=document.getElementById('saveStat');if(!el
487
501
  else el.textContent=msg||'Auto-save on';}
488
502
  function persist(){try{localStorage.setItem(LSKEY,JSON.stringify({sig:dataSig(),ts:Date.now(),active:C.active,
489
503
  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);}}
504
+ 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
505
  // --- server-side draft save: PUT the FULL contract C — this is the copy Approve bakes.
492
506
  // localStorage (persist) stays the instant per-browser draft cache; this is the durable one.
493
507
  // fetch does NOT reject on HTTP 400 (schema rejection), so treat !res.ok as 'err' (not a false 'Saved ✓'). ---
@@ -516,7 +530,7 @@ function restoreSaved(){try{const raw=localStorage.getItem(LSKEY);if(!raw)return
516
530
  if(d.custom_details)C.custom_details=d.custom_details;
517
531
  if(d.profile_colors)C.profile_colors=d.profile_colors;
518
532
  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;}});
533
+ 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
534
  if(d.active!=null)C.active=d.active;return true;}catch(e){console.warn('discarding corrupt local draft',e);return false;}}
521
535
  function updUR(){document.getElementById('undoB').disabled=!undo.length;document.getElementById('redoB').disabled=!redo.length;}
522
536
  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 +541,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
541
  const t=((x1-x3)*(y3-y4)-(y1-y3)*(x3-x4))/den;return [x1+t*(x2-x1),y1+t*(y2-y1)];}
528
542
  function projPt(p,a,b){const vx=b[0]-a[0],vy=b[1]-a[1],L2=vx*vx+vy*vy||1;
529
543
  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};}
544
+ // --- local coordinate frame: P.frame={o:[ox,oy],u:[ux,uy]} (unit local-X); local-Y v=[uy,-ux] (right-hand,
545
+ // plan convention X-right→Y-up in y-down screen). null = global. The math is direction-only (origin o is
546
+ // just the glyph anchor). frameRot() = the frame's bearing baked into each dim's `rot`. ---
547
+ function frameRot(){return P.frame?Math.atan2(P.frame.u[1],P.frame.u[0]):0;}
548
+ // Lock free point (x,y) to the dominant axis through anchor (ax,ay). No frame → screen H/V (today,
549
+ // bit-identical). Frame → nearer of local-X / local-Y. Returns the locked [x,y].
550
+ function orthoLock(ax,ay,x,y){
551
+ if(!P.frame)return Math.abs(x-ax)>=Math.abs(y-ay)?[x,ay]:[ax,y];
552
+ const ux=P.frame.u[0],uy=P.frame.u[1],vx=uy,vy=-ux,dx=x-ax,dy=y-ay; // v = local-Y
553
+ const du=dx*ux+dy*uy,dv=dx*vx+dy*vy; // components along local axes
554
+ return Math.abs(du)>=Math.abs(dv)?[ax+du*ux,ay+du*uy]:[ax+dv*vx,ay+dv*vy];}
555
+ // rotate p about the world origin by angle r (rad). rotPt(p,-rot) maps display→a frame whose local-X is
556
+ // horizontal; rotPt(p,+rot) maps back. Used to run the existing screen-aligned dim math inside a rotated frame.
557
+ 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];}
558
+ // Which axis ('x'|'y') the span a→p is closer to — local components when a frame is set, else screen dx/dy.
559
+ // Drives the Dimension tool's Shift / momentary axis lock so it agrees with the active frame.
560
+ function dominantAxis(a,p){const dx=p[0]-a[0],dy=p[1]-a[1];
561
+ if(!P.frame)return Math.abs(dx)>=Math.abs(dy)?'x':'y';
562
+ 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])
563
+ return Math.abs(du)>=Math.abs(dv)?'x':'y';}
564
+ // Guard a frame that arrived from outside the UI (hand-edited contract / AI writeback): the math assumes a
565
+ // finite, non-zero, unit u. Drop a malformed frame to global; normalize a non-unit direction. (The schema's
566
+ // minimal validator can't express unit-length, so we sanitize at the load boundary — UI-set frames are clean.)
567
+ function sanitizeFrame(p){const f=p&&p.frame;if(!f)return;
568
+ const okPt=a=>Array.isArray(a)&&a.length>=2&&isFinite(a[0])&&isFinite(a[1]);
569
+ if(!okPt(f.u)||!okPt(f.o)){p.frame=null;return;}
570
+ const L=Math.hypot(f.u[0],f.u[1]);
571
+ if(!(L>1e-6)){p.frame=null;return;} // zero-length → no usable axis → global
572
+ if(Math.abs(L-1)>1e-6)f.u=[f.u[0]/L,f.u[1]/L];} // normalize a non-unit direction
530
573
  // --- dimension geometry (display space, y-down). `off` meaning by axis: x -> the dim-line Y;
531
574
  // 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};}
575
+ // `rot` (rad) = the dim's baked local frame; 0 = global (screen-aligned x/y, today's behavior). For x/y
576
+ // we rotate the anchors into the frame (local-X horizontal), run the original screen-aligned logic, then
577
+ // rotate the produced geometry back — so x measures along local-X, y along local-Y. `free` is frame-independent.
578
+ function dimGeo(a,b,axis,off,rot){rot=rot||0;
579
+ 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)
580
+ const a2=[a[0]+nx*off,a[1]+ny*off],b2=[b[0]+nx*off,b[1]+ny*off];
581
+ return {p1:a2,p2:b2,w:[[a,a2],[b,b2]],mid:[(a2[0]+b2[0])/2,(a2[1]+b2[1])/2],px:L};}
582
+ const A=rot?rotPt(a,-rot):a,B=rot?rotPt(b,-rot):b; // into the local frame (no-op when rot 0)
583
+ let g;
584
+ 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])};}
585
+ 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
586
+ 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)
587
+ return g;}
538
588
  // measured pixels -> real length text, identical to every other length in the editor (ft-in, 16ths).
539
589
  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;}
590
+ // the offset a click at pointer p implies, given the two anchors + axis (+ baked frame rot). For x/y the
591
+ // pointer is read in the local frame so the stored off is in that frame (consistent with dimGeo).
592
+ function dimOffFromPointer(a,b,axis,p,rot){rot=rot||0;
593
+ 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;
594
+ return (p[0]-a[0])*nx+(p[1]-a[1])*ny;}
595
+ const P_=rot?rotPt(p,-rot):p;
596
+ return axis==='x'?P_[1]:P_[0];}
546
597
  // 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
598
+ function dimDefaultOff(a,b,axis,rot){rot=rot||0;
599
+ if(axis==='free')return -28/zoom; // free: a constant perpendicular offset
600
+ 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)
601
+ if(axis==='x')return Math.min(A[1],B[1])-28/zoom; // X dim line sits "above" the anchors (local-Y)
602
+ return Math.min(A[0],B[0])-28/zoom;} // Y dim line sits "left" of the anchors (local-X)
551
603
  // one dimension -> SVG string. opts: {id, sel, preview}. Lines use non-scaling-stroke (constant on
552
604
  // 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);
605
+ function dimSvg(d,o){o=o||{};const g=dimGeo(d.a,d.b,d.axis,d.off,d.rot);
554
606
  const c=(o.preview?'dimprev ':'')+(o.sel?'dimsel ':'');const txt=esc(dimValueText(g.px));
555
607
  const cw=txt.length*7+16,ch=18,dd=o.id?` data-dim="${esc(o.id)}"`:'';
556
608
  // a free (aligned) dim rotates its chip+text to the line, like a drafting dimension; x/y keep it
557
609
  // horizontal (more readable). Clamp to the −90..90 arc so the text never reads upside-down; within
558
610
  // 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;}
611
+ 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
612
  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
613
  s+=`<line class="dim ${c}"${dd} x1="${g.p1[0]}" y1="${g.p1[1]}" x2="${g.p2[0]}" y2="${g.p2[1]}"/>`;
562
614
  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 +739,8 @@ function render(){
687
739
  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
740
  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
741
  s+=renderDims();
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();
742
+ 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)
743
+ 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
744
  if(view3d&&window.Steel3DView)window.Steel3DView.setSelection(selIds); // keep the 3D highlight in sync with the 2D selection
692
745
  }
693
746
  function updDup(){const n=redundantDups().length;
@@ -703,7 +756,8 @@ function updateBadges(){const R=12/zoom,F=13/zoom,ox=el=>(+el.dataset.fi-(+el.da
703
756
  const dz=13/zoom;svg.querySelectorAll('text.dimtx').forEach(t=>t.style.fontSize=dz+'px');
704
757
  svg.querySelectorAll('circle.dimend').forEach(c=>c.setAttribute('r',3/zoom));
705
758
  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);});}
759
+ r.setAttribute('width',w);r.setAttribute('height',h);r.setAttribute('x',(+r.dataset.cx)-w/2);r.setAttribute('y',(+r.dataset.cy)-h/2);});
760
+ 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
761
  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
762
  function updateLine(m){const ln=svg.querySelector(`line.member[data-id="${m.id}"]`);
709
763
  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 +772,10 @@ const VARIES=Symbol('varies');
718
772
  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
773
  function panel(){
720
774
  const p=document.getElementById('panel');
775
+ if(!selDimIds.size||!dimsVisible)dimSplitMode=false;document.body.classList.toggle('dimsplit',dimSplitMode); // split mode is meaningless without a (visible) dim selected — also disarms when dims are hidden
776
+ if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
777
+ <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>
778
+ <div class=hint style="margin-top:8px">Two clicks define the frame; snaps to member ends &amp; grid (<b>Alt</b> off). <b>Esc</b> cancels.</div>`;return;}
721
779
  if(dimMode){p.innerHTML=`<span class=badge>Dimension</span>
722
780
  <div class=hint id=dimHint style="margin-top:8px">${esc(dimHintText())}</div>
723
781
  <div class=sect style="margin-top:12px">Direction</div>
@@ -730,6 +788,14 @@ function panel(){
730
788
  document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
731
789
  document.getElementById('dimChainB').onclick=()=>toggleDimChain();
732
790
  return;}
791
+ if(selDimIds.size&&!selIds.size&&!dimMode&&mode==='sel'){const one=selDimIds.size===1?P.dims.find(x=>x.id===[...selDimIds][0]):null;
792
+ p.innerHTML=`<span class=badge>Dimension${selDimIds.size>1?'s · '+selDimIds.size:''}</span>
793
+ ${one?`<div class=hint style="margin-top:8px">Length <b>${esc(dimValueText(dimGeo(one.a,one.b,one.axis,one.off).px))}</b> · ${({free:'Free (aligned)',x:'X (horizontal)',y:'Y (vertical)'})[one.axis]}</div>`:''}
794
+ <div class=sect style="margin-top:12px">Edit</div>
795
+ <div class=seg2><button id=dimSplitB class="${dimSplitMode?'on':''}" title="Add split point (S) — click points along the dimension to insert extra dimension points; each click splits the segment under it. Esc ends.">✂ Add split point</button></div>
796
+ <div class=hint style="margin-top:8px">${dimSplitMode?'Click points along the dimension to split it — keep clicking to add more. <b>Alt</b> = no snap · <b>Esc</b> ends.':'Drag an end handle to re-measure · <b>Del</b> removes · <b>Esc</b> deselects.'}</div>`;
797
+ document.getElementById('dimSplitB').onclick=()=>toggleDimSplit();
798
+ return;}
733
799
  if(mode==='add'){
734
800
  p.innerHTML=`<span class=badge>Add member</span>
735
801
  <div class=sect>Current profile</div>
@@ -803,7 +869,7 @@ function panel(){
803
869
  document.getElementById('verifyBtn').onclick=()=>edit(()=>{const v=!allVerified;for(const m of selArr())m.verified=v;});
804
870
  {const _gel=document.getElementById('geoEL');if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};}
805
871
  {const _sw=document.getElementById('swapEnds');if(_sw)_sw.onclick=()=>edit(()=>{for(const m of selArr())swapMemberEnds(m);});} // reverse start↔end for the whole selection (2D + 3D via render+sync3D)
806
- document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();});
872
+ document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(m=>!selIds.has(m.id));selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // a mixed marquee selection also holds dims — remove them too (matches keyboard Delete)
807
873
  {const sp=v=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.position=v;});
808
874
  const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
809
875
  if(_pt)_pt.onclick=()=>sp('top');if(_pm)_pm.onclick=()=>sp('middle');if(_pb)_pb.onclick=()=>sp('bottom');}
@@ -879,7 +945,7 @@ function panel(){
879
945
  wireDet('dtB',m.ends[1]);
880
946
  document.getElementById('matchEnds').onclick=()=>edit(()=>{m.ends[1]={tos:m.ends[0].tos,note:m.ends[0].note,tosDef:m.ends[0].tosDef,detail:m.ends[0].detail};});
881
947
  }
882
- document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();});
948
+ document.getElementById('del').onclick=()=>edit(()=>{P.members=P.members.filter(x=>x.id!==m.id);selIds.clear();if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}}); // also drop any dims a mixed marquee selected
883
949
  {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
884
950
  }
885
951
  function addFromSeg(sid){const sg=P.segments.find(s=>s.id===sid);if(!sg)return;const prof=addProfile;const id='m'+Date.now();const pv=snapshot();
@@ -918,62 +984,134 @@ function snapClear(){const c=document.getElementById('snapMark');if(c)c.remove()
918
984
  // svg.innerHTML wholesale, so we only render() on commit/cancel — like the draw/marquee temps). ---
919
985
  const SVGNS='http://www.w3.org/2000/svg';
920
986
  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
987
+ 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
988
  dimDraft=null;dimChainPrev=null;dimPrevClear();}
923
989
  function dimSnapAt(e){const q=toSvg(e);let x=q.x,y=q.y;
924
990
  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
991
  return {x,y,raw:[q.x,q.y],hit:false};}
926
992
  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
993
+ 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
994
+ 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
995
+ edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off,rot});});
996
+ dimChainPrev={point:b,axis,off,rot};dimPrev(e);return;} // keep going from b; Esc/Enter ends the chain
932
997
  if(!dimDraft){dimDraft={a:[s.x,s.y],b:null,axis:dimAxis};dimPrev(e);return;} // click 1 → first anchor
933
998
  if(!dimDraft.b){dimDraft.b=[s.x,s.y];dimPrev(e);return;} // click 2 → second anchor (value locks)
934
999
  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
1000
+ const rot=axis==='free'?0:frameRot(); // bake the active frame so a later Reset/redefine never moves this dim (free is frame-independent)
1001
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,axis,s.raw,rot); // click 3 → offset/side from raw pointer
936
1002
  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
1003
+ edit(()=>{P.dims.push({id:'d'+Date.now()+'_'+(dimSeq++),a,b,axis,off,rot});}); // undoable; render() clears the preview
1004
+ if(dimChain)dimChainPrev={point:b,axis,off,rot}; // chain mode → continue from b along this same offset line + baked frame
939
1005
  dimDraft=null;}
940
1006
  function dimPrevClear(){const g=document.getElementById('dimPrevG');if(g)g.remove();snapClear();}
941
1007
  function dimPrev(e){if(!dimMode){dimPrevClear();return;}
942
1008
  {const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();} // keep the panel hint live (chain / stage state)
943
1009
  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
1010
  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;}
1011
+ 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
1012
  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
1013
  let g=document.getElementById('dimPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='dimPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
948
1014
  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
1015
  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;}
1016
+ 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
1017
+ const rot=ax==='free'?0:frameRot();
1018
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:ax,off:dimDefaultOff(dimDraft.a,b,ax,rot),rot},{preview:true});return;}
952
1019
  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;}
1020
+ const ax=e.shiftKey?dominantAxis(dimDraft.a,dimDraft.b):dimDraft.axis; // Shift = momentary dominant-axis lock (local-frame aware)
1021
+ const rot=ax==='free'?0:frameRot();
1022
+ const off=dimOffFromPointer(dimDraft.a,dimDraft.b,ax,[q.x,q.y],rot); // stage 2: full dim rides the pointer
1023
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:ax,off,rot},{preview:true});dimDraft._ax=ax;}
956
1024
  function dimSetAxis(ax){dimAxis=ax;if(dimDraft)dimDraft.axis=ax;
957
1025
  const h=document.getElementById('dimHint');if(h)h.textContent=dimHintText();
958
1026
  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
1027
  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.';}
1028
+ const loc=!!(P&&P.frame);
1029
+ const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
1030
+ 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
1031
  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)
1032
+ function toggleDimSplit(){dimSplitMode=!dimSplitMode;snapClear();render();} // S / "Add split point": arm splitting on the selected dim(s); each click then inserts a point
1033
+ // Split the SELECTED dim nearest the click into two at a point ON its baseline (same axis + offset).
1034
+ // Picks the closest selected dim within a screen tolerance whose interior the click projects into; the
1035
+ // split point is the perpendicular foot, so a free (oblique) dim never kinks and the total is preserved.
1036
+ // Keeps both halves selected so you can keep adding points. No-op (with a hint) if the click isn't on a dim.
1037
+ function dimSplitAt(m){
1038
+ const tol=16/zoom;let best=null;
1039
+ for(const d of P.dims){if(!selDimIds.has(d.id))continue;
1040
+ const pr=projPt(m,d.a,d.b); // foot on the a→b baseline + parameter t (clamped 0..1)
1041
+ if(pr.t<=0.02||pr.t>=0.98)continue; // not interior → can't split here
1042
+ const dist=Math.hypot(m[0]-pr.pt[0],m[1]-pr.pt[1]); // how far the click is from this dim's line
1043
+ if(dist<=tol&&(!best||dist<best.dist))best={d,pt:pr.pt,dist};}
1044
+ if(!best){toast('Click on the dimension (between its ends) to split it');return;}
1045
+ const d=best.d,mid=best.pt; // split point ON the baseline (no kink)
1046
+ const id1='d'+Date.now()+'_'+(dimSeq++),id2='d'+Date.now()+'_'+(dimSeq++);
1047
+ const d1={id:id1,a:d.a.slice(),b:mid.slice(),axis:d.axis,off:d.off,rot:d.rot},d2={id:id2,a:mid.slice(),b:d.b.slice(),axis:d.axis,off:d.off,rot:d.rot}; // halves inherit the parent's baked local-frame rotation (undefined = global)
1048
+ const pv=snapshot();P.dims=P.dims.filter(x=>x.id!==d.id);P.dims.push(d1,d2);
1049
+ selDimIds.delete(d.id);selDimIds.add(id1);selDimIds.add(id2); // both halves stay selected → keep adding points
1050
+ pushUndo(pv);render();sync3D();
1051
+ }
963
1052
  let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
964
1053
  function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
965
1054
  const g=document.getElementById('dimPrevG');if(!g)return;
1055
+ const rot=dimDraft.axis==='free'?0:frameRot();
966
1056
  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});}
1057
+ g.innerHTML=dimSvg({a:dimDraft.a,b,axis:dimDraft.axis,off:dimDefaultOff(dimDraft.a,b,dimDraft.axis,rot),rot},{preview:true});return;}
1058
+ 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
1059
+ g.innerHTML=dimSvg({a:dimDraft.a,b:dimDraft.b,axis:dimDraft.axis,off,rot},{preview:true});}
1060
+ // --- Local coordinate system "set axes" tool: two snapped clicks (origin, then X-direction) define
1061
+ // P.frame={o,u}. Shares buildSnap/snap/snapMark; preview lives in its own <g> (render() rebuilds wholesale). ---
1062
+ function setCsMode(){document.body.classList.toggle('csaxison',csaxisMode);
1063
+ const b=document.getElementById('csSetB');if(b)b.classList.toggle('on',csaxisMode);
1064
+ 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
1065
+ csDraft=null;csPrevClear();}
1066
+ 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];}
1067
+ function csClick(e){const p=csSnapAt(e);
1068
+ if(!csDraft){csDraft=p;csPrev(e);panel();return;} // click 1 → origin (refresh the panel hint to "click the 2nd point")
1069
+ const dx=p[0]-csDraft[0],dy=p[1]-csDraft[1],L=Math.hypot(dx,dy); // click 2 → X-direction
1070
+ if(L<1e-6){toast('Pick two distinct points for the X axis');return;} // degenerate → stay armed
1071
+ const o=csDraft.slice(),u=[dx/L,dy/L];
1072
+ csaxisMode=false;setCsMode();
1073
+ edit(()=>{P.frame={o,u};}); // undoable + persisted; render() draws the glyph
1074
+ toast('Local axes set ('+Math.round(Math.atan2(u[1],u[0])*180/Math.PI)+'°) — ortho & X/Y dims now follow them');}
1075
+ function csPrevClear(){const g=document.getElementById('csPrevG');if(g)g.remove();snapClear();}
1076
+ function csPrev(e){if(!csaxisMode){csPrevClear();return;}
1077
+ const p=csSnapAt(e);(!e.altKey)?snapMark(p[0],p[1]):snapClear();
1078
+ let g=document.getElementById('csPrevG');if(!g){g=document.createElementNS(SVGNS,'g');g.id='csPrevG';g.setAttribute('pointer-events','none');svg.appendChild(g);}
1079
+ 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
1080
+ function resetFrame(){if(!P.frame)return;edit(()=>{P.frame=null;});toast('Axes reset to global');}
1081
+ // One coordinate-axes glyph → SVG string. `dir` = any vector along local-X (normalized here). Screen-constant
1082
+ // size (÷zoom), re-synced on zoom by updateBadges. Tekla axis colours: X = red, Y = green, origin = Z blue
1083
+ // (local-Y = [uy,-ux], plan X-right→Y-up; in plan view Z points out of screen, so it's the origin dot).
1084
+ function axisGlyphSvg(o,dir,preview){
1085
+ 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;}
1086
+ 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];
1087
+ const xt=[ox+ux*len,oy+uy*len],yt=[ox+vx*len,oy+vy*len];
1088
+ 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
1089
+ const barbs=(tip,ax,ay)=>{const px=-ay,py=ax; // arrowhead: tip + two barbs back along (ax,ay)
1090
+ 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}`;};
1091
+ // dark halo under each arm so the X/Y axis stays readable where a same-colour member runs through it
1092
+ const arm=(tip,col)=>`<line x1="${ox}" y1="${oy}" x2="${tip[0]}" y2="${tip[1]}" stroke="#0b1220" stroke-width="${halo}" stroke-linecap="round"/>`
1093
+ +`<line x1="${ox}" y1="${oy}" x2="${tip[0]}" y2="${tip[1]}" stroke="${col}" stroke-width="${sw}"/>`;
1094
+ const r=7/zoom; // label backing chip (dark, axis-coloured edge — like the dim chip), keeps the letter off the drawing
1095
+ 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}"/>`
1096
+ +`<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>`;
1097
+ return `<g class="csglyph"${preview?' opacity="0.85"':''} pointer-events="none">`
1098
+ +arm(xt,'#ef4444')+`<polygon points="${barbs(xt,ux,uy)}" fill="#ef4444"/>`
1099
+ +arm(yt,'#22c55e')+`<polygon points="${barbs(yt,vx,vy)}" fill="#22c55e"/>`
1100
+ +`<circle cx="${ox}" cy="${oy}" r="${4/zoom}" fill="#3b82f6" stroke="#e2e8f0" stroke-width="${1.5/zoom}"/>`
1101
+ +tag(lx,'#ef4444','X')+tag(ly,'#22c55e','Y')+`</g>`;}
1102
+ // header chip + the More-menu "Reset to global" enabled state — reflect the current frame.
1103
+ function updCS(){const s=document.getElementById('csStat');
1104
+ 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.';}
1105
+ const r=document.getElementById('csResetB');if(r)r.disabled=!(P&&P.frame);}
970
1106
  // themed transient toast (baseline tokens — never a native alert)
971
1107
  function toast(msg){let t=document.getElementById('toast');
972
1108
  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
1109
  t.textContent=msg;t.style.opacity='1';clearTimeout(t._h);t._h=setTimeout(()=>{t.style.opacity='0';},2600);}
974
1110
  // --- pointer: select(+ctrl toggle / box) + group-move + endpoint (Shift = ortho) ---
975
1111
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1112
+ if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
976
1113
  if(dimMode){dimClick(e);e.preventDefault();return;} // tool armed → all clicks place dimension points
1114
+ if(dimSplitMode&&selDimIds.size&&dimsVisible&&mode==='sel'){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);x=sn.x;y=sn.y;}snapClear();dimSplitAt([x,y]);e.preventDefault();return;} // split mode → each click inserts a point into the selected dim under it
977
1115
  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
1116
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
979
1117
  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
@@ -1013,21 +1151,23 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1013
1151
  const rc=document.createElementNS('http://www.w3.org/2000/svg','rect');rc.setAttribute('class','marquee');svg.appendChild(rc);
1014
1152
  drag={type:'marquee',x0:p.x,y0:p.y,add:(e.ctrlKey||e.metaKey),rect:rc};svg.setPointerCapture(e.pointerId);e.preventDefault();}});
1015
1153
  svg.addEventListener('pointermove',e=>{
1154
+ if(csaxisMode){csPrev(e);return;}
1016
1155
  if(dimMode){dimPrev(e);return;}
1156
+ if(dimSplitMode&&dimsVisible){let q=toSvg(e),x=q.x,y=q.y;if(!e.altKey){buildSnap(null);const sn=snap(x,y);sn.hit?snapMark(sn.x,sn.y):snapClear();}else snapClear();return;} // split mode → show the snap marker for the prospective split point
1017
1157
  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
1158
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
1019
1159
  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
1160
  if(!drag)return;const p=toSvg(e);
1021
1161
  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;}
1162
+ 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
1163
  if(drag.type==='draw'){let x=p.x,y=p.y;
1024
- if(e.shiftKey){if(Math.abs(x-drag.x0)>=Math.abs(y-drag.y0))y=drag.y0;else x=drag.x0;snapClear();}
1164
+ if(e.shiftKey){[x,y]=orthoLock(drag.x0,drag.y0,x,y);snapClear();} // ortho draw — local frame when set, else screen H/V
1025
1165
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
1026
1166
  drag.line.setAttribute('x2',x);drag.line.setAttribute('y2',y);drag.cur=[x,y];return;}
1027
1167
  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
1168
  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
1169
  if(drag.type==='move'){let dx=p.x-drag.start[0],dy=p.y-drag.start[1];
1030
- if(e.shiftKey){if(Math.abs(dx)>=Math.abs(dy))dy=0;else dx=0;snapClear();}
1170
+ 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
1171
  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
1172
  for(const it of drag.items)for(const o of [it.o0,it.o1]){const nx=o[0]+dx,ny=o[1]+dy;
1033
1173
  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 +1179,7 @@ svg.addEventListener('pointermove',e=>{
1039
1179
  if(selIds.size<=2)for(const m of selArr())updateHandles(m);return;}
1040
1180
  // endpoint
1041
1181
  const m=byId(drag.id);if(!m)return;let x=p.x,y=p.y;
1042
- if(e.shiftKey){if(Math.abs(x-drag.anchor[0])>=Math.abs(y-drag.anchor[1]))y=drag.anchor[1];else x=drag.anchor[0];snapClear();}
1182
+ 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
1183
  else if(!e.altKey){const sn=snap(x,y);x=sn.x;y=sn.y;sn.hit?snapMark(x,y):snapClear();}else snapClear();
1044
1184
  m.wp[drag.h]=[x,y];updateLine(m);updateHandles(m);});
1045
1185
  svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
@@ -1050,12 +1190,14 @@ svg.addEventListener('pointerup',()=>{if(!drag)return;snapClear();
1050
1190
  drag=null;render();return;}
1051
1191
  if(drag.type==='marquee'){if(!drag.add){selIds.clear();selDimIds.clear();}
1052
1192
  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)
1193
+ 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
1194
  drag.rect.remove();drag=null;render();return;}
1055
1195
  if(drag.pre&&snapshot()!==drag.pre)pushUndo(drag.pre);drag=null;render();});
1056
1196
  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 tool (else dimMode keeps eating canvas clicks) and drops any dim selection
1058
- document.getElementById('dimB').onclick=()=>{dimMode=!dimMode;setDimMode();render();};
1197
+ 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
1198
+ document.getElementById('dimB').onclick=()=>{if(csaxisMode){csaxisMode=false;setCsMode();}dimMode=!dimMode;setDimMode();render();};
1199
+ document.getElementById('csSetB').onclick=()=>{csaxisMode=!csaxisMode;setCsMode();render();};
1200
+ document.getElementById('csResetB').onclick=()=>{resetFrame();render();};
1059
1201
  function updDimToggle(){const b=document.getElementById('dimToggleB');if(b)b.textContent=dimsVisible?'Hide dimensions':'Show dimensions';}
1060
1202
  document.getElementById('dimToggleB').onclick=()=>{dimsVisible=!dimsVisible;updDimToggle();render();};
1061
1203
  document.getElementById('dupB').onclick=()=>{const ids=redundantDups(); // re-scan on demand (also runs live after every edit)
@@ -1084,8 +1226,10 @@ addEventListener('keydown',e=>{
1084
1226
  if(e.key==='Escape'&&confOpen()){closeConf();return;}
1085
1227
  if(e.key==='Home'){e.preventDefault();if(view3d&&window.Steel3DView)window.Steel3DView.frameAll();else fitToWindow();return;}
1086
1228
  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)
1229
+ 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
1230
  if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1088
1231
  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
1232
+ if(e.key==='Escape'&&selDimIds.size){if(dimSplitMode){dimSplitMode=false;snapClear();render();}else{selDimIds.clear();render();}return;} // Esc on a selected dim: end split mode → else deselect
1089
1233
  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;}
1090
1234
  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
1235
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
@@ -1094,10 +1238,11 @@ addEventListener('keydown',e=>{
1094
1238
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1095
1239
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1096
1240
  if(dk==='c'){e.preventDefault();toggleDimChain();return;}} // C — toggle chained (continuous) dimensioning
1241
+ if(!inForm&&selDimIds.size>=1&&!selIds.size&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&e.key.toLowerCase()==='s'){e.preventDefault();toggleDimSplit();return;} // S — toggle "add split point" on the selected dimension(s)
1097
1242
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
1098
1243
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
1099
1244
  if(kk==='p'){e.preventDefault();edit(()=>{for(const m of selArr())swapMemberEnds(m);});return;} // P — swap start↔end for the whole selection (2D + 3D)
1100
- if(kk==='s'&&selIds.size===1){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // Split — single only
1245
+ if(kk==='s'&&selIds.size===1&&!selDimIds.size){e.preventDefault();geoMode=(geoMode==='split'?null:'split');setGeo();render();return;}} // member Split — single member only, and not while dims are also selected (mixed → ambiguous, so neither split fires)
1101
1246
  if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
1102
1247
  if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
1103
1248
  else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
@@ -1492,6 +1637,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
1492
1637
  if(!Array.isArray(P.members))P.members=[];
1493
1638
  if(!Array.isArray(P.segments))P.segments=[];
1494
1639
  if(!Array.isArray(P.dims))P.dims=[];
1640
+ sanitizeFrame(P); // tolerate a hand-edited/AI-written frame: normalize or drop to global
1495
1641
  // clip = the drawing's display-space crop. A contract may ship WITHOUT a raster/clip (a shared or
1496
1642
  // committed contract strips raster_b64 for confidentiality), so fall back to the members' bounding
1497
1643
  // box (padded) → the editor renders members on a blank canvas instead of crashing on P.clip[0].
@@ -1514,7 +1660,8 @@ function setPlan(i){C.active=i;P=C.plans[i];
1514
1660
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
1515
1661
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
1516
1662
  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)
1663
+ dimMode=false;dimChain=false;dimSplitMode=false;selDimIds=new Set();setDimMode(); // Dimension tool resets per plan (incl. chain + split); setDimMode syncs the button/body.dimon classes + clears any draft/preview/chain (dimsVisible persists across plans)
1664
+ csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
1518
1665
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
1519
1666
  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
1667
  if(!P.autofilled){autofillTOS();P.autofilled=true;}
@@ -1529,6 +1676,37 @@ const _restored=restoreSaved(); // bring back this brows
1529
1676
  setPlan(C.active==null?0:C.active);
1530
1677
  setSaved(_restored?'ok':'idle', _restored?'Restored ✓':'Auto-save on');
1531
1678
  try{stage.focus({preventScroll:true});}catch(_){} // grab keyboard focus on open so shortcuts work before the first click
1679
+ // --- ?selftest=1 — cheap regression guard for the only non-trivial new math (local frame). Logs PASS/FAIL
1680
+ // to the console and a toast; never runs in normal use. (ponytail: one runnable check for the frame logic.)
1681
+ if(new URLSearchParams(location.search).get('selftest')==='1'){(function(){
1682
+ const fails=[];const ok=(c,m)=>{if(!c)fails.push(m);};
1683
+ 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);
1684
+ const sf=P.frame;
1685
+ // 1) orthoLock — global (no frame): snaps to screen H/V
1686
+ P.frame=null;
1687
+ ok(pnear(orthoLock(0,0,3,1),[3,0]),'orthoLock global x-dominant');
1688
+ ok(pnear(orthoLock(0,0,1,3),[0,3]),'orthoLock global y-dominant');
1689
+ // 2) orthoLock — frame with local-X pointing down u=(0,1), local-Y v=(1,0)
1690
+ P.frame={o:[0,0],u:[0,1]};
1691
+ ok(pnear(orthoLock(0,0,3,1),[3,0]),'orthoLock framed locks to local-Y');
1692
+ ok(pnear(orthoLock(0,0,1,3),[0,3]),'orthoLock framed locks to local-X');
1693
+ ok(dominantAxis([0,0],[1,3])==='x'&&dominantAxis([0,0],[3,1])==='y','dominantAxis framed');
1694
+ // 3) dim rot — an x dim under a 90° frame measures along local-X (here 0), y measures 10
1695
+ P.frame=null;
1696
+ ok(near(dimGeo([0,0],[10,0],'x',0,0).px,10),'dimGeo x rot0');
1697
+ ok(near(dimGeo([0,0],[10,0],'x',0,Math.PI/2).px,0),'dimGeo x rot90 → along local-X');
1698
+ ok(near(dimGeo([0,0],[10,0],'y',0,Math.PI/2).px,10),'dimGeo y rot90 → along local-Y');
1699
+ ok(near(dimGeo([0,0],[3,4],'free',0,Math.PI/2).px,5),'dimGeo free rot-independent');
1700
+ // 4) witness "from" maps back to the anchor after the rotate-out
1701
+ 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');
1702
+ // 5) sanitizeFrame — normalize a non-unit u, drop zero-length / malformed (external/AI contract safety)
1703
+ {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');}
1704
+ {const tz={frame:{o:[0,0],u:[0,0]}};sanitizeFrame(tz);ok(tz.frame===null,'sanitizeFrame drops zero-length u');}
1705
+ {const tb={frame:{o:[0,0],u:[1]}};sanitizeFrame(tb);ok(tb.frame===null,'sanitizeFrame drops malformed u');}
1706
+ P.frame=sf; // restore
1707
+ const msg=fails.length?('SELFTEST FAIL: '+fails.join(' | ')):'SELFTEST PASS (local-frame math)';
1708
+ console[fails.length?'error':'log'](msg);try{toast(msg);}catch(_){}
1709
+ })();}
1532
1710
  // --- provenance caption: show contract.source when present (read from <name> · <sheet> · <read_at>) ---
1533
1711
  (function(){
1534
1712
  const src=C.source;if(!src||typeof src!=='object')return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "description": "Thin localhost host for floless.app — serves web/ and shells the aware CLI. No engine, no LLM.",
6
6
  "bin": {