@floless/app 0.40.0 → 0.42.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.40.0" : void 0,
52859
+ define: true ? "0.42.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.40.0" : void 0 });
52869
+ return resolveChannel({ isSea: isSea2(), define: true ? "0.42.0" : void 0 });
52870
52870
  }
52871
52871
 
52872
52872
  // oauth-presets.ts
@@ -53752,18 +53752,23 @@ function bakeContractIntoApp(sourcePath, contract) {
53752
53752
  if (idx < 0) idx = items.findIndex((it) => nodeOf(it)?.agent === "steel-takeoff-us");
53753
53753
  if (idx < 0) throw new Error("no contract-emitting node to bake the contract into");
53754
53754
  doc2.setIn(["nodes", idx, "config", "contract"], contract.type);
53755
- const plans = contract.plans;
53756
- const baked = Array.isArray(plans) ? {
53757
- ...contract,
53758
- plans: plans.map((p) => {
53755
+ const baked = { ...contract };
53756
+ if (Array.isArray(contract.plans)) {
53757
+ baked.plans = contract.plans.map((p) => {
53759
53758
  if (p && typeof p === "object") {
53760
53759
  const clone = { ...p };
53761
53760
  delete clone.dims;
53762
53761
  return clone;
53763
53762
  }
53764
53763
  return p;
53765
- })
53766
- } : contract;
53764
+ });
53765
+ }
53766
+ const filter = baked.filter;
53767
+ if (filter && typeof filter === "object" && filter.page && typeof filter.page === "object") {
53768
+ const page = { ...filter.page };
53769
+ delete page.bg_b64;
53770
+ baked.filter = { ...filter, page };
53771
+ }
53767
53772
  doc2.setIn(["nodes", idx, "config", "takeoff"], baked);
53768
53773
  (0, import_node_fs17.writeFileSync)(sourcePath, doc2.toString());
53769
53774
  }
@@ -36,9 +36,89 @@
36
36
  },
37
37
  "additionalProperties": false
38
38
  },
39
- "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } }
39
+ "plans": { "type": "array", "items": { "$ref": "#/$defs/plan" } },
40
+ "filter": { "$ref": "#/$defs/filter", "description": "Layer/thickness/colour filter pre-stage dataset from one compose-time PyMuPDF pass. The saved selection (per-facet `on` flags + `mode`) scopes which lines the reader binds profile labels to; replaces the length heuristic, fallback when absent. CONFIDENTIAL: `page.bg_b64` is machine-local, never committed." }
40
41
  },
41
42
  "$defs": {
43
+ "filter": {
44
+ "type": "object",
45
+ "additionalProperties": true,
46
+ "description": "Steel filter pre-stage. Lines render as SVG paths over a dimmed page raster; text spans make profile labels pickable (and feed the label-binding step).",
47
+ "properties": {
48
+ "page": {
49
+ "type": "object",
50
+ "required": ["w", "h"],
51
+ "description": "When present, w and h are required and positive — the view sets the SVG viewBox from them (an empty page would yield viewBox=\"NaN …\").",
52
+ "properties": {
53
+ "w": { "type": "number", "minimum": 1 },
54
+ "h": { "type": "number", "minimum": 1 },
55
+ "bg_b64": { "type": "string", "description": "Base64 JPEG of the page (title block excluded). CONFIDENTIAL — machine-local, stripped from the baked .flo (contract-bake.ts), never committed." }
56
+ }
57
+ },
58
+ "steelBbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0,y0,x1,y1] of the steel linework, for Zoom-to-steel." },
59
+ "mode": { "enum": ["thickness", "layer", "color", "thickness+layer", "thickness+color"], "description": "Active match mode." },
60
+ "hide_title_block": { "type": "boolean" },
61
+ "layers": {
62
+ "type": "array",
63
+ "items": {
64
+ "type": "object",
65
+ "required": ["name"],
66
+ "additionalProperties": true,
67
+ "properties": {
68
+ "name": { "type": "string" },
69
+ "count": { "type": "number" },
70
+ "steel": { "type": "boolean", "description": "Heuristic steel layer — defaults ON at first load." },
71
+ "drawable": { "type": "boolean", "description": "false = listed but not drawn (e.g. a text/title-block layer)." },
72
+ "on": { "type": "boolean", "description": "Persisted selection state." }
73
+ }
74
+ }
75
+ },
76
+ "thicknesses": {
77
+ "type": "array",
78
+ "items": {
79
+ "type": "object",
80
+ "required": ["w"],
81
+ "additionalProperties": true,
82
+ "properties": {
83
+ "w": { "type": "number", "description": "Stroke width in pt." },
84
+ "count": { "type": "number" },
85
+ "on": { "type": "boolean" }
86
+ }
87
+ }
88
+ },
89
+ "colors": {
90
+ "type": "array",
91
+ "items": {
92
+ "type": "object",
93
+ "required": ["hex"],
94
+ "additionalProperties": true,
95
+ "properties": {
96
+ "hex": { "type": "string" },
97
+ "count": { "type": "number" },
98
+ "on": { "type": "boolean" }
99
+ }
100
+ }
101
+ },
102
+ "elements": {
103
+ "type": "array",
104
+ "description": "Every drawing element: lines (with an SVG path `d`) and text spans (with `text`+`bbox`), each tagged layer/thickness/colour.",
105
+ "items": {
106
+ "type": "object",
107
+ "additionalProperties": true,
108
+ "properties": {
109
+ "kind": { "enum": ["line", "text"] },
110
+ "layer": { "type": "string" },
111
+ "w": { "type": "number" },
112
+ "color": { "type": "string" },
113
+ "d": { "type": "string", "description": "SVG path (line elements)." },
114
+ "text": { "type": "string", "description": "Label text (text elements)." },
115
+ "bbox": { "type": "array", "items": { "type": "number" }, "description": "[x0,y0,x1,y1] (text elements)." },
116
+ "dashed": { "type": "boolean" }
117
+ }
118
+ }
119
+ }
120
+ }
121
+ },
42
122
  "point2": {
43
123
  "type": "array",
44
124
  "items": { "type": "number" },
@@ -39,8 +39,48 @@ file) and optionally `sourceName` (the original filename — use it for the `sou
39
39
  provenance field). If the user just attaches or points at a drawing in prose, skip the lookup
40
40
  and proceed from that path.
41
41
 
42
+ ### 1b. Filter pre-stage — isolate the steel linework FIRST (the `filter` node)
43
+
44
+ Before the heavy read, do **one** deterministic PyMuPDF pass that enumerates *every* drawing
45
+ element by **layer · thickness · colour** and bakes it into the contract's `filter` block. The user
46
+ then opens the **Filter** node (double-click → the steel-filter view) and keeps only the real steel
47
+ lines — by facet (layer/thickness/colour) or the **eyedropper** (click a line or label → Add its
48
+ layer/thickness/colour). The saved selection is what step 2 binds profile labels to, so a clean
49
+ framing plan goes in instead of the whole noisy sheet. This is a big reliability win
50
+ (`S-ANNO-BEAM-STEL` + thick line beats a length-only guess) **and** makes the profile-label search in
51
+ step 2 far cleaner (fewer candidate lines).
52
+
53
+ One pass, display space (`page.rotation_matrix` — same as step 2, never mix native/display coords):
54
+
55
+ 1. **Lines** — `page.get_drawings()`: for each path keep `{ kind:"line", layer:<OCG name>, w:<stroke
56
+ width pt>, color:<#rrggbb stroke>, d:<SVG path built from the path items>, dashed:<bool> }`. The
57
+ **layer** is the path's optional-content group (OCG) name; thickness is the stroke width; colour
58
+ the stroke colour.
59
+ 2. **Text** — `page.get_text("dict")` (or rawdict): for each span keep `{ kind:"text", layer:<OCG>,
60
+ w:0, color:<#hex>, text:<string>, bbox:[x0,y0,x1,y1] }`. Text elements make profile **labels**
61
+ pickable (the eyedropper) and are the candidate set step 2's label-binding iterates.
62
+ 3. **Facets** — aggregate distinct `layers` / `thicknesses` / `colors` with a `count` each. Round
63
+ each element's `w` and the facet `w` the **same** way — matching is exact-equality, so a rounding
64
+ mismatch would silently hide lines. Mark
65
+ `steel: true` on layers whose name reads as steel framing (e.g. `*BEAM*STEL*`, `*COL*`, `*BRACE*`,
66
+ `*JOIST*`) → those default **on**; everything else defaults off. Mark `drawable: false` on pure
67
+ text/dimension/title-block layers (listed for completeness, not drawn as lines).
68
+ 4. **Page + bbox** — `page: { w, h, bg_b64 }` where `bg_b64` is a dimmed raster of the framing area
69
+ (**title block excluded** — CONFIDENTIAL, machine-local, never committed); `steelBbox` = the bbox
70
+ of the steel-layer elements (for Zoom-to-steel). Set `mode: "layer"`.
71
+ 5. **PUT** the contract with this `filter` block (`PUT /api/contract/steel-takeoff`). On a re-read,
72
+ preserve any existing per-facet `on` flags the user already set.
73
+
74
+ Then tell the user to open the **Filter** node, narrow to the steel, and **Save**. The saved `on`
75
+ flags + `mode` are what step 2 reads.
76
+
42
77
  ### 2. Read it with your own vision — the heavy pipeline
43
78
 
79
+ > **Candidate geometry comes from the filter (1b) when present.** If the contract carries a saved
80
+ > `filter`, the member candidate set is the **kept** line elements (those whose layer/thickness/colour
81
+ > pass `mode` + the `on` flags) — bind labels to *those*, not to a length-filtered scan of the whole
82
+ > sheet. The length heuristic below is the **fallback** only when no filter was saved.
83
+
44
84
  Follow the methodology condensed here (full detail + every gotcha in
45
85
  `docs/superpowers/specs/2026-06-17-steel-takeoff-agent-guidance.md`):
46
86
 
package/dist/web/aware.js CHANGED
@@ -926,7 +926,12 @@
926
926
  // An html-report (static) or viewer-3d (interactive 3D) agent node renders HTML — its
927
927
  // `render` command returns an `html` field, exactly what extractReportHtml keys on. So
928
928
  // it's a report producer regardless of exec code; prefer it directly as the viewer node.
929
- const htmlReportNode = app.nodes.find((n) => n.agent === 'html-report' || n.agent === 'viewer-3d');
929
+ // A pre-stage authoring node (config.surface, e.g. the steel `filter` node) renders a summary
930
+ // but is NOT the workflow's report viewer — its affordance is its own surface button. Skip it,
931
+ // or prepending it would steal "View report ▸" from the real first report node (e.g. `read`).
932
+ const htmlReportNode = app.nodes.find(
933
+ (n) => (n.agent === 'html-report' || n.agent === 'viewer-3d') && !(n.config && n.config.surface),
934
+ );
930
935
  if (htmlReportNode) return htmlReportNode.id;
931
936
  // Otherwise, qualify the app only if some node actually PRODUCES report HTML — its exec
932
937
  // code returns an `html` field (the `data.result.html` extractReportHtml keys
@@ -1068,6 +1073,11 @@
1068
1073
  teklaBtn.dataset.tip = 'Tekla must be open with a model loaded. Click to create native parts in it.';
1069
1074
  card.dataset.tip = 'Double-click to open the contract editor';
1070
1075
  }
1076
+ // Filter pre-stage node — opens the served steel-filter view (facets + eyedropper → contract.filter).
1077
+ if (node && node.config && node.config.surface === 'steel-filter') {
1078
+ addNodeAction(card, 'Filter ▸', () => openFilterView(currentId));
1079
+ if (!card.dataset.tip) card.dataset.tip = 'Double-click to open the steel filter';
1080
+ }
1071
1081
  });
1072
1082
  }
1073
1083
 
@@ -1428,6 +1438,9 @@
1428
1438
  function openContractEditor(appId, type) {
1429
1439
  const r = window.CONTRACT_RENDERERS && window.CONTRACT_RENDERERS[type];
1430
1440
  if (!r) return false;
1441
+ // The editor bakes the .lock on Approve — that control belongs to the contract editor, not the
1442
+ // filter view (which has its own Save). Make sure it's visible here (openFilterView hides it).
1443
+ const ap0 = document.getElementById('contract-editor-approve'); if (ap0) ap0.hidden = false;
1431
1444
  $contractEditorTitle.replaceChildren(
1432
1445
  Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1433
1446
  Object.assign(document.createElement('span'), { textContent: ' · ' + type, style: 'color:var(--text-muted)' }),
@@ -1439,6 +1452,22 @@
1439
1452
  $contractEditor.hidden = false;
1440
1453
  return true;
1441
1454
  }
1455
+ // The steel filter pre-stage view — reuses the contract-editor overlay/iframe to load the served
1456
+ // steel-filter.html (layer/thickness/colour facets + eyedropper). Same seam as the editor: the
1457
+ // iframe talks to /api/contract directly and Save writes contract.filter.
1458
+ function openFilterView(appId) {
1459
+ // The filter view has its own Save; the overlay's "Approve & bake lock" (editor-only) would
1460
+ // be a confusing no-op here, so hide it. openContractEditor restores it.
1461
+ const ap = document.getElementById('contract-editor-approve'); if (ap) ap.hidden = true;
1462
+ $contractEditorTitle.replaceChildren(
1463
+ Object.assign(document.createElement('span'), { textContent: appId, style: 'color:var(--text);font-weight:600' }),
1464
+ Object.assign(document.createElement('span'), { textContent: ' · steel filter', style: 'color:var(--text-muted)' }),
1465
+ );
1466
+ $contractEditorFrame.onload = () => { try { $contractEditorFrame.contentWindow.focus(); } catch (_) {} };
1467
+ $contractEditorFrame.src = '/steel-filter.html?app=' + encodeURIComponent(appId);
1468
+ $contractEditor.hidden = false;
1469
+ return true;
1470
+ }
1442
1471
  function closeContractEditor() {
1443
1472
  $contractEditor.hidden = true;
1444
1473
  $contractEditorFrame.src = 'about:blank'; // tear down the editor session + its draft listeners
@@ -2061,6 +2090,7 @@
2061
2090
  const node = app && (app.nodes || []).find((n) => n.id === id);
2062
2091
  const ct = window.contractTypeOf && window.contractTypeOf(node);
2063
2092
  if (ct && openContractEditor(currentId, ct)) return;
2093
+ if (node && node.config && node.config.surface === 'steel-filter') { openFilterView(currentId); return; }
2064
2094
  if (id === reportNodeId()) showReport(id);
2065
2095
  else if (id === inputNodeId()) openInputsDialog();
2066
2096
  });
@@ -151,6 +151,7 @@
151
151
  text.dimtx{fill:#e2e8f0;font-weight:600;font-family:system-ui;text-anchor:middle;dominant-baseline:central;pointer-events:none}
152
152
  line.dim.dimsel{stroke:#f59e0b} line.dimwit.dimsel{stroke:#f59e0b;opacity:1} rect.dimchip.dimsel{stroke:#f59e0b} circle.dimend.dimsel{fill:#f59e0b}
153
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}
154
155
  .dimprev{opacity:.85} line.dim.dimprev{stroke-dasharray:7 4}
155
156
  body.dimon line.member,body.dimon line.seg,body.dimon image{cursor:crosshair}
156
157
  body.csaxison #svg,body.csaxison line.member,body.csaxison line.seg,body.csaxison image{cursor:crosshair}
@@ -424,6 +425,7 @@ let mode='sel', drag=null, picking=false, pickKind='profile', pickEnd=null, geoM
424
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
425
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).
426
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
427
429
  let selIds=new Set();
428
430
  let undo=[], redo=[];
429
431
  const byId=id=>P.members.find(m=>m.id===id);
@@ -770,6 +772,7 @@ const VARIES=Symbol('varies');
770
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;}
771
773
  function panel(){
772
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
773
776
  if(csaxisMode){p.innerHTML=`<span class=badge>Set local axes</span>
774
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>
775
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;}
@@ -785,6 +788,14 @@ function panel(){
785
788
  document.getElementById('dimAxY').onclick=()=>{dimSetAxis('y');dimRefreshPrev();panel();};
786
789
  document.getElementById('dimChainB').onclick=()=>toggleDimChain();
787
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;}
788
799
  if(mode==='add'){
789
800
  p.innerHTML=`<span class=badge>Add member</span>
790
801
  <div class=sect>Current profile</div>
@@ -858,7 +869,7 @@ function panel(){
858
869
  document.getElementById('verifyBtn').onclick=()=>edit(()=>{const v=!allVerified;for(const m of selArr())m.verified=v;});
859
870
  {const _gel=document.getElementById('geoEL');if(_gel)_gel.onclick=()=>{geoMode=(geoMode==='el'?null:'el');setGeo();render();};}
860
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)
861
- 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)
862
873
  {const sp=v=>edit(()=>{for(const m of selArr())if(m.role!=='column')m.position=v;});
863
874
  const _pt=document.getElementById('posTop'),_pm=document.getElementById('posMid'),_pb=document.getElementById('posBot');
864
875
  if(_pt)_pt.onclick=()=>sp('top');if(_pm)_pm.onclick=()=>sp('middle');if(_pb)_pb.onclick=()=>sp('bottom');}
@@ -934,7 +945,7 @@ function panel(){
934
945
  wireDet('dtB',m.ends[1]);
935
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};});
936
947
  }
937
- 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
938
949
  {const vb=document.getElementById('verifyBtn');if(vb)vb.onclick=()=>edit(()=>{m.verified=!m.verified;});}
939
950
  }
940
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();
@@ -1018,6 +1029,26 @@ function dimHintText(){if(dimChainPrev)return 'Chaining — click the next point
1018
1029
  const a={free:'Free (aligned)',x:loc?'X (local)':'X (horizontal)',y:loc?'Y (local)':'Y (vertical)'}[dimDraft?dimDraft.axis:dimAxis];
1019
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.';}
1020
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
+ }
1021
1052
  let dimLastPtr=null; // last canvas pointer during placement, for axis-key live refresh
1022
1053
  function dimRefreshPrev(){if(!dimMode||!dimDraft||!dimLastPtr)return;
1023
1054
  const g=document.getElementById('dimPrevG');if(!g)return;
@@ -1080,6 +1111,7 @@ function toast(msg){let t=document.getElementById('toast');
1080
1111
  svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1081
1112
  if(csaxisMode){csClick(e);e.preventDefault();return;} // set-local-axes armed → clicks define origin then X-direction
1082
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
1083
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)
1084
1116
  if(t.dataset.dim&&mode==='sel'){const did=t.dataset.dim;selIds.clear(); // a dim click is member-exclusive (clears any member selection)
1085
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
@@ -1121,6 +1153,7 @@ svg.addEventListener('pointerdown',e=>{if(e.button!==0)return;const t=e.target;
1121
1153
  svg.addEventListener('pointermove',e=>{
1122
1154
  if(csaxisMode){csPrev(e);return;}
1123
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
1124
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]);
1125
1158
  const d=Math.hypot(q.x-raw.pt[0],q.y-raw.pt[1]);
1126
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;}
@@ -1196,6 +1229,7 @@ addEventListener('keydown',e=>{
1196
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
1197
1230
  if(e.key==='Enter'&&dimMode&&dimChainPrev){e.preventDefault();dimChainPrev=null;dimPrevClear();render();return;} // Enter ends a running chain
1198
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
1199
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;}
1200
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)
1201
1235
  if(selDimIds.size){P.dims=P.dims.filter(d=>!selDimIds.has(d.id));selDimIds.clear();}
@@ -1204,10 +1238,11 @@ addEventListener('keydown',e=>{
1204
1238
  if(dimMode&&!inForm&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const dk=e.key.toLowerCase();
1205
1239
  if(dk==='x'||dk==='y'||dk==='f'){e.preventDefault();dimSetAxis(dk==='f'?'free':dk);dimDraft&&svg.querySelector('#dimPrevG')&&dimRefreshPrev();return;}
1206
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)
1207
1242
  if(!inForm&&selIds.size>=1&&!e.ctrlKey&&!e.metaKey&&!e.altKey){const kk=e.key.toLowerCase();
1208
1243
  if(kk==='e'){e.preventDefault();geoMode=(geoMode==='el'?null:'el');setGeo();render();return;} // Extend/Trim — any selection
1209
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)
1210
- 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)
1211
1246
  if(!(e.ctrlKey||e.metaKey))return;const k=e.key.toLowerCase();
1212
1247
  if(k==='z'&&!e.shiftKey){e.preventDefault();doUndo();}
1213
1248
  else if(k==='y'||(k==='z'&&e.shiftKey)){e.preventDefault();doRedo();}
@@ -1625,7 +1660,7 @@ function setPlan(i){C.active=i;P=C.plans[i];
1625
1660
  profs=[...new Set([...P.members.map(m=>m.profile), ...Object.keys(WT)])].sort();
1626
1661
  undo=P.undo||(P.undo=[]);redo=P.redo||(P.redo=[]);
1627
1662
  selIds=new Set();picking=false;pickKind='profile';pickEnd=null;mode='sel';geoMode=null;
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)
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)
1629
1664
  csaxisMode=false;setCsMode(); // set-axes tool resets per plan; P.frame itself is per-plan data (persisted), so it stays
1630
1665
  defaultTOS=(P.default_tos!=null?P.default_tos:198);
1631
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)
@@ -0,0 +1,79 @@
1
+ /*
2
+ * steel-filter-core.js — PURE, framework-free core for the steel filter pre-stage (no DOM, no
3
+ * window). Unit-tested in node (server/steel-filter-core.test.ts) AND loaded by web/steel-filter.html.
4
+ *
5
+ * The filter pre-stage isolates the real steel linework on a drawing before the takeoff reader binds
6
+ * profile labels to it. The host AI runs ONE PyMuPDF pass at compose time and writes a `filter` block
7
+ * onto the steel.takeoff/v1 contract; this module is the pure match/selection logic the view and the
8
+ * reader both reason about. Lifted from the steel-parts-from-pdf.flo spike (`matchesActive`), extended
9
+ * with persistence (`on` flags) and the eyedropper add.
10
+ *
11
+ * Shapes:
12
+ * element : { kind:'line'|'text', layer:string, w:number, color:string, d?:string, text?:string, bbox?:number[], dashed?:boolean }
13
+ * mode : 'thickness' | 'layer' | 'color' | 'thickness+layer' | 'thickness+color'
14
+ * sel : { layers:Set<string>, thicknesses:Set<number>, colors:Set<string> }
15
+ * filter : { mode, layers:[{name,count,steel?,drawable?,on?}], thicknesses:[{w,count,on?}], colors:[{hex,count,on?}], elements:[…] }
16
+ */
17
+
18
+ /** The active match predicate — does this element pass the current mode + selection? */
19
+ export function matchesActive(el, mode, sel) {
20
+ const byLayer = () => sel.layers.has(el.layer);
21
+ const byThk = () => sel.thicknesses.has(el.w);
22
+ const byColor = () => sel.colors.has(el.color);
23
+ switch (mode) {
24
+ case 'thickness': return byThk();
25
+ case 'layer': return byLayer();
26
+ case 'color': return byColor();
27
+ case 'thickness+layer': return byThk() && byLayer();
28
+ case 'thickness+color': return byThk() && byColor();
29
+ default: return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Build the in-memory selection Sets from a persisted filter block's `on` flags. A missing `on`
35
+ * defaults to the sensible first-load state: layers default to their `steel` flag, thicknesses and
36
+ * colors default ON (so switching into those modes shows everything until the user narrows).
37
+ */
38
+ export function selFromFilter(filter) {
39
+ const onOr = (it, dflt) => (it.on === undefined ? dflt : !!it.on);
40
+ return {
41
+ layers: new Set((filter.layers || []).filter((l) => onOr(l, !!l.steel)).map((l) => l.name)),
42
+ thicknesses: new Set((filter.thicknesses || []).filter((t) => onOr(t, true)).map((t) => t.w)),
43
+ colors: new Set((filter.colors || []).filter((c) => onOr(c, true)).map((c) => c.hex)),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Eyedropper: add a picked element's facet value(s) to the selection (additive — never clears).
49
+ * `which` is 'layer' | 'thickness' | 'color' | 'all'. Returns the same (mutated) sel for chaining.
50
+ */
51
+ export function eyedropperAdd(sel, el, which) {
52
+ // Text spans / hairlines (w<=0) have no meaningful stroke width — the popover hides their
53
+ // Thickness row, so "all" must not slip a w:0 into the thickness selection (which would then
54
+ // match every text/hairline element in any thickness mode — the opposite of isolating steel).
55
+ const hasStroke = el.kind !== 'text' && el.w > 0;
56
+ if (which === 'layer' || which === 'all') sel.layers.add(el.layer);
57
+ if ((which === 'thickness' || which === 'all') && hasStroke) sel.thicknesses.add(el.w);
58
+ if (which === 'color' || which === 'all') sel.colors.add(el.color);
59
+ return sel;
60
+ }
61
+
62
+ /** Count how many elements are visible under the current mode + selection. */
63
+ export function countShown(elements, mode, sel) {
64
+ let n = 0;
65
+ for (const el of elements) if (matchesActive(el, mode, sel)) n++;
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * Write the live selection + mode back onto the filter block's `on` flags so it persists on the
71
+ * contract (what Save PUTs, and what the reader reads). Mutates and returns `filter`.
72
+ */
73
+ export function applySelToFilter(filter, sel, mode) {
74
+ filter.mode = mode;
75
+ for (const l of filter.layers || []) l.on = sel.layers.has(l.name);
76
+ for (const t of filter.thicknesses || []) t.on = sel.thicknesses.has(t.w);
77
+ for (const c of filter.colors || []) c.on = sel.colors.has(c.hex);
78
+ return filter;
79
+ }
@@ -0,0 +1,384 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Steel Filter</title>
7
+ <style>
8
+ :root{--bg:#0f172a;--panel:#1e293b;--line:#334155;--text:#e2e8f0;--mut:#94a3b8;--brand:#3b82f6}
9
+ /* Theme EVERY scrollbar — no native white default may leak (panels, popovers, modal lists). */
10
+ *{scrollbar-width:thin;scrollbar-color:#475569 transparent}
11
+ *::-webkit-scrollbar{width:10px;height:10px}
12
+ *::-webkit-scrollbar-track{background:transparent}
13
+ *::-webkit-scrollbar-thumb{background:#475569;border-radius:6px;border:2px solid transparent;background-clip:content-box}
14
+ *::-webkit-scrollbar-thumb:hover{background:#5b6b85;background-clip:content-box}
15
+ *::-webkit-scrollbar-corner{background:transparent}
16
+ *{box-sizing:border-box}
17
+ html,body{height:100%}
18
+ body{margin:0;background:var(--bg);color:var(--text);font:13px system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;display:flex;flex-direction:column;overflow:hidden}
19
+ header{display:flex;align-items:center;gap:14px;padding:8px 14px;background:var(--panel);border-bottom:1px solid var(--line);flex:none}
20
+ header b{font-size:14px} header .sp{flex:1}
21
+ .stat{color:var(--mut);font-size:12px} .stat b{color:var(--text)}
22
+ .stat.dirty b{color:#f59e0b} .stat.saved b{color:#34d399} .stat.err b{color:#f87171}
23
+ button{background:#334155;color:var(--text);border:1px solid #475569;border-radius:6px;padding:5px 10px;cursor:pointer;font:13px system-ui}
24
+ button:hover{background:#475569} button:disabled{opacity:.5;cursor:default}
25
+ button.primary{background:var(--brand);border-color:var(--brand);color:#fff} button.primary:hover{background:#2f6fe0}
26
+ button:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
27
+ #app{display:flex;flex:1;min-height:0}
28
+ #panel{width:300px;flex:0 0 300px;background:var(--panel);border-right:1px solid var(--line);padding:14px;overflow-y:auto;display:flex;flex-direction:column;gap:18px}
29
+ #panel h2{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin:0 0 8px;display:flex;align-items:center;justify-content:space-between}
30
+ .acts{display:inline-flex;gap:4px}
31
+ .acts button{font-size:11px;padding:2px 7px;background:transparent;border-color:var(--line);color:var(--mut)}
32
+ .acts button:hover{background:transparent;border-color:var(--brand);color:var(--text)}
33
+ .facet{border-top:1px solid var(--line);padding-top:14px}
34
+ #modes label,.opts label{display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;cursor:pointer;color:var(--text)}
35
+ .opts label em{margin-left:auto;color:var(--mut);font-style:normal;font-size:11px}
36
+ .opts .sw{width:12px;height:12px;border-radius:3px;flex:0 0 12px;border:1px solid var(--line)}
37
+ .opts label.textlayer{opacity:.55;cursor:default}
38
+ /* Fully custom checkbox/radio — the native unchecked widget is a white/grey square that breaks the dark baseline. */
39
+ input[type=radio],input[type=checkbox]{-webkit-appearance:none;appearance:none;width:14px;height:14px;flex:none;border:1px solid #475569;border-radius:3px;background:transparent;cursor:pointer;margin:0;vertical-align:middle}
40
+ input[type=radio]{border-radius:50%}
41
+ input[type=checkbox]:checked{background:var(--brand);border-color:var(--brand);background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 10 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 4l3 3 5-5' stroke='%23fff' stroke-width='1.6' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center}
42
+ input[type=radio]:checked{background:var(--brand);border-color:var(--brand);box-shadow:inset 0 0 0 3px var(--panel)}
43
+ input:focus-visible{outline:2px solid var(--brand);outline-offset:1px}
44
+ .facet.dim{opacity:.4} .facet.dim .opts,.facet.dim .acts{pointer-events:none}
45
+ .hint{color:var(--mut);font-size:11px;line-height:1.5}
46
+ footer{margin-top:auto;display:flex;align-items:center;gap:6px;flex-wrap:wrap;border-top:1px solid var(--line);padding-top:12px}
47
+ #count{font-size:11px;color:var(--mut);width:100%;margin-bottom:4px}
48
+ #count b{color:var(--text)}
49
+ #stage{flex:1;position:relative;background:var(--bg);min-width:0}
50
+ #canvas{width:100%;height:100%;cursor:grab;touch-action:none;display:block}
51
+ #canvas.dragging{cursor:grabbing}
52
+ #canvas image.bg{opacity:.28}
53
+ #canvas path.el{pointer-events:stroke}
54
+ #canvas text.el{pointer-events:all;cursor:pointer;user-select:none}
55
+ #empty{position:absolute;inset:0;display:none;align-items:center;justify-content:center;padding:40px;text-align:center;color:var(--mut)}
56
+ #empty.show{display:flex}
57
+ #zoombar{position:absolute;left:12px;bottom:12px;display:flex;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 8px;box-shadow:0 4px 14px rgba(0,0,0,.45)}
58
+ /* eyedropper popover */
59
+ #eye{position:fixed;display:none;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:10px;box-shadow:0 8px 24px rgba(0,0,0,.5);min-width:210px}
60
+ #eye.show{display:block}
61
+ #eye .ehead{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
62
+ #eye .erow{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px}
63
+ #eye .erow .k{color:var(--mut);min-width:72px;flex:none}
64
+ #eye .erow .v{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
65
+ #eye .erow .sw{width:12px;height:12px;border-radius:3px;border:1px solid var(--line);flex:none}
66
+ #eye .erow button{font-size:11px;padding:2px 7px}
67
+ #eye .eall{margin-top:8px;width:100%}
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <header>
72
+ <b>Steel filter</b>
73
+ <span id="src" class="stat"></span>
74
+ <span class="sp"></span>
75
+ <span id="saved" class="stat"></span>
76
+ <button id="save" class="primary" disabled>Save</button>
77
+ </header>
78
+ <div id="app">
79
+ <aside id="panel">
80
+ <section id="modes">
81
+ <h2>Match mode</h2>
82
+ <label><input type="radio" name="mode" value="layer" checked /> Layer only</label>
83
+ <label><input type="radio" name="mode" value="thickness" /> Thickness only</label>
84
+ <label><input type="radio" name="mode" value="color" /> Color only</label>
85
+ <label><input type="radio" name="mode" value="thickness+layer" /> Thickness + Layer</label>
86
+ <label><input type="radio" name="mode" value="thickness+color" /> Thickness + Color</label>
87
+ </section>
88
+ <section class="facet" id="facet-layers">
89
+ <h2>Layers <span class="acts"><button data-all="layers">all</button><button data-none="layers">none</button></span></h2>
90
+ <div class="opts"></div>
91
+ </section>
92
+ <section class="facet" id="facet-thicknesses">
93
+ <h2>Thicknesses <span class="acts"><button data-all="thicknesses">all</button><button data-none="thicknesses">none</button></span></h2>
94
+ <div class="opts"></div>
95
+ </section>
96
+ <section class="facet" id="facet-colors">
97
+ <h2>Colors <span class="acts"><button data-all="colors">all</button><button data-none="colors">none</button></span></h2>
98
+ <div class="opts"></div>
99
+ </section>
100
+ <footer>
101
+ <span id="count"></span>
102
+ <button id="fit">Fit page</button>
103
+ <button id="zoomsteel">Zoom to steel</button>
104
+ </footer>
105
+ </aside>
106
+ <main id="stage">
107
+ <svg id="canvas" preserveAspectRatio="xMidYMid meet">
108
+ <image class="bg"></image>
109
+ <g id="overlay" fill="none"></g>
110
+ </svg>
111
+ <div id="empty">
112
+ <div>
113
+ <p>No filter data yet.</p>
114
+ <p class="hint" style="margin-top:8px;max-width:340px">Your terminal AI reads the drawing once (PyMuPDF) and bakes its layers, thicknesses and colours here. Use <b>Re-read &amp; re-bake ▸</b> on the canvas, then reload.</p>
115
+ </div>
116
+ </div>
117
+ <div id="zoombar"><button id="fit2">Fit</button><button id="zoomsteel2">Steel</button></div>
118
+ </main>
119
+ </div>
120
+ <div id="eye">
121
+ <div class="ehead"><span>Picked element</span><button id="eyeClose" style="padding:0 6px">✕</button></div>
122
+ <div id="eyeBody"></div>
123
+ <button class="eall primary" id="eyeAddAll">Add all to filter</button>
124
+ </div>
125
+
126
+ <script type="module">
127
+ import { matchesActive, selFromFilter, eyedropperAdd, countShown, applySelToFilter } from './steel-filter-core.js';
128
+
129
+ const APP_ID = new URLSearchParams(location.search).get('app') || '';
130
+ const SVGNS = 'http://www.w3.org/2000/svg';
131
+ const PALETTE = ['#3b82f6','#22d3ee','#a78bfa','#f59e0b','#34d399','#f472b6','#60a5fa','#facc15'];
132
+
133
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
134
+ const safeColor = c => /^#[0-9a-fA-F]{3,8}$/.test(String(c)) ? c : 'transparent';
135
+
136
+ const svg = document.getElementById('canvas');
137
+ const overlay = document.getElementById('overlay');
138
+ const bgImg = svg.querySelector('image.bg');
139
+ const countEl = document.getElementById('count');
140
+ const savedEl = document.getElementById('saved');
141
+ const saveBtn = document.getElementById('save');
142
+
143
+ let C = null; // the full contract
144
+ let F = null; // C.filter
145
+ let sel = null; // { layers, thicknesses, colors } Sets
146
+ let mode = 'layer';
147
+ let nodes = []; // [{ node, el }]
148
+ let layerColor = {};
149
+ let dirty = false;
150
+
151
+ function setSaved(state){
152
+ savedEl.className = 'stat ' + (state || '');
153
+ savedEl.innerHTML = state === 'dirty' ? 'Unsaved <b>●</b>' : state === 'saved' ? 'Saved <b>✓</b>' : state === 'err' ? 'Save failed <b>✕</b>' : '';
154
+ }
155
+ function markDirty(){ dirty = true; saveBtn.disabled = false; setSaved('dirty'); }
156
+
157
+ async function boot(){
158
+ if(!APP_ID){ showEmpty('No app specified.'); return; }
159
+ let res;
160
+ try { res = await fetch('/api/contract/' + encodeURIComponent(APP_ID)); }
161
+ catch(e){ showEmpty('Could not reach the server.'); return; }
162
+ if(!res.ok){ showEmpty(); return; }
163
+ C = await res.json();
164
+ F = C && C.filter;
165
+ if(!F || !Array.isArray(F.elements) || !F.elements.length){ showEmpty(); return; }
166
+ document.getElementById('empty').classList.remove('show');
167
+ document.getElementById('src').textContent = (C.source && C.source.name) ? ('· ' + C.source.name) : '';
168
+ initFromFilter();
169
+ }
170
+
171
+ function showEmpty(msg){
172
+ const e = document.getElementById('empty');
173
+ if(msg) e.querySelector('p').textContent = msg;
174
+ e.classList.add('show');
175
+ }
176
+
177
+ function initFromFilter(){
178
+ mode = F.mode || 'layer';
179
+ document.querySelector(`input[name=mode][value="${mode}"]`)?.setAttribute('checked','');
180
+ document.querySelectorAll('input[name=mode]').forEach(r => { r.checked = (r.value === mode); });
181
+ // layer colours: steel layers vivid+distinct, others a single muted slate
182
+ let si = 0;
183
+ layerColor = {};
184
+ for(const l of F.layers || []) layerColor[l.name] = l.steel ? PALETTE[si++ % PALETTE.length] : '#94a3b8';
185
+ sel = selFromFilter(F);
186
+ // page + viewBox
187
+ const pg = (F.page && F.page.w > 0 && F.page.h > 0) ? F.page : { w: 1000, h: 1000 }; // guard an empty page → no NaN viewBox
188
+ svg.setAttribute('viewBox', `0 0 ${pg.w} ${pg.h}`);
189
+ if(pg.bg_b64){ bgImg.setAttribute('href', 'data:image/jpeg;base64,' + pg.bg_b64); bgImg.setAttribute('width', pg.w); bgImg.setAttribute('height', pg.h); bgImg.style.display=''; }
190
+ else bgImg.style.display = 'none';
191
+ buildOverlay();
192
+ buildFacet('layers', F.layers || [], 'name', it => it.name, it => it.name);
193
+ buildFacet('thicknesses', F.thicknesses || [], 'w', it => it.w + ' pt', it => it.w);
194
+ buildFacet('colors', F.colors || [], 'hex', it => it.hex, it => it.hex);
195
+ base = { x: 0, y: 0, w: pg.w, h: pg.h }; vb = { ...base };
196
+ apply();
197
+ }
198
+
199
+ function buildOverlay(){
200
+ overlay.replaceChildren();
201
+ nodes = (F.elements || []).map(el => {
202
+ let n;
203
+ if(el.kind === 'text'){
204
+ n = document.createElementNS(SVGNS, 'text');
205
+ const b = el.bbox || [0,0,0,0];
206
+ n.setAttribute('x', b[0]); n.setAttribute('y', b[3] || b[1]);
207
+ n.setAttribute('font-size', Math.max(8, (b[3]-b[1]) || 10));
208
+ n.setAttribute('fill', layerColor[el.layer] || '#cbd5e1');
209
+ n.textContent = el.text || '';
210
+ } else {
211
+ n = document.createElementNS(SVGNS, 'path');
212
+ n.setAttribute('d', el.d || '');
213
+ n.setAttribute('stroke', layerColor[el.layer] || '#3b82f6');
214
+ n.setAttribute('stroke-width', '2.2');
215
+ n.setAttribute('stroke-linecap', 'round');
216
+ n.setAttribute('vector-effect', 'non-scaling-stroke');
217
+ if(el.dashed) n.setAttribute('stroke-dasharray', '7 4');
218
+ }
219
+ n.setAttribute('class', 'el');
220
+ n.__el = el;
221
+ overlay.appendChild(n);
222
+ return { node: n, el };
223
+ });
224
+ }
225
+
226
+ function setOf(facet){ return facet === 'layers' ? sel.layers : facet === 'thicknesses' ? sel.thicknesses : sel.colors; }
227
+
228
+ function buildFacet(facet, items, valueKey, label, selValue){
229
+ const box = document.querySelector(`#facet-${facet} .opts`);
230
+ box.replaceChildren();
231
+ for(const it of items){
232
+ const val = it[valueKey];
233
+ const isText = facet === 'layers' && it.drawable === false;
234
+ const wrap = document.createElement('label');
235
+ if(isText) wrap.className = 'textlayer';
236
+ const cb = document.createElement('input');
237
+ cb.type = 'checkbox';
238
+ cb.checked = setOf(facet).has(selValue(it));
239
+ cb.value = String(val);
240
+ cb.addEventListener('change', () => { const s = setOf(facet); cb.checked ? s.add(selValue(it)) : s.delete(selValue(it)); markDirty(); apply(); });
241
+ wrap.appendChild(cb);
242
+ if(facet === 'layers'){
243
+ const sw = document.createElement('span'); sw.className = 'sw';
244
+ sw.style.background = layerColor[val] || 'transparent';
245
+ wrap.appendChild(sw);
246
+ } else if(facet === 'colors'){
247
+ const sw = document.createElement('span'); sw.className = 'sw';
248
+ sw.style.background = safeColor(val);
249
+ wrap.appendChild(sw);
250
+ }
251
+ wrap.appendChild(document.createTextNode(' ' + label(it) + ' '));
252
+ const c = document.createElement('em'); c.textContent = it.count; wrap.appendChild(c);
253
+ box.appendChild(wrap);
254
+ }
255
+ }
256
+
257
+ function syncFacetChecks(){
258
+ for(const facet of ['layers','thicknesses','colors']){
259
+ const items = F[facet] || [];
260
+ const cbs = document.querySelectorAll(`#facet-${facet} input[type=checkbox]`);
261
+ cbs.forEach((cb, i) => {
262
+ const it = items[i]; if(!it) return;
263
+ const v = facet === 'thicknesses' ? it.w : (facet === 'layers' ? it.name : it.hex);
264
+ cb.checked = setOf(facet).has(v);
265
+ });
266
+ }
267
+ }
268
+
269
+ function apply(){
270
+ for(const { node, el } of nodes){
271
+ const vis = matchesActive(el, mode, sel);
272
+ node.style.display = vis ? '' : 'none';
273
+ }
274
+ countEl.innerHTML = '<b>' + countShown(F.elements, mode, sel) + '</b> of <b>' + nodes.length + '</b> elements shown';
275
+ document.getElementById('facet-layers').classList.toggle('dim', !(mode === 'layer' || mode === 'thickness+layer'));
276
+ document.getElementById('facet-thicknesses').classList.toggle('dim', !mode.startsWith('thickness'));
277
+ document.getElementById('facet-colors').classList.toggle('dim', !(mode === 'color' || mode === 'thickness+color'));
278
+ }
279
+
280
+ // --- modes + all/none ---
281
+ document.querySelectorAll('input[name=mode]').forEach(r => r.addEventListener('change', () => { if(r.checked){ mode = r.value; markDirty(); apply(); } }));
282
+ document.querySelectorAll('[data-all]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.all, true)));
283
+ document.querySelectorAll('[data-none]').forEach(b => b.addEventListener('click', () => setAllFacet(b.dataset.none, false)));
284
+ function setAllFacet(facet, on){
285
+ const s = setOf(facet);
286
+ const items = F[facet] || [];
287
+ for(const it of items){ const v = facet === 'thicknesses' ? it.w : (facet === 'layers' ? it.name : it.hex); on ? s.add(v) : s.delete(v); }
288
+ syncFacetChecks(); markDirty(); apply();
289
+ }
290
+
291
+ // --- eyedropper: pick a line/label -> popover, + Add each / Add all ---
292
+ const eye = document.getElementById('eye');
293
+ let eyeEl = null;
294
+ function openEyedropper(clientX, clientY, el){
295
+ eyeEl = el;
296
+ const body = document.getElementById('eyeBody');
297
+ const rows = [];
298
+ rows.push(eyeRow('Layer', el.layer, 'layer', `<span class="sw" style="background:${safeColor(layerColor[el.layer])}"></span>`));
299
+ if(el.kind !== 'text' && el.w > 0) rows.push(eyeRow('Thickness', el.w + ' pt', 'thickness', ''));
300
+ rows.push(eyeRow('Color', el.color, 'color', `<span class="sw" style="background:${safeColor(el.color)}"></span>`));
301
+ body.innerHTML = rows.join('');
302
+ body.querySelectorAll('button[data-add]').forEach(b => b.onclick = () => { eyedropperAdd(sel, el, b.dataset.add); syncFacetChecks(); markDirty(); apply(); closeEye(); });
303
+ eye.classList.add('show');
304
+ const w = eye.offsetWidth || 220, h = eye.offsetHeight || 160;
305
+ eye.style.left = Math.min(clientX + 8, window.innerWidth - w - 8) + 'px';
306
+ eye.style.top = Math.min(clientY + 8, window.innerHeight - h - 8) + 'px';
307
+ }
308
+ function eyeRow(k, v, which, swatch){
309
+ return `<div class="erow"><span class="k">${k}</span>${swatch}<span class="v" title="${esc(v)}">${esc(v)}</span><button data-add="${which}">+ Add</button></div>`;
310
+ }
311
+ function closeEye(){ eye.classList.remove('show'); eyeEl = null; }
312
+ document.getElementById('eyeClose').onclick = closeEye;
313
+ document.getElementById('eyeAddAll').onclick = () => { if(eyeEl){ eyedropperAdd(sel, eyeEl, 'all'); syncFacetChecks(); markDirty(); apply(); } closeEye(); };
314
+
315
+ // --- pan/zoom via viewBox; a click (no drag) on an element opens the eyedropper ---
316
+ let base = { x:0, y:0, w:1000, h:1000 }, vb = { ...base }, drag = null;
317
+ function setVB(){ svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`); }
318
+ svg.addEventListener('wheel', e => {
319
+ e.preventDefault();
320
+ const r = svg.getBoundingClientRect();
321
+ const mx = vb.x + ((e.clientX - r.left) / r.width) * vb.w;
322
+ const my = vb.y + ((e.clientY - r.top) / r.height) * vb.h;
323
+ const k = e.deltaY < 0 ? 0.85 : 1 / 0.85;
324
+ vb.w *= k; vb.h *= k;
325
+ vb.x = mx - ((e.clientX - r.left) / r.width) * vb.w;
326
+ vb.y = my - ((e.clientY - r.top) / r.height) * vb.h;
327
+ setVB();
328
+ }, { passive: false });
329
+ svg.addEventListener('pointerdown', e => {
330
+ drag = { x: e.clientX, y: e.clientY, vx: vb.x, vy: vb.y, moved: false, target: e.target };
331
+ svg.setPointerCapture(e.pointerId); svg.classList.add('dragging');
332
+ });
333
+ svg.addEventListener('pointermove', e => {
334
+ if(!drag) return;
335
+ if(Math.abs(e.clientX - drag.x) + Math.abs(e.clientY - drag.y) > 3) drag.moved = true;
336
+ const r = svg.getBoundingClientRect();
337
+ vb.x = drag.vx - ((e.clientX - drag.x) / r.width) * vb.w;
338
+ vb.y = drag.vy - ((e.clientY - drag.y) / r.height) * vb.h;
339
+ setVB();
340
+ });
341
+ svg.addEventListener('pointerup', e => {
342
+ svg.classList.remove('dragging');
343
+ if(drag && !drag.moved && drag.target && drag.target.__el) openEyedropper(e.clientX, e.clientY, drag.target.__el);
344
+ else if(drag && !drag.moved) closeEye();
345
+ drag = null;
346
+ });
347
+ function fit(){ vb = { ...base }; setVB(); }
348
+ function zoomSteel(){ const b = F && F.steelBbox; if(!b){ fit(); return; } const pad = 40; vb = { x: b[0]-pad, y: b[1]-pad, w: (b[2]-b[0])+2*pad, h: (b[3]-b[1])+2*pad }; setVB(); }
349
+ document.getElementById('fit').onclick = fit;
350
+ document.getElementById('fit2').onclick = fit;
351
+ document.getElementById('zoomsteel').onclick = zoomSteel;
352
+ document.getElementById('zoomsteel2').onclick = zoomSteel;
353
+
354
+ // --- save: write the selection + mode onto C.filter and PUT the contract ---
355
+ saveBtn.onclick = save;
356
+ async function save(){
357
+ if(!C || !F) return;
358
+ applySelToFilter(F, sel, mode);
359
+ C.filter = F;
360
+ saveBtn.disabled = true; setSaved('');
361
+ try {
362
+ const res = await fetch('/api/contract/' + encodeURIComponent(APP_ID), { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(C) });
363
+ if(!res.ok) throw new Error(await res.text());
364
+ dirty = false; setSaved('saved');
365
+ } catch(e){ console.error('filter save failed', e); setSaved('err'); saveBtn.disabled = false; }
366
+ }
367
+ window.addEventListener('beforeunload', e => { if(dirty){ e.preventDefault(); e.returnValue = ''; } });
368
+
369
+ // Test seam (mirrors steel-3d-view's debug()/probe()) — lets a headless E2E drive the eyedropper
370
+ // and inspect state deterministically without fragile sub-pixel clicks on thin SVG strokes.
371
+ window.__filterTest = {
372
+ ready: () => !!F,
373
+ mode: () => mode,
374
+ sel: () => ({ layers: [...sel.layers], thicknesses: [...sel.thicknesses], colors: [...sel.colors] }),
375
+ shown: () => countShown(F.elements, mode, sel),
376
+ total: () => nodes.length,
377
+ pick: (i) => { const el = nodes[i].el; openEyedropper(Math.round(window.innerWidth / 2), 200, el); return el; },
378
+ addAll: () => document.getElementById('eyeAddAll').click(),
379
+ };
380
+
381
+ boot();
382
+ </script>
383
+ </body>
384
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floless/app",
3
- "version": "0.40.0",
3
+ "version": "0.42.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": {