Aluode commited on
Commit
e38621f
·
verified ·
1 Parent(s): 8ca5f9e

Upload 18 files

Browse files
Files changed (7) hide show
  1. README.md +56 -5
  2. requirements.txt +1 -1
  3. studio/studio.html +269 -9
  4. studio/studio_core.js +42 -0
  5. studio_core.js +42 -0
  6. studio_ui.html +228 -9
  7. tests_edit.js +141 -0
README.md CHANGED
@@ -8,8 +8,64 @@ pinned: false
8
  license: mit
9
  ---
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  # Slapstack Studio (Bet 8 Playroom)
12
 
 
 
 
 
13
  ![pic2](pic2.png)
14
 
15
  An interactive, layered visual sandbox running multi-object Belief Propagation (BP) over Gabor packet representations. The system supports full 2D depth occlusion matting, spatial grab/drag/scale/rotate mechanics, semantic prompt interventions, and server-side neural layer distillation via Score Distillation Sampling (SDS).
@@ -90,8 +146,3 @@ Open your browser and navigate to http://127.0.0.1:7860. The unified workspace w
90
  ### Prompt Interventions
91
 
92
  Enter clean, natural commands in the prompt box (e.g., *"move the boat left, send the star behind the tractor"*) to execute hard algebraic transformations. The graph instantly recalculates the new joint posterior distribution around your action.
93
-
94
- Future direction:
95
-
96
- Deleting splats to seperate background fuzz from the images would fit here great. I think there has been a lot of work on 3d splats on this. So I guess the work is already done?
97
-
 
8
  license: mit
9
  ---
10
 
11
+ # Slapstack Studio — Bet 9 (splat eraser · backdrop · band gains)
12
+
13
+ Bet 8 plus the editing layer: this is the "adaptive density control by hand"
14
+ step — delete splats, clean images, control the look. **Client-only delta**:
15
+ `app.py`, `oracle.py`, and the verified BP core are byte-identical to Bet 8, so
16
+ deploying is replacing `studio_ui.html`, `studio_core.js`, and the built
17
+ `studio/studio.html` on the existing Space (or pushing this repo as-is).
18
+
19
+ ## New in Bet 9 — all in the EDIT & LOOK card
20
+
21
+ **Erase tool.** Switch tool from *pose* to *erase*; three brush sizes (S/M/L);
22
+ drag over the canvas and atoms under the brush are deleted. Works in soup and
23
+ bound states. Mechanically an erase filters every per-atom parallel array
24
+ (obs, gt, evidence, assignment, marginals) by the brush mask — atom order
25
+ carries no meaning anywhere in the system (additive render, per-atom
26
+ marginals), so **Undo** is simply re-appending the removed records, and the
27
+ field restores bit-exactly (verified). Undo history survives until the next
28
+ Bind/Scramble (BP re-inference makes the records stale, so the stack clears —
29
+ honestly, rather than pretending).
30
+
31
+ **Burn into template.** By default erases are scene-level: Scramble rebuilds
32
+ from the layer templates and the splats come back. Tick *burn into template*
33
+ and each erased atom is also removed from its source layer's template (tracked
34
+ by reference through the scene build), so the cleanup is permanent — the
35
+ hand-operated version of 3DGS adaptive density pruning. Undo restores burned
36
+ atoms too.
37
+
38
+ **Backdrop.** A color picker and an image upload. The backdrop is pure
39
+ presentation state, touched by nothing in the inference loop — which is
40
+ exactly why it *persists* across Bind/Scramble/drag instead of reverting to
41
+ gray. The gray you see by default is not a background at all: it is
42
+ sigmoid(0), the neutral of the additive field where no atom paints. The
43
+ backdrop composites under the field using the rendered-deviation alpha
44
+ (`alphaFromPre`), so cancelling atom pairs stay transparent over your image.
45
+
46
+ **Gains.** The dials from the original splatstack direct-fit editor, back:
47
+ coarse (<4 c/img), mid (4–10), fine (>10) band gains plus a global opacity
48
+ gain. Render-only, like before — occlusion evidence and BP are untouched.
49
+ Exactness for free: the Gabor render is linear in atom color, so
50
+ Σ_b gain_b·render(band_b) ≡ render(gain-scaled atoms), verified to 2.4e-7.
51
+ Band gains apply in field mode; opacity applies in both modes.
52
+
53
+ **Ledger for this drop.** `tests_edit.js`: 13/13 GO (band edges, gain
54
+ linearity, backdrop identities incl. the image path, brush geometry + veto,
55
+ erase→undo bit-exact round trip, burn/unburn template membership).
56
+ `tests_studio.js` regression: unchanged from shipped Bet 8 (11 GO and the one
57
+ known inherited coverage-overlap FAIL, untouched by this delta). All four
58
+ script blocks of the built `studio/studio.html` (106 KB) syntax-verified.
59
+ Untouched and still honestly unverified: the GPU SDS path.
60
+
61
+ ---
62
+
63
  # Slapstack Studio (Bet 8 Playroom)
64
 
65
+ Live at huggingface: https://huggingface.co/spaces/Aluode/SlapStackStudio
66
+
67
+ Video: [https://studio.youtube.com/video/a-emh7csWQk/edit](https://youtu.be/a-emh7csWQk)
68
+
69
  ![pic2](pic2.png)
70
 
71
  An interactive, layered visual sandbox running multi-object Belief Propagation (BP) over Gabor packet representations. The system supports full 2D depth occlusion matting, spatial grab/drag/scale/rotate mechanics, semantic prompt interventions, and server-side neural layer distillation via Score Distillation Sampling (SDS).
 
146
  ### Prompt Interventions
147
 
148
  Enter clean, natural commands in the prompt box (e.g., *"move the boat left, send the star behind the tractor"*) to execute hard algebraic transformations. The graph instantly recalculates the new joint posterior distribution around your action.
 
 
 
 
 
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- gradio<6.0
2
  diffusers
3
  transformers
4
  torch
 
1
+ gradio
2
  diffusers
3
  transformers
4
  torch
studio/studio.html CHANGED
@@ -72,6 +72,11 @@ button.primary{border-color:var(--phosphor);color:var(--phosphor)}
72
  label.tog{display:flex;gap:8px;align-items:center;cursor:pointer;padding:2px 0;font-size:12px}
73
  label.tog input{accent-color:var(--phosphor)}
74
  input[type=range]{flex:1;accent-color:var(--phosphor)}
 
 
 
 
 
75
  input[type=text],textarea{font-family:var(--mono);font-size:12px;background:var(--well);
76
  color:var(--ink);border:1px solid var(--panel-edge);border-radius:4px;padding:6px 8px;width:100%}
77
  input[type=text]:focus-visible,textarea:focus-visible{outline:2px solid var(--phosphor);border-color:transparent}
@@ -99,7 +104,7 @@ input[type=file]{font-size:10.5px;color:var(--muted);max-width:170px}
99
  <div id="stage">
100
  <canvas id="scene-canvas"></canvas>
101
  <canvas id="overlay-canvas"></canvas>
102
- <div class="hint">drag = pose clamp &middot; wheel rotate &middot; shift+wheel scale &middot; shift+click = semantic hint for selected layer</div>
103
  </div>
104
  </div>
105
 
@@ -129,6 +134,36 @@ input[type=file]{font-size:10.5px;color:var(--muted);max-width:170px}
129
  </div>
130
  </div>
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  <div class="card">
133
  <h2>LAYERS <span class="dim" style="letter-spacing:0">(front is highest)</span></h2>
134
  <div id="layer-list"></div>
@@ -177,7 +212,7 @@ input[type=file]{font-size:10.5px;color:var(--muted);max-width:170px}
177
  <div class="card" id="ledger">
178
  <details>
179
  <summary>HONEST LEDGER</summary>
180
- <p><b>Verified in this build:</b> the BP core matches the SlapstackBet6 Python (transform 2e-16, render MSE 8.6e-8, identical binding); drag = pose clamp + re-settle; identity persists through occlusion; depth occlusion removes evidence honestly (marginals revert toward the prior, coverage releases when moved away); the semantic likelihood factor resolves the twins case (two identical stars: 0.05 acc on geometry alone, 0.99 with location priors) and a mildly wrong prior is overridden by evidence; image&rarr;layer fitting runs end to end on CPU.</p>
181
  <p><span class="no">Not verified:</span> text&rarr;layer (SDS) and cross-attention matte are line-for-line adaptations of the verified Bet-5 GPU loop but have NOT been executed on a GPU — first run is a smoke test; SD attention maps flicker across seeds, which is why they enter BP as a prior with a weight, never as truth. Real-photo scene&rarr;atoms remains open. Painter compositing and the alpha matte are presentation choices, not the additive field model.</p>
182
  </details>
183
  </div>
@@ -702,6 +737,47 @@ function trimAtoms(atoms, sigMax = 0.35) {
702
  return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
703
  }
704
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
705
  /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
706
  function composePose(g2, g1) {
707
  const s2 = Math.exp(g2[3]);
@@ -754,6 +830,19 @@ const S = {
754
  };
755
  let nextDepth = 1, nextId = 1;
756
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757
  function addLayer(name, template, autoPlace) {
758
  const [color, rgb] = PALETTE[(S.layers.length) % PALETTE.length];
759
  const L = { id: nextId++, name, color, rgb, template, placed: false,
@@ -782,17 +871,19 @@ function rand(){ rngState=(rngState*1103515245+12345)&0x7fffffff; return rngStat
782
  function rebuildScene() {
783
  S.placed = S.layers.map((L,i)=>L.placed?i:-1).filter(i=>i>=0);
784
  const noiseAmt = 0.006 * Math.pow(3, +document.getElementById("noise").value / 3);
785
- S.obs = []; S.gt = [];
786
  S.placed.forEach((li,k)=>{
787
- for (const a of transformAtoms(S.layers[li].template, S.layers[li].pose)) {
788
- S.obs.push(a); S.gt.push(k);
 
 
789
  }
790
  });
791
  for (let i=0;i<12;i++){
792
  const su=0.04+0.08*rand();
793
  S.obs.push([-0.95+1.9*rand(),-0.95+1.9*rand(),-Math.PI/2+Math.PI*rand(),
794
  su,su*(0.4+0.5*rand()),4+10*rand(),TAU*rand(),rand(),rand(),rand()]);
795
- S.gt.push(-1);
796
  }
797
  for (const a of S.obs) for(let q=0;q<10;q++){
798
  const g=Math.sqrt(-2*Math.log(rand()+1e-9))*Math.cos(TAU*rand());
@@ -804,6 +895,7 @@ function rebuildScene() {
804
  S.covFrac=S.placed.map(()=>0);
805
  S.coastT=S.placed.map(()=>0);
806
  S.ownField=null;
 
807
  setPhase("soup");
808
  rebuildGroupBuffers();
809
  renderScene(); drawOverlay(); renderLayerList(); renderLibrary(); updatePanels();
@@ -861,12 +953,28 @@ function rebuildGroupBuffers(){
861
  renderPre([S.obs[i]],RES,S.groupPre[g]);
862
  }
863
  for(let g=0;g<=K;g++) S.groupAlpha.push(alphaFromPre(S.groupPre[g],RES));
 
864
  }
865
  function rebuildOneGroup(k){
866
  S.groupPre[k]=new Float32Array(3*RES*RES);
867
  if(!S.layers[S.placed[k]].hidden)
868
  renderPre(groupAtomIdx(k).map(i=>S.obs[i]),RES,S.groupPre[k]);
869
  S.groupAlpha[k]=alphaFromPre(S.groupPre[k],RES);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  }
871
  const sceneCtx=sceneC.getContext("2d");
872
  const compositePre=new Float32Array(3*RES*RES);
@@ -874,16 +982,40 @@ const sceneImg=new ImageData(RES,RES);
874
  function compMode(){ return document.querySelector('input[name=comp]:checked').value; }
875
  function renderScene(){
876
  const K=S.placed.length;
 
 
877
  if(compMode()==="field"){
878
  compositePre.fill(0);
879
- for(const buf of S.groupPre)
880
- for(let i=0;i<compositePre.length;i++) compositePre[i]+=buf[i];
 
 
 
 
 
 
 
 
 
881
  sigmoidField(compositePre,RES,sceneImg.data);
 
 
 
 
882
  } else {
883
  // painter: clutter at the back, then placed layers by depth
884
  const order=[K, ...S.placed.map((li,k)=>k)
885
  .sort((a,b)=>S.layers[S.placed[a]].depth-S.layers[S.placed[b]].depth)];
886
  compositePainter(order,S.groupPre,S.groupAlpha,RES,sceneImg.data);
 
 
 
 
 
 
 
 
 
887
  }
888
  sceneCtx.putImageData(sceneImg,0,0);
889
  }
@@ -993,6 +1125,14 @@ function drawOverlay(){
993
  }
994
  }
995
  }
 
 
 
 
 
 
 
 
996
  }
997
 
998
  /* ------------- semantic factor from hints ------------- */
@@ -1049,6 +1189,7 @@ function runBind(opts={}){
1049
  function bind(animate=true){
1050
  if(S.phase==="binding"||!S.placed.length) return;
1051
  S.assign=null;
 
1052
  S.evid=S.obs.map(()=>true); // full re-bind: everything is evidence again
1053
  for(const li of S.placed) if(S.layers[li].hidden){
1054
  // user-hidden layers stay evidence-free
@@ -1132,6 +1273,70 @@ function applyGesture(k,g){
1132
  renderScene();drawOverlay();updatePanels();
1133
  }
1134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1135
  /* ------------- pointer interaction ------------- */
1136
  let drag=null;
1137
  overC.addEventListener("pointerdown",e=>{
@@ -1148,6 +1353,13 @@ overC.addEventListener("pointerdown",e=>{
1148
  }
1149
  return;
1150
  }
 
 
 
 
 
 
 
1151
  if(S.phase!=="bound") return;
1152
  let best=-1,bd=0.12*0.12;
1153
  for(let i=0;i<S.obs.length;i++){
@@ -1167,19 +1379,25 @@ overC.addEventListener("pointerdown",e=>{
1167
  overC.setPointerCapture(e.pointerId);
1168
  });
1169
  overC.addEventListener("pointermove",e=>{
1170
- if(!drag) return;
1171
  const rect=overC.getBoundingClientRect();
1172
  const wx=p2w((e.clientX-rect.left)*overC.width/rect.width);
1173
  const wy=p2w((e.clientY-rect.top)*overC.width/rect.width);
 
 
 
1174
  const dx=wx-drag.lx,dy=wy-drag.ly;
1175
  if(dx||dy){applyGesture(drag.k,gestureTranslate(dx,dy));drag.lx=wx;drag.ly=wy;drag.moved=true;}
1176
  });
1177
  overC.addEventListener("pointerup",e=>{
1178
  if(!drag) return;
 
1179
  overC.classList.remove("dragging");
1180
  if(drag.moved) resettle(drag.k,S.mu[drag.k]);
1181
  drag=null;
1182
  });
 
 
 
1183
  overC.addEventListener("wheel",e=>{
1184
  if(S.phase!=="bound") return;
1185
  const rect=overC.getBoundingClientRect();
@@ -1480,6 +1698,48 @@ document.getElementById("auto-occ").addEventListener("change",()=>{
1480
  if(S.phase==="bound"){occlusionRefresh(false);renderScene();}
1481
  });
1482
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1483
  /* ------------- boot ------------- */
1484
  document.getElementById("btn-scramble").addEventListener("click",scramble);
1485
  document.getElementById("btn-bind").addEventListener("click",()=>bind(true));
 
72
  label.tog{display:flex;gap:8px;align-items:center;cursor:pointer;padding:2px 0;font-size:12px}
73
  label.tog input{accent-color:var(--phosphor)}
74
  input[type=range]{flex:1;accent-color:var(--phosphor)}
75
+ .gain-row{display:grid;grid-template-columns:54px 1fr 40px;gap:6px;align-items:center;margin-top:3px}
76
+ .gain-row input[type=range]{width:100%;margin:0}
77
+ .gain-row .val{text-align:right}
78
+ input[type=color]{width:36px;height:22px;padding:1px;border:1px solid var(--panel-edge);background:var(--well);border-radius:4px;cursor:pointer}
79
+ #stage.erasing #overlay-canvas{cursor:crosshair}
80
  input[type=text],textarea{font-family:var(--mono);font-size:12px;background:var(--well);
81
  color:var(--ink);border:1px solid var(--panel-edge);border-radius:4px;padding:6px 8px;width:100%}
82
  input[type=text]:focus-visible,textarea:focus-visible{outline:2px solid var(--phosphor);border-color:transparent}
 
104
  <div id="stage">
105
  <canvas id="scene-canvas"></canvas>
106
  <canvas id="overlay-canvas"></canvas>
107
+ <div class="hint">drag = pose clamp &middot; wheel rotate &middot; shift+wheel scale &middot; shift+click = hint &middot; erase tool: brush deletes atoms</div>
108
  </div>
109
  </div>
110
 
 
134
  </div>
135
  </div>
136
 
137
+ <div class="card">
138
+ <h2>EDIT &amp; LOOK</h2>
139
+ <div class="row">
140
+ <span class="dim">tool</span>
141
+ <label class="tog"><input type="radio" name="tool" value="pose" checked> pose</label>
142
+ <label class="tog"><input type="radio" name="tool" value="erase"> erase</label>
143
+ <span class="spacer"></span>
144
+ <span class="dim">brush</span>
145
+ <label class="tog"><input type="radio" name="brush" value="0.05"> S</label>
146
+ <label class="tog"><input type="radio" name="brush" value="0.12" checked> M</label>
147
+ <label class="tog"><input type="radio" name="brush" value="0.25"> L</label>
148
+ </div>
149
+ <div class="row">
150
+ <button id="btn-undo-erase" disabled>Undo erase</button>
151
+ <button id="btn-restore-erase" disabled>Restore all</button>
152
+ <label class="tog"><input type="checkbox" id="erase-burn"> burn into template</label>
153
+ </div>
154
+ <div class="row">
155
+ <span class="dim">backdrop</span>
156
+ <input type="color" id="bg-color" value="#808080" title="backdrop color">
157
+ <input type="file" id="bg-file" accept="image/*" style="max-width:46%">
158
+ <button id="btn-bg-clear">clear img</button>
159
+ </div>
160
+ <div class="gain-row"><span class="dim">coarse</span><input type="range" id="g-coarse" min="0" max="200" value="100"><span class="val readout" id="g-coarse-v">1.00</span></div>
161
+ <div class="gain-row"><span class="dim">mid</span><input type="range" id="g-mid" min="0" max="200" value="100"><span class="val readout" id="g-mid-v">1.00</span></div>
162
+ <div class="gain-row"><span class="dim">fine</span><input type="range" id="g-fine" min="0" max="200" value="100"><span class="val readout" id="g-fine-v">1.00</span></div>
163
+ <div class="gain-row"><span class="dim">opacity</span><input type="range" id="g-op" min="0" max="200" value="100"><span class="val readout" id="g-op-v">1.00</span></div>
164
+ <div class="dim" style="font-size:10.5px;margin-top:6px">Erase deletes atoms under the brush (render only, until <b>burn</b> writes it into the layer template; Scramble/Bind rebuild the scene from templates). Undo works until the next Bind. Backdrop is presentation and survives everything. Band gains (coarse&lt;4, mid 4&ndash;10, fine&gt;10&nbsp;c/img) scale atom colors in field mode; opacity blends the whole field over the backdrop.</div>
165
+ </div>
166
+
167
  <div class="card">
168
  <h2>LAYERS <span class="dim" style="letter-spacing:0">(front is highest)</span></h2>
169
  <div id="layer-list"></div>
 
212
  <div class="card" id="ledger">
213
  <details>
214
  <summary>HONEST LEDGER</summary>
215
+ <p><b>Verified in this build:</b> the BP core matches the SlapstackBet6 Python (transform 2e-16, render MSE 8.6e-8, identical binding); drag = pose clamp + re-settle; identity persists through occlusion; depth occlusion removes evidence honestly (marginals revert toward the prior, coverage releases when moved away); the semantic likelihood factor resolves the twins case (two identical stars: 0.05 acc on geometry alone, 0.99 with location priors) and a mildly wrong prior is overridden by evidence; image&rarr;layer fitting runs end to end on CPU. <b>Bet 9:</b> the eraser filters per-atom state (order-free, so undo is re-append), burn edits the template so erases survive rebuilds; the backdrop is pure presentation and persists across Bind/Scramble; band gains exploit render linearity (gain&middot;color &equiv; gain&middot;contribution), verified in tests_edit.js.</p>
216
  <p><span class="no">Not verified:</span> text&rarr;layer (SDS) and cross-attention matte are line-for-line adaptations of the verified Bet-5 GPU loop but have NOT been executed on a GPU — first run is a smoke test; SD attention maps flicker across seeds, which is why they enter BP as a prior with a weight, never as truth. Real-photo scene&rarr;atoms remains open. Painter compositing and the alpha matte are presentation choices, not the additive field model.</p>
217
  </details>
218
  </div>
 
737
  return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
738
  }
739
 
740
+ /* ---- Bet 9 additions: eraser + backdrop + band gains (pure, testable) ---- */
741
+
742
+ /* Frequency band of an atom. f is cycles/unit; the frame spans 2 units, so
743
+ cycles-per-image = 2f. Bands follow the original splatstack dials:
744
+ coarse < 4 c/img, mid 4–10, fine > 10. Returns 0|1|2. */
745
+ const BAND_EDGES = [2.0, 5.0];
746
+ function bandOf(f) { return f < BAND_EDGES[0] ? 0 : f < BAND_EDGES[1] ? 1 : 2; }
747
+
748
+ /* Erase mask: true for atoms whose center falls inside the brush circle.
749
+ skip(i) can veto (e.g. atoms of hidden layers stay untouched). */
750
+ function eraseMask(obs, wx, wy, r, skip) {
751
+ const r2 = r * r;
752
+ return obs.map((a, i) => {
753
+ if (skip && skip(i)) return false;
754
+ const dx = a[0] - wx, dy = a[1] - wy;
755
+ return dx * dx + dy * dy < r2;
756
+ });
757
+ }
758
+
759
+ /* Composite an already-sigmoided field over a backdrop.
760
+ img: Uint8ClampedArray RGBA from sigmoidField/compositePainter (mutated).
761
+ alpha: Float32Array(H*H) from alphaFromPre of the (band-gained) pre field.
762
+ opacity: global gain on alpha, clamped to [0,1] per pixel.
763
+ bg: either [r,g,b] flat color or a Uint8ClampedArray(4*H*H) RGBA image.
764
+ Identity check: with alpha==1 everywhere or bg==the field itself the
765
+ output is unchanged; with opacity such that a==1 the field wins. */
766
+ function blendOverBackdrop(img, alpha, opacity, bg, H) {
767
+ const n = H * H;
768
+ const flat = !(bg && bg.length === 4 * n);
769
+ for (let i = 0; i < n; i++) {
770
+ const a = Math.min(1, alpha[i] * opacity);
771
+ const br = flat ? bg[0] : bg[4 * i],
772
+ bgr = flat ? bg[1] : bg[4 * i + 1],
773
+ bb = flat ? bg[2] : bg[4 * i + 2];
774
+ img[4 * i] = br + a * (img[4 * i] - br);
775
+ img[4 * i + 1] = bgr + a * (img[4 * i + 1] - bgr);
776
+ img[4 * i + 2] = bb + a * (img[4 * i + 2] - bb);
777
+ }
778
+ return img;
779
+ }
780
+
781
  /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
782
  function composePose(g2, g1) {
783
  const s2 = Math.exp(g2[3]);
 
830
  };
831
  let nextDepth = 1, nextId = 1;
832
 
833
+ /* ---- Bet 9: eraser / backdrop / band-gain state ---- */
834
+ const GAINS = { band: [1, 1, 1], opacity: 1 };
835
+ const backdrop = { custom: false, rgb: [128, 128, 128], img: null }; // img: RGBA RES*RES
836
+ let bandPre = null, bandDirty = true;
837
+ const fieldAlpha = new Float32Array(RES * RES);
838
+ let eraseStack = [], lastCursor = null;
839
+ function toolMode(){ return document.querySelector('input[name=tool]:checked').value; }
840
+ function brushR(){ return +document.querySelector('input[name=brush]:checked').value; }
841
+ function updateEraseButtons(){
842
+ document.getElementById("btn-undo-erase").disabled = !eraseStack.length;
843
+ document.getElementById("btn-restore-erase").disabled = !eraseStack.length;
844
+ }
845
+
846
  function addLayer(name, template, autoPlace) {
847
  const [color, rgb] = PALETTE[(S.layers.length) % PALETTE.length];
848
  const L = { id: nextId++, name, color, rgb, template, placed: false,
 
871
  function rebuildScene() {
872
  S.placed = S.layers.map((L,i)=>L.placed?i:-1).filter(i=>i>=0);
873
  const noiseAmt = 0.006 * Math.pow(3, +document.getElementById("noise").value / 3);
874
+ S.obs = []; S.gt = []; S.src = [];
875
  S.placed.forEach((li,k)=>{
876
+ const tmpl = S.layers[li].template;
877
+ const tr = transformAtoms(tmpl, S.layers[li].pose);
878
+ for (let j = 0; j < tr.length; j++) {
879
+ S.obs.push(tr[j]); S.gt.push(k); S.src.push({ li, ref: tmpl[j] });
880
  }
881
  });
882
  for (let i=0;i<12;i++){
883
  const su=0.04+0.08*rand();
884
  S.obs.push([-0.95+1.9*rand(),-0.95+1.9*rand(),-Math.PI/2+Math.PI*rand(),
885
  su,su*(0.4+0.5*rand()),4+10*rand(),TAU*rand(),rand(),rand(),rand()]);
886
+ S.gt.push(-1); S.src.push(null);
887
  }
888
  for (const a of S.obs) for(let q=0;q<10;q++){
889
  const g=Math.sqrt(-2*Math.log(rand()+1e-9))*Math.cos(TAU*rand());
 
895
  S.covFrac=S.placed.map(()=>0);
896
  S.coastT=S.placed.map(()=>0);
897
  S.ownField=null;
898
+ eraseStack=[]; updateEraseButtons();
899
  setPhase("soup");
900
  rebuildGroupBuffers();
901
  renderScene(); drawOverlay(); renderLayerList(); renderLibrary(); updatePanels();
 
953
  renderPre([S.obs[i]],RES,S.groupPre[g]);
954
  }
955
  for(let g=0;g<=K;g++) S.groupAlpha.push(alphaFromPre(S.groupPre[g],RES));
956
+ bandDirty=true;
957
  }
958
  function rebuildOneGroup(k){
959
  S.groupPre[k]=new Float32Array(3*RES*RES);
960
  if(!S.layers[S.placed[k]].hidden)
961
  renderPre(groupAtomIdx(k).map(i=>S.obs[i]),RES,S.groupPre[k]);
962
  S.groupAlpha[k]=alphaFromPre(S.groupPre[k],RES);
963
+ bandDirty=true;
964
+ }
965
+ /* frequency-band buffers, built lazily only when a band gain != 1.
966
+ Same visibility rule as the group buffers; render is linear in atom color,
967
+ so gain*color == gain*contribution and Σ_b gain_b·bandPre_b is exact. */
968
+ function rebuildBandBuffers(){
969
+ const K=S.placed.length;
970
+ bandPre=[new Float32Array(3*RES*RES),new Float32Array(3*RES*RES),new Float32Array(3*RES*RES)];
971
+ const groupOf=i=>{ const g=S.marg?believedGroup(i):S.gt[i]; return g>=0?g:K; };
972
+ for(let i=0;i<S.obs.length;i++){
973
+ const g=groupOf(i);
974
+ if(g<K && S.layers[S.placed[g]].hidden) continue;
975
+ renderPre([S.obs[i]],RES,bandPre[bandOf(S.obs[i][5])]);
976
+ }
977
+ bandDirty=false;
978
  }
979
  const sceneCtx=sceneC.getContext("2d");
980
  const compositePre=new Float32Array(3*RES*RES);
 
982
  function compMode(){ return document.querySelector('input[name=comp]:checked').value; }
983
  function renderScene(){
984
  const K=S.placed.length;
985
+ const gb=GAINS.band, gainsOn=gb[0]!==1||gb[1]!==1||gb[2]!==1;
986
+ const bgOn=backdrop.custom||backdrop.img||GAINS.opacity!==1;
987
  if(compMode()==="field"){
988
  compositePre.fill(0);
989
+ if(gainsOn){
990
+ if(bandDirty) rebuildBandBuffers();
991
+ for(let b=0;b<3;b++){
992
+ const g=gb[b]; if(!g) continue;
993
+ const buf=bandPre[b];
994
+ for(let i=0;i<compositePre.length;i++) compositePre[i]+=g*buf[i];
995
+ }
996
+ } else {
997
+ for(const buf of S.groupPre)
998
+ for(let i=0;i<compositePre.length;i++) compositePre[i]+=buf[i];
999
+ }
1000
  sigmoidField(compositePre,RES,sceneImg.data);
1001
+ if(bgOn){
1002
+ alphaFromPre(compositePre,RES,fieldAlpha);
1003
+ blendOverBackdrop(sceneImg.data,fieldAlpha,GAINS.opacity,backdrop.img||backdrop.rgb,RES);
1004
+ }
1005
  } else {
1006
  // painter: clutter at the back, then placed layers by depth
1007
  const order=[K, ...S.placed.map((li,k)=>k)
1008
  .sort((a,b)=>S.layers[S.placed[a]].depth-S.layers[S.placed[b]].depth)];
1009
  compositePainter(order,S.groupPre,S.groupAlpha,RES,sceneImg.data);
1010
+ if(bgOn){
1011
+ // union alpha of everything rendered (band gains are field-mode only)
1012
+ for(let i=0;i<RES*RES;i++){
1013
+ let keep=1;
1014
+ for(let q=0;q<=K;q++) keep*=1-S.groupAlpha[q][i];
1015
+ fieldAlpha[i]=1-keep;
1016
+ }
1017
+ blendOverBackdrop(sceneImg.data,fieldAlpha,GAINS.opacity,backdrop.img||backdrop.rgb,RES);
1018
+ }
1019
  }
1020
  sceneCtx.putImageData(sceneImg,0,0);
1021
  }
 
1125
  }
1126
  }
1127
  }
1128
+
1129
+ if(toolMode()==="erase"&&lastCursor){ // eraser brush cursor
1130
+ ctx.strokeStyle="rgba(255,120,110,0.9)";
1131
+ ctx.lineWidth=1.4*px1;ctx.setLineDash([4*px1,3*px1]);
1132
+ ctx.beginPath();
1133
+ ctx.arc(w2p(lastCursor[0]),w2p(lastCursor[1]),brushR()*overC.width/2,0,TAU);
1134
+ ctx.stroke();ctx.setLineDash([]);
1135
+ }
1136
  }
1137
 
1138
  /* ------------- semantic factor from hints ------------- */
 
1189
  function bind(animate=true){
1190
  if(S.phase==="binding"||!S.placed.length) return;
1191
  S.assign=null;
1192
+ eraseStack=[]; updateEraseButtons(); // erase history is stale once BP re-runs
1193
  S.evid=S.obs.map(()=>true); // full re-bind: everything is evidence again
1194
  for(const li of S.placed) if(S.layers[li].hidden){
1195
  // user-hidden layers stay evidence-free
 
1273
  renderScene();drawOverlay();updatePanels();
1274
  }
1275
 
1276
+ /* ------------- eraser (Bet 9) -------------
1277
+ Deleting an atom = filtering every per-atom parallel array by the brush
1278
+ mask. Order of atoms carries no meaning anywhere (additive render, per-atom
1279
+ marginals), so undo can simply re-append the removed records.
1280
+ Undo is valid until the next Bind/Scramble/place/remove (those rebuild or
1281
+ re-infer the per-atom state). "Burn" additionally removes the source atom
1282
+ from the layer TEMPLATE, so the erase survives scene rebuilds. */
1283
+ function eraseAt(wx,wy,stroke){
1284
+ const skip=i=>{ const g=believedGroup(i); return g>=0&&S.layers[S.placed[g]].hidden; };
1285
+ const mask=eraseMask(S.obs,wx,wy,brushR(),skip);
1286
+ let any=false; for(const m of mask) if(m){any=true;break;}
1287
+ if(!any) return;
1288
+ const kObs=[],kGt=[],kSrc=[],kEvid=[],
1289
+ kAssign=S.assign?[]:null, kMarg=S.marg?[]:null;
1290
+ for(let i=0;i<S.obs.length;i++){
1291
+ if(mask[i]){
1292
+ stroke.atoms.push({a:S.obs[i],gt:S.gt[i],src:S.src[i],evid:S.evid[i],
1293
+ assign:S.assign?S.assign[i]:null, marg:S.marg?S.marg[i]:null});
1294
+ } else {
1295
+ kObs.push(S.obs[i]);kGt.push(S.gt[i]);kSrc.push(S.src[i]);kEvid.push(S.evid[i]);
1296
+ if(kAssign)kAssign.push(S.assign[i]);
1297
+ if(kMarg)kMarg.push(S.marg[i]);
1298
+ }
1299
+ }
1300
+ S.obs=kObs;S.gt=kGt;S.src=kSrc;S.evid=kEvid;
1301
+ if(kAssign)S.assign=kAssign;
1302
+ if(kMarg)S.marg=kMarg;
1303
+ rebuildGroupBuffers();renderScene();drawOverlay();updatePanels();
1304
+ }
1305
+ function commitStroke(stroke){
1306
+ if(!stroke.atoms.length) return;
1307
+ if(document.getElementById("erase-burn").checked){
1308
+ for(const rec of stroke.atoms){
1309
+ if(!rec.src) continue; // clutter has no template
1310
+ const L=S.layers[rec.src.li];
1311
+ const idx=L.template.indexOf(rec.src.ref);
1312
+ if(idx>=0){L.template.splice(idx,1);stroke.tmpl.push(rec.src);}
1313
+ }
1314
+ }
1315
+ eraseStack.push(stroke);
1316
+ updateEraseButtons();
1317
+ refreshOwnership();drawOverlay();renderLayerList();renderLibrary();
1318
+ }
1319
+ function undoErase(redraw=true){
1320
+ const stroke=eraseStack.pop();
1321
+ if(!stroke) return;
1322
+ for(const t of stroke.tmpl) S.layers[t.li].template.push(t.ref);
1323
+ for(const rec of stroke.atoms){
1324
+ S.obs.push(rec.a);S.gt.push(rec.gt);S.src.push(rec.src);S.evid.push(rec.evid);
1325
+ if(S.assign)S.assign.push(rec.assign??-1);
1326
+ if(S.marg)S.marg.push(rec.marg);
1327
+ }
1328
+ updateEraseButtons();
1329
+ if(redraw){
1330
+ rebuildGroupBuffers();renderScene();refreshOwnership();
1331
+ drawOverlay();updatePanels();renderLayerList();renderLibrary();
1332
+ }
1333
+ }
1334
+ function restoreAllErases(){
1335
+ while(eraseStack.length) undoErase(false);
1336
+ rebuildGroupBuffers();renderScene();refreshOwnership();
1337
+ drawOverlay();updatePanels();renderLayerList();renderLibrary();
1338
+ }
1339
+
1340
  /* ------------- pointer interaction ------------- */
1341
  let drag=null;
1342
  overC.addEventListener("pointerdown",e=>{
 
1353
  }
1354
  return;
1355
  }
1356
+ if(toolMode()==="erase"){ // erase works in soup AND bound
1357
+ drag={erase:true,stroke:{atoms:[],tmpl:[]}};
1358
+ overC.setPointerCapture(e.pointerId);
1359
+ lastCursor=[wx,wy];
1360
+ eraseAt(wx,wy,drag.stroke);
1361
+ return;
1362
+ }
1363
  if(S.phase!=="bound") return;
1364
  let best=-1,bd=0.12*0.12;
1365
  for(let i=0;i<S.obs.length;i++){
 
1379
  overC.setPointerCapture(e.pointerId);
1380
  });
1381
  overC.addEventListener("pointermove",e=>{
 
1382
  const rect=overC.getBoundingClientRect();
1383
  const wx=p2w((e.clientX-rect.left)*overC.width/rect.width);
1384
  const wy=p2w((e.clientY-rect.top)*overC.width/rect.width);
1385
+ if(toolMode()==="erase") lastCursor=[wx,wy];
1386
+ if(!drag){ if(toolMode()==="erase") drawOverlay(); return; }
1387
+ if(drag.erase){ eraseAt(wx,wy,drag.stroke); return; }
1388
  const dx=wx-drag.lx,dy=wy-drag.ly;
1389
  if(dx||dy){applyGesture(drag.k,gestureTranslate(dx,dy));drag.lx=wx;drag.ly=wy;drag.moved=true;}
1390
  });
1391
  overC.addEventListener("pointerup",e=>{
1392
  if(!drag) return;
1393
+ if(drag.erase){ commitStroke(drag.stroke); drag=null; return; }
1394
  overC.classList.remove("dragging");
1395
  if(drag.moved) resettle(drag.k,S.mu[drag.k]);
1396
  drag=null;
1397
  });
1398
+ overC.addEventListener("pointerleave",()=>{
1399
+ if(lastCursor){ lastCursor=null; drawOverlay(); }
1400
+ });
1401
  overC.addEventListener("wheel",e=>{
1402
  if(S.phase!=="bound") return;
1403
  const rect=overC.getBoundingClientRect();
 
1698
  if(S.phase==="bound"){occlusionRefresh(false);renderScene();}
1699
  });
1700
 
1701
+ /* ------------- edit & look wiring (Bet 9) ------------- */
1702
+ document.getElementById("btn-undo-erase").addEventListener("click",()=>undoErase());
1703
+ document.getElementById("btn-restore-erase").addEventListener("click",restoreAllErases);
1704
+ document.querySelectorAll('input[name=tool]').forEach(el=>el.addEventListener("change",()=>{
1705
+ stage.classList.toggle("erasing",toolMode()==="erase");
1706
+ if(toolMode()!=="erase") lastCursor=null;
1707
+ drawOverlay();
1708
+ }));
1709
+ document.querySelectorAll('input[name=brush]').forEach(el=>el.addEventListener("change",drawOverlay));
1710
+ document.getElementById("bg-color").addEventListener("input",e=>{
1711
+ const h=e.target.value;
1712
+ backdrop.rgb=[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
1713
+ backdrop.custom=true;renderScene();
1714
+ });
1715
+ document.getElementById("bg-file").addEventListener("change",e=>{
1716
+ const f=e.target.files[0];if(!f)return;
1717
+ const img=new Image();
1718
+ img.onload=()=>{
1719
+ const c=document.createElement("canvas");c.width=c.height=RES;
1720
+ const sc=Math.max(RES/img.width,RES/img.height); // cover-crop
1721
+ c.getContext("2d").drawImage(img,(RES-img.width*sc)/2,(RES-img.height*sc)/2,img.width*sc,img.height*sc);
1722
+ backdrop.img=c.getContext("2d").getImageData(0,0,RES,RES).data;
1723
+ backdrop.custom=true;renderScene();
1724
+ URL.revokeObjectURL(img.src);
1725
+ };
1726
+ img.src=URL.createObjectURL(f);
1727
+ });
1728
+ document.getElementById("btn-bg-clear").addEventListener("click",()=>{
1729
+ backdrop.img=null;document.getElementById("bg-file").value="";
1730
+ renderScene();
1731
+ });
1732
+ for(const [id,set] of [
1733
+ ["g-coarse",v=>GAINS.band[0]=v],["g-mid",v=>GAINS.band[1]=v],
1734
+ ["g-fine",v=>GAINS.band[2]=v],["g-op",v=>GAINS.opacity=v]]){
1735
+ const el=document.getElementById(id);
1736
+ el.addEventListener("input",()=>{
1737
+ const v=el.value/100; set(v);
1738
+ document.getElementById(id+"-v").textContent=v.toFixed(2);
1739
+ renderScene();
1740
+ });
1741
+ }
1742
+
1743
  /* ------------- boot ------------- */
1744
  document.getElementById("btn-scramble").addEventListener("click",scramble);
1745
  document.getElementById("btn-bind").addEventListener("click",()=>bind(true));
studio/studio_core.js CHANGED
@@ -107,6 +107,47 @@ function trimAtoms(atoms, sigMax = 0.35) {
107
  return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
108
  }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
111
  function composePose(g2, g1) {
112
  const s2 = Math.exp(g2[3]);
@@ -131,5 +172,6 @@ if (typeof module !== "undefined") {
131
  ALPHA_C, COVER_THRESH, alphaFromPre, coverageOf, autoHidden,
132
  compositePainter, composePose, gestureTranslate, gestureRotateAbout,
133
  gestureScaleAbout, trimAtoms,
 
134
  };
135
  }
 
107
  return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
108
  }
109
 
110
+ /* ---- Bet 9 additions: eraser + backdrop + band gains (pure, testable) ---- */
111
+
112
+ /* Frequency band of an atom. f is cycles/unit; the frame spans 2 units, so
113
+ cycles-per-image = 2f. Bands follow the original splatstack dials:
114
+ coarse < 4 c/img, mid 4–10, fine > 10. Returns 0|1|2. */
115
+ const BAND_EDGES = [2.0, 5.0];
116
+ function bandOf(f) { return f < BAND_EDGES[0] ? 0 : f < BAND_EDGES[1] ? 1 : 2; }
117
+
118
+ /* Erase mask: true for atoms whose center falls inside the brush circle.
119
+ skip(i) can veto (e.g. atoms of hidden layers stay untouched). */
120
+ function eraseMask(obs, wx, wy, r, skip) {
121
+ const r2 = r * r;
122
+ return obs.map((a, i) => {
123
+ if (skip && skip(i)) return false;
124
+ const dx = a[0] - wx, dy = a[1] - wy;
125
+ return dx * dx + dy * dy < r2;
126
+ });
127
+ }
128
+
129
+ /* Composite an already-sigmoided field over a backdrop.
130
+ img: Uint8ClampedArray RGBA from sigmoidField/compositePainter (mutated).
131
+ alpha: Float32Array(H*H) from alphaFromPre of the (band-gained) pre field.
132
+ opacity: global gain on alpha, clamped to [0,1] per pixel.
133
+ bg: either [r,g,b] flat color or a Uint8ClampedArray(4*H*H) RGBA image.
134
+ Identity check: with alpha==1 everywhere or bg==the field itself the
135
+ output is unchanged; with opacity such that a==1 the field wins. */
136
+ function blendOverBackdrop(img, alpha, opacity, bg, H) {
137
+ const n = H * H;
138
+ const flat = !(bg && bg.length === 4 * n);
139
+ for (let i = 0; i < n; i++) {
140
+ const a = Math.min(1, alpha[i] * opacity);
141
+ const br = flat ? bg[0] : bg[4 * i],
142
+ bgr = flat ? bg[1] : bg[4 * i + 1],
143
+ bb = flat ? bg[2] : bg[4 * i + 2];
144
+ img[4 * i] = br + a * (img[4 * i] - br);
145
+ img[4 * i + 1] = bgr + a * (img[4 * i + 1] - bgr);
146
+ img[4 * i + 2] = bb + a * (img[4 * i + 2] - bb);
147
+ }
148
+ return img;
149
+ }
150
+
151
  /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
152
  function composePose(g2, g1) {
153
  const s2 = Math.exp(g2[3]);
 
172
  ALPHA_C, COVER_THRESH, alphaFromPre, coverageOf, autoHidden,
173
  compositePainter, composePose, gestureTranslate, gestureRotateAbout,
174
  gestureScaleAbout, trimAtoms,
175
+ BAND_EDGES, bandOf, eraseMask, blendOverBackdrop,
176
  };
177
  }
studio_core.js CHANGED
@@ -107,6 +107,47 @@ function trimAtoms(atoms, sigMax = 0.35) {
107
  return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
108
  }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
111
  function composePose(g2, g1) {
112
  const s2 = Math.exp(g2[3]);
@@ -131,5 +172,6 @@ if (typeof module !== "undefined") {
131
  ALPHA_C, COVER_THRESH, alphaFromPre, coverageOf, autoHidden,
132
  compositePainter, composePose, gestureTranslate, gestureRotateAbout,
133
  gestureScaleAbout, trimAtoms,
 
134
  };
135
  }
 
107
  return kept.map(a => { const b = a.slice(); b[0] -= mx; b[1] -= my; return b; });
108
  }
109
 
110
+ /* ---- Bet 9 additions: eraser + backdrop + band gains (pure, testable) ---- */
111
+
112
+ /* Frequency band of an atom. f is cycles/unit; the frame spans 2 units, so
113
+ cycles-per-image = 2f. Bands follow the original splatstack dials:
114
+ coarse < 4 c/img, mid 4–10, fine > 10. Returns 0|1|2. */
115
+ const BAND_EDGES = [2.0, 5.0];
116
+ function bandOf(f) { return f < BAND_EDGES[0] ? 0 : f < BAND_EDGES[1] ? 1 : 2; }
117
+
118
+ /* Erase mask: true for atoms whose center falls inside the brush circle.
119
+ skip(i) can veto (e.g. atoms of hidden layers stay untouched). */
120
+ function eraseMask(obs, wx, wy, r, skip) {
121
+ const r2 = r * r;
122
+ return obs.map((a, i) => {
123
+ if (skip && skip(i)) return false;
124
+ const dx = a[0] - wx, dy = a[1] - wy;
125
+ return dx * dx + dy * dy < r2;
126
+ });
127
+ }
128
+
129
+ /* Composite an already-sigmoided field over a backdrop.
130
+ img: Uint8ClampedArray RGBA from sigmoidField/compositePainter (mutated).
131
+ alpha: Float32Array(H*H) from alphaFromPre of the (band-gained) pre field.
132
+ opacity: global gain on alpha, clamped to [0,1] per pixel.
133
+ bg: either [r,g,b] flat color or a Uint8ClampedArray(4*H*H) RGBA image.
134
+ Identity check: with alpha==1 everywhere or bg==the field itself the
135
+ output is unchanged; with opacity such that a==1 the field wins. */
136
+ function blendOverBackdrop(img, alpha, opacity, bg, H) {
137
+ const n = H * H;
138
+ const flat = !(bg && bg.length === 4 * n);
139
+ for (let i = 0; i < n; i++) {
140
+ const a = Math.min(1, alpha[i] * opacity);
141
+ const br = flat ? bg[0] : bg[4 * i],
142
+ bgr = flat ? bg[1] : bg[4 * i + 1],
143
+ bb = flat ? bg[2] : bg[4 * i + 2];
144
+ img[4 * i] = br + a * (img[4 * i] - br);
145
+ img[4 * i + 1] = bgr + a * (img[4 * i + 1] - bgr);
146
+ img[4 * i + 2] = bb + a * (img[4 * i + 2] - bb);
147
+ }
148
+ return img;
149
+ }
150
+
151
  /* Sim(2) composition in vote coordinates: (g2 ∘ g1). */
152
  function composePose(g2, g1) {
153
  const s2 = Math.exp(g2[3]);
 
172
  ALPHA_C, COVER_THRESH, alphaFromPre, coverageOf, autoHidden,
173
  compositePainter, composePose, gestureTranslate, gestureRotateAbout,
174
  gestureScaleAbout, trimAtoms,
175
+ BAND_EDGES, bandOf, eraseMask, blendOverBackdrop,
176
  };
177
  }
studio_ui.html CHANGED
@@ -72,6 +72,11 @@ button.primary{border-color:var(--phosphor);color:var(--phosphor)}
72
  label.tog{display:flex;gap:8px;align-items:center;cursor:pointer;padding:2px 0;font-size:12px}
73
  label.tog input{accent-color:var(--phosphor)}
74
  input[type=range]{flex:1;accent-color:var(--phosphor)}
 
 
 
 
 
75
  input[type=text],textarea{font-family:var(--mono);font-size:12px;background:var(--well);
76
  color:var(--ink);border:1px solid var(--panel-edge);border-radius:4px;padding:6px 8px;width:100%}
77
  input[type=text]:focus-visible,textarea:focus-visible{outline:2px solid var(--phosphor);border-color:transparent}
@@ -99,7 +104,7 @@ input[type=file]{font-size:10.5px;color:var(--muted);max-width:170px}
99
  <div id="stage">
100
  <canvas id="scene-canvas"></canvas>
101
  <canvas id="overlay-canvas"></canvas>
102
- <div class="hint">drag = pose clamp &middot; wheel rotate &middot; shift+wheel scale &middot; shift+click = semantic hint for selected layer</div>
103
  </div>
104
  </div>
105
 
@@ -129,6 +134,36 @@ input[type=file]{font-size:10.5px;color:var(--muted);max-width:170px}
129
  </div>
130
  </div>
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  <div class="card">
133
  <h2>LAYERS <span class="dim" style="letter-spacing:0">(front is highest)</span></h2>
134
  <div id="layer-list"></div>
@@ -177,7 +212,7 @@ input[type=file]{font-size:10.5px;color:var(--muted);max-width:170px}
177
  <div class="card" id="ledger">
178
  <details>
179
  <summary>HONEST LEDGER</summary>
180
- <p><b>Verified in this build:</b> the BP core matches the SlapstackBet6 Python (transform 2e-16, render MSE 8.6e-8, identical binding); drag = pose clamp + re-settle; identity persists through occlusion; depth occlusion removes evidence honestly (marginals revert toward the prior, coverage releases when moved away); the semantic likelihood factor resolves the twins case (two identical stars: 0.05 acc on geometry alone, 0.99 with location priors) and a mildly wrong prior is overridden by evidence; image&rarr;layer fitting runs end to end on CPU.</p>
181
  <p><span class="no">Not verified:</span> text&rarr;layer (SDS) and cross-attention matte are line-for-line adaptations of the verified Bet-5 GPU loop but have NOT been executed on a GPU — first run is a smoke test; SD attention maps flicker across seeds, which is why they enter BP as a prior with a weight, never as truth. Real-photo scene&rarr;atoms remains open. Painter compositing and the alpha matte are presentation choices, not the additive field model.</p>
182
  </details>
183
  </div>
@@ -221,6 +256,19 @@ const S = {
221
  };
222
  let nextDepth = 1, nextId = 1;
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  function addLayer(name, template, autoPlace) {
225
  const [color, rgb] = PALETTE[(S.layers.length) % PALETTE.length];
226
  const L = { id: nextId++, name, color, rgb, template, placed: false,
@@ -249,17 +297,19 @@ function rand(){ rngState=(rngState*1103515245+12345)&0x7fffffff; return rngStat
249
  function rebuildScene() {
250
  S.placed = S.layers.map((L,i)=>L.placed?i:-1).filter(i=>i>=0);
251
  const noiseAmt = 0.006 * Math.pow(3, +document.getElementById("noise").value / 3);
252
- S.obs = []; S.gt = [];
253
  S.placed.forEach((li,k)=>{
254
- for (const a of transformAtoms(S.layers[li].template, S.layers[li].pose)) {
255
- S.obs.push(a); S.gt.push(k);
 
 
256
  }
257
  });
258
  for (let i=0;i<12;i++){
259
  const su=0.04+0.08*rand();
260
  S.obs.push([-0.95+1.9*rand(),-0.95+1.9*rand(),-Math.PI/2+Math.PI*rand(),
261
  su,su*(0.4+0.5*rand()),4+10*rand(),TAU*rand(),rand(),rand(),rand()]);
262
- S.gt.push(-1);
263
  }
264
  for (const a of S.obs) for(let q=0;q<10;q++){
265
  const g=Math.sqrt(-2*Math.log(rand()+1e-9))*Math.cos(TAU*rand());
@@ -271,6 +321,7 @@ function rebuildScene() {
271
  S.covFrac=S.placed.map(()=>0);
272
  S.coastT=S.placed.map(()=>0);
273
  S.ownField=null;
 
274
  setPhase("soup");
275
  rebuildGroupBuffers();
276
  renderScene(); drawOverlay(); renderLayerList(); renderLibrary(); updatePanels();
@@ -328,12 +379,28 @@ function rebuildGroupBuffers(){
328
  renderPre([S.obs[i]],RES,S.groupPre[g]);
329
  }
330
  for(let g=0;g<=K;g++) S.groupAlpha.push(alphaFromPre(S.groupPre[g],RES));
 
331
  }
332
  function rebuildOneGroup(k){
333
  S.groupPre[k]=new Float32Array(3*RES*RES);
334
  if(!S.layers[S.placed[k]].hidden)
335
  renderPre(groupAtomIdx(k).map(i=>S.obs[i]),RES,S.groupPre[k]);
336
  S.groupAlpha[k]=alphaFromPre(S.groupPre[k],RES);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  }
338
  const sceneCtx=sceneC.getContext("2d");
339
  const compositePre=new Float32Array(3*RES*RES);
@@ -341,16 +408,40 @@ const sceneImg=new ImageData(RES,RES);
341
  function compMode(){ return document.querySelector('input[name=comp]:checked').value; }
342
  function renderScene(){
343
  const K=S.placed.length;
 
 
344
  if(compMode()==="field"){
345
  compositePre.fill(0);
346
- for(const buf of S.groupPre)
347
- for(let i=0;i<compositePre.length;i++) compositePre[i]+=buf[i];
 
 
 
 
 
 
 
 
 
348
  sigmoidField(compositePre,RES,sceneImg.data);
 
 
 
 
349
  } else {
350
  // painter: clutter at the back, then placed layers by depth
351
  const order=[K, ...S.placed.map((li,k)=>k)
352
  .sort((a,b)=>S.layers[S.placed[a]].depth-S.layers[S.placed[b]].depth)];
353
  compositePainter(order,S.groupPre,S.groupAlpha,RES,sceneImg.data);
 
 
 
 
 
 
 
 
 
354
  }
355
  sceneCtx.putImageData(sceneImg,0,0);
356
  }
@@ -460,6 +551,14 @@ function drawOverlay(){
460
  }
461
  }
462
  }
 
 
 
 
 
 
 
 
463
  }
464
 
465
  /* ------------- semantic factor from hints ------------- */
@@ -516,6 +615,7 @@ function runBind(opts={}){
516
  function bind(animate=true){
517
  if(S.phase==="binding"||!S.placed.length) return;
518
  S.assign=null;
 
519
  S.evid=S.obs.map(()=>true); // full re-bind: everything is evidence again
520
  for(const li of S.placed) if(S.layers[li].hidden){
521
  // user-hidden layers stay evidence-free
@@ -599,6 +699,70 @@ function applyGesture(k,g){
599
  renderScene();drawOverlay();updatePanels();
600
  }
601
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  /* ------------- pointer interaction ------------- */
603
  let drag=null;
604
  overC.addEventListener("pointerdown",e=>{
@@ -615,6 +779,13 @@ overC.addEventListener("pointerdown",e=>{
615
  }
616
  return;
617
  }
 
 
 
 
 
 
 
618
  if(S.phase!=="bound") return;
619
  let best=-1,bd=0.12*0.12;
620
  for(let i=0;i<S.obs.length;i++){
@@ -634,19 +805,25 @@ overC.addEventListener("pointerdown",e=>{
634
  overC.setPointerCapture(e.pointerId);
635
  });
636
  overC.addEventListener("pointermove",e=>{
637
- if(!drag) return;
638
  const rect=overC.getBoundingClientRect();
639
  const wx=p2w((e.clientX-rect.left)*overC.width/rect.width);
640
  const wy=p2w((e.clientY-rect.top)*overC.width/rect.width);
 
 
 
641
  const dx=wx-drag.lx,dy=wy-drag.ly;
642
  if(dx||dy){applyGesture(drag.k,gestureTranslate(dx,dy));drag.lx=wx;drag.ly=wy;drag.moved=true;}
643
  });
644
  overC.addEventListener("pointerup",e=>{
645
  if(!drag) return;
 
646
  overC.classList.remove("dragging");
647
  if(drag.moved) resettle(drag.k,S.mu[drag.k]);
648
  drag=null;
649
  });
 
 
 
650
  overC.addEventListener("wheel",e=>{
651
  if(S.phase!=="bound") return;
652
  const rect=overC.getBoundingClientRect();
@@ -947,6 +1124,48 @@ document.getElementById("auto-occ").addEventListener("change",()=>{
947
  if(S.phase==="bound"){occlusionRefresh(false);renderScene();}
948
  });
949
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
950
  /* ------------- boot ------------- */
951
  document.getElementById("btn-scramble").addEventListener("click",scramble);
952
  document.getElementById("btn-bind").addEventListener("click",()=>bind(true));
 
72
  label.tog{display:flex;gap:8px;align-items:center;cursor:pointer;padding:2px 0;font-size:12px}
73
  label.tog input{accent-color:var(--phosphor)}
74
  input[type=range]{flex:1;accent-color:var(--phosphor)}
75
+ .gain-row{display:grid;grid-template-columns:54px 1fr 40px;gap:6px;align-items:center;margin-top:3px}
76
+ .gain-row input[type=range]{width:100%;margin:0}
77
+ .gain-row .val{text-align:right}
78
+ input[type=color]{width:36px;height:22px;padding:1px;border:1px solid var(--panel-edge);background:var(--well);border-radius:4px;cursor:pointer}
79
+ #stage.erasing #overlay-canvas{cursor:crosshair}
80
  input[type=text],textarea{font-family:var(--mono);font-size:12px;background:var(--well);
81
  color:var(--ink);border:1px solid var(--panel-edge);border-radius:4px;padding:6px 8px;width:100%}
82
  input[type=text]:focus-visible,textarea:focus-visible{outline:2px solid var(--phosphor);border-color:transparent}
 
104
  <div id="stage">
105
  <canvas id="scene-canvas"></canvas>
106
  <canvas id="overlay-canvas"></canvas>
107
+ <div class="hint">drag = pose clamp &middot; wheel rotate &middot; shift+wheel scale &middot; shift+click = hint &middot; erase tool: brush deletes atoms</div>
108
  </div>
109
  </div>
110
 
 
134
  </div>
135
  </div>
136
 
137
+ <div class="card">
138
+ <h2>EDIT &amp; LOOK</h2>
139
+ <div class="row">
140
+ <span class="dim">tool</span>
141
+ <label class="tog"><input type="radio" name="tool" value="pose" checked> pose</label>
142
+ <label class="tog"><input type="radio" name="tool" value="erase"> erase</label>
143
+ <span class="spacer"></span>
144
+ <span class="dim">brush</span>
145
+ <label class="tog"><input type="radio" name="brush" value="0.05"> S</label>
146
+ <label class="tog"><input type="radio" name="brush" value="0.12" checked> M</label>
147
+ <label class="tog"><input type="radio" name="brush" value="0.25"> L</label>
148
+ </div>
149
+ <div class="row">
150
+ <button id="btn-undo-erase" disabled>Undo erase</button>
151
+ <button id="btn-restore-erase" disabled>Restore all</button>
152
+ <label class="tog"><input type="checkbox" id="erase-burn"> burn into template</label>
153
+ </div>
154
+ <div class="row">
155
+ <span class="dim">backdrop</span>
156
+ <input type="color" id="bg-color" value="#808080" title="backdrop color">
157
+ <input type="file" id="bg-file" accept="image/*" style="max-width:46%">
158
+ <button id="btn-bg-clear">clear img</button>
159
+ </div>
160
+ <div class="gain-row"><span class="dim">coarse</span><input type="range" id="g-coarse" min="0" max="200" value="100"><span class="val readout" id="g-coarse-v">1.00</span></div>
161
+ <div class="gain-row"><span class="dim">mid</span><input type="range" id="g-mid" min="0" max="200" value="100"><span class="val readout" id="g-mid-v">1.00</span></div>
162
+ <div class="gain-row"><span class="dim">fine</span><input type="range" id="g-fine" min="0" max="200" value="100"><span class="val readout" id="g-fine-v">1.00</span></div>
163
+ <div class="gain-row"><span class="dim">opacity</span><input type="range" id="g-op" min="0" max="200" value="100"><span class="val readout" id="g-op-v">1.00</span></div>
164
+ <div class="dim" style="font-size:10.5px;margin-top:6px">Erase deletes atoms under the brush (render only, until <b>burn</b> writes it into the layer template; Scramble/Bind rebuild the scene from templates). Undo works until the next Bind. Backdrop is presentation and survives everything. Band gains (coarse&lt;4, mid 4&ndash;10, fine&gt;10&nbsp;c/img) scale atom colors in field mode; opacity blends the whole field over the backdrop.</div>
165
+ </div>
166
+
167
  <div class="card">
168
  <h2>LAYERS <span class="dim" style="letter-spacing:0">(front is highest)</span></h2>
169
  <div id="layer-list"></div>
 
212
  <div class="card" id="ledger">
213
  <details>
214
  <summary>HONEST LEDGER</summary>
215
+ <p><b>Verified in this build:</b> the BP core matches the SlapstackBet6 Python (transform 2e-16, render MSE 8.6e-8, identical binding); drag = pose clamp + re-settle; identity persists through occlusion; depth occlusion removes evidence honestly (marginals revert toward the prior, coverage releases when moved away); the semantic likelihood factor resolves the twins case (two identical stars: 0.05 acc on geometry alone, 0.99 with location priors) and a mildly wrong prior is overridden by evidence; image&rarr;layer fitting runs end to end on CPU. <b>Bet 9:</b> the eraser filters per-atom state (order-free, so undo is re-append), burn edits the template so erases survive rebuilds; the backdrop is pure presentation and persists across Bind/Scramble; band gains exploit render linearity (gain&middot;color &equiv; gain&middot;contribution), verified in tests_edit.js.</p>
216
  <p><span class="no">Not verified:</span> text&rarr;layer (SDS) and cross-attention matte are line-for-line adaptations of the verified Bet-5 GPU loop but have NOT been executed on a GPU — first run is a smoke test; SD attention maps flicker across seeds, which is why they enter BP as a prior with a weight, never as truth. Real-photo scene&rarr;atoms remains open. Painter compositing and the alpha matte are presentation choices, not the additive field model.</p>
217
  </details>
218
  </div>
 
256
  };
257
  let nextDepth = 1, nextId = 1;
258
 
259
+ /* ---- Bet 9: eraser / backdrop / band-gain state ---- */
260
+ const GAINS = { band: [1, 1, 1], opacity: 1 };
261
+ const backdrop = { custom: false, rgb: [128, 128, 128], img: null }; // img: RGBA RES*RES
262
+ let bandPre = null, bandDirty = true;
263
+ const fieldAlpha = new Float32Array(RES * RES);
264
+ let eraseStack = [], lastCursor = null;
265
+ function toolMode(){ return document.querySelector('input[name=tool]:checked').value; }
266
+ function brushR(){ return +document.querySelector('input[name=brush]:checked').value; }
267
+ function updateEraseButtons(){
268
+ document.getElementById("btn-undo-erase").disabled = !eraseStack.length;
269
+ document.getElementById("btn-restore-erase").disabled = !eraseStack.length;
270
+ }
271
+
272
  function addLayer(name, template, autoPlace) {
273
  const [color, rgb] = PALETTE[(S.layers.length) % PALETTE.length];
274
  const L = { id: nextId++, name, color, rgb, template, placed: false,
 
297
  function rebuildScene() {
298
  S.placed = S.layers.map((L,i)=>L.placed?i:-1).filter(i=>i>=0);
299
  const noiseAmt = 0.006 * Math.pow(3, +document.getElementById("noise").value / 3);
300
+ S.obs = []; S.gt = []; S.src = [];
301
  S.placed.forEach((li,k)=>{
302
+ const tmpl = S.layers[li].template;
303
+ const tr = transformAtoms(tmpl, S.layers[li].pose);
304
+ for (let j = 0; j < tr.length; j++) {
305
+ S.obs.push(tr[j]); S.gt.push(k); S.src.push({ li, ref: tmpl[j] });
306
  }
307
  });
308
  for (let i=0;i<12;i++){
309
  const su=0.04+0.08*rand();
310
  S.obs.push([-0.95+1.9*rand(),-0.95+1.9*rand(),-Math.PI/2+Math.PI*rand(),
311
  su,su*(0.4+0.5*rand()),4+10*rand(),TAU*rand(),rand(),rand(),rand()]);
312
+ S.gt.push(-1); S.src.push(null);
313
  }
314
  for (const a of S.obs) for(let q=0;q<10;q++){
315
  const g=Math.sqrt(-2*Math.log(rand()+1e-9))*Math.cos(TAU*rand());
 
321
  S.covFrac=S.placed.map(()=>0);
322
  S.coastT=S.placed.map(()=>0);
323
  S.ownField=null;
324
+ eraseStack=[]; updateEraseButtons();
325
  setPhase("soup");
326
  rebuildGroupBuffers();
327
  renderScene(); drawOverlay(); renderLayerList(); renderLibrary(); updatePanels();
 
379
  renderPre([S.obs[i]],RES,S.groupPre[g]);
380
  }
381
  for(let g=0;g<=K;g++) S.groupAlpha.push(alphaFromPre(S.groupPre[g],RES));
382
+ bandDirty=true;
383
  }
384
  function rebuildOneGroup(k){
385
  S.groupPre[k]=new Float32Array(3*RES*RES);
386
  if(!S.layers[S.placed[k]].hidden)
387
  renderPre(groupAtomIdx(k).map(i=>S.obs[i]),RES,S.groupPre[k]);
388
  S.groupAlpha[k]=alphaFromPre(S.groupPre[k],RES);
389
+ bandDirty=true;
390
+ }
391
+ /* frequency-band buffers, built lazily only when a band gain != 1.
392
+ Same visibility rule as the group buffers; render is linear in atom color,
393
+ so gain*color == gain*contribution and Σ_b gain_b·bandPre_b is exact. */
394
+ function rebuildBandBuffers(){
395
+ const K=S.placed.length;
396
+ bandPre=[new Float32Array(3*RES*RES),new Float32Array(3*RES*RES),new Float32Array(3*RES*RES)];
397
+ const groupOf=i=>{ const g=S.marg?believedGroup(i):S.gt[i]; return g>=0?g:K; };
398
+ for(let i=0;i<S.obs.length;i++){
399
+ const g=groupOf(i);
400
+ if(g<K && S.layers[S.placed[g]].hidden) continue;
401
+ renderPre([S.obs[i]],RES,bandPre[bandOf(S.obs[i][5])]);
402
+ }
403
+ bandDirty=false;
404
  }
405
  const sceneCtx=sceneC.getContext("2d");
406
  const compositePre=new Float32Array(3*RES*RES);
 
408
  function compMode(){ return document.querySelector('input[name=comp]:checked').value; }
409
  function renderScene(){
410
  const K=S.placed.length;
411
+ const gb=GAINS.band, gainsOn=gb[0]!==1||gb[1]!==1||gb[2]!==1;
412
+ const bgOn=backdrop.custom||backdrop.img||GAINS.opacity!==1;
413
  if(compMode()==="field"){
414
  compositePre.fill(0);
415
+ if(gainsOn){
416
+ if(bandDirty) rebuildBandBuffers();
417
+ for(let b=0;b<3;b++){
418
+ const g=gb[b]; if(!g) continue;
419
+ const buf=bandPre[b];
420
+ for(let i=0;i<compositePre.length;i++) compositePre[i]+=g*buf[i];
421
+ }
422
+ } else {
423
+ for(const buf of S.groupPre)
424
+ for(let i=0;i<compositePre.length;i++) compositePre[i]+=buf[i];
425
+ }
426
  sigmoidField(compositePre,RES,sceneImg.data);
427
+ if(bgOn){
428
+ alphaFromPre(compositePre,RES,fieldAlpha);
429
+ blendOverBackdrop(sceneImg.data,fieldAlpha,GAINS.opacity,backdrop.img||backdrop.rgb,RES);
430
+ }
431
  } else {
432
  // painter: clutter at the back, then placed layers by depth
433
  const order=[K, ...S.placed.map((li,k)=>k)
434
  .sort((a,b)=>S.layers[S.placed[a]].depth-S.layers[S.placed[b]].depth)];
435
  compositePainter(order,S.groupPre,S.groupAlpha,RES,sceneImg.data);
436
+ if(bgOn){
437
+ // union alpha of everything rendered (band gains are field-mode only)
438
+ for(let i=0;i<RES*RES;i++){
439
+ let keep=1;
440
+ for(let q=0;q<=K;q++) keep*=1-S.groupAlpha[q][i];
441
+ fieldAlpha[i]=1-keep;
442
+ }
443
+ blendOverBackdrop(sceneImg.data,fieldAlpha,GAINS.opacity,backdrop.img||backdrop.rgb,RES);
444
+ }
445
  }
446
  sceneCtx.putImageData(sceneImg,0,0);
447
  }
 
551
  }
552
  }
553
  }
554
+
555
+ if(toolMode()==="erase"&&lastCursor){ // eraser brush cursor
556
+ ctx.strokeStyle="rgba(255,120,110,0.9)";
557
+ ctx.lineWidth=1.4*px1;ctx.setLineDash([4*px1,3*px1]);
558
+ ctx.beginPath();
559
+ ctx.arc(w2p(lastCursor[0]),w2p(lastCursor[1]),brushR()*overC.width/2,0,TAU);
560
+ ctx.stroke();ctx.setLineDash([]);
561
+ }
562
  }
563
 
564
  /* ------------- semantic factor from hints ------------- */
 
615
  function bind(animate=true){
616
  if(S.phase==="binding"||!S.placed.length) return;
617
  S.assign=null;
618
+ eraseStack=[]; updateEraseButtons(); // erase history is stale once BP re-runs
619
  S.evid=S.obs.map(()=>true); // full re-bind: everything is evidence again
620
  for(const li of S.placed) if(S.layers[li].hidden){
621
  // user-hidden layers stay evidence-free
 
699
  renderScene();drawOverlay();updatePanels();
700
  }
701
 
702
+ /* ------------- eraser (Bet 9) -------------
703
+ Deleting an atom = filtering every per-atom parallel array by the brush
704
+ mask. Order of atoms carries no meaning anywhere (additive render, per-atom
705
+ marginals), so undo can simply re-append the removed records.
706
+ Undo is valid until the next Bind/Scramble/place/remove (those rebuild or
707
+ re-infer the per-atom state). "Burn" additionally removes the source atom
708
+ from the layer TEMPLATE, so the erase survives scene rebuilds. */
709
+ function eraseAt(wx,wy,stroke){
710
+ const skip=i=>{ const g=believedGroup(i); return g>=0&&S.layers[S.placed[g]].hidden; };
711
+ const mask=eraseMask(S.obs,wx,wy,brushR(),skip);
712
+ let any=false; for(const m of mask) if(m){any=true;break;}
713
+ if(!any) return;
714
+ const kObs=[],kGt=[],kSrc=[],kEvid=[],
715
+ kAssign=S.assign?[]:null, kMarg=S.marg?[]:null;
716
+ for(let i=0;i<S.obs.length;i++){
717
+ if(mask[i]){
718
+ stroke.atoms.push({a:S.obs[i],gt:S.gt[i],src:S.src[i],evid:S.evid[i],
719
+ assign:S.assign?S.assign[i]:null, marg:S.marg?S.marg[i]:null});
720
+ } else {
721
+ kObs.push(S.obs[i]);kGt.push(S.gt[i]);kSrc.push(S.src[i]);kEvid.push(S.evid[i]);
722
+ if(kAssign)kAssign.push(S.assign[i]);
723
+ if(kMarg)kMarg.push(S.marg[i]);
724
+ }
725
+ }
726
+ S.obs=kObs;S.gt=kGt;S.src=kSrc;S.evid=kEvid;
727
+ if(kAssign)S.assign=kAssign;
728
+ if(kMarg)S.marg=kMarg;
729
+ rebuildGroupBuffers();renderScene();drawOverlay();updatePanels();
730
+ }
731
+ function commitStroke(stroke){
732
+ if(!stroke.atoms.length) return;
733
+ if(document.getElementById("erase-burn").checked){
734
+ for(const rec of stroke.atoms){
735
+ if(!rec.src) continue; // clutter has no template
736
+ const L=S.layers[rec.src.li];
737
+ const idx=L.template.indexOf(rec.src.ref);
738
+ if(idx>=0){L.template.splice(idx,1);stroke.tmpl.push(rec.src);}
739
+ }
740
+ }
741
+ eraseStack.push(stroke);
742
+ updateEraseButtons();
743
+ refreshOwnership();drawOverlay();renderLayerList();renderLibrary();
744
+ }
745
+ function undoErase(redraw=true){
746
+ const stroke=eraseStack.pop();
747
+ if(!stroke) return;
748
+ for(const t of stroke.tmpl) S.layers[t.li].template.push(t.ref);
749
+ for(const rec of stroke.atoms){
750
+ S.obs.push(rec.a);S.gt.push(rec.gt);S.src.push(rec.src);S.evid.push(rec.evid);
751
+ if(S.assign)S.assign.push(rec.assign??-1);
752
+ if(S.marg)S.marg.push(rec.marg);
753
+ }
754
+ updateEraseButtons();
755
+ if(redraw){
756
+ rebuildGroupBuffers();renderScene();refreshOwnership();
757
+ drawOverlay();updatePanels();renderLayerList();renderLibrary();
758
+ }
759
+ }
760
+ function restoreAllErases(){
761
+ while(eraseStack.length) undoErase(false);
762
+ rebuildGroupBuffers();renderScene();refreshOwnership();
763
+ drawOverlay();updatePanels();renderLayerList();renderLibrary();
764
+ }
765
+
766
  /* ------------- pointer interaction ------------- */
767
  let drag=null;
768
  overC.addEventListener("pointerdown",e=>{
 
779
  }
780
  return;
781
  }
782
+ if(toolMode()==="erase"){ // erase works in soup AND bound
783
+ drag={erase:true,stroke:{atoms:[],tmpl:[]}};
784
+ overC.setPointerCapture(e.pointerId);
785
+ lastCursor=[wx,wy];
786
+ eraseAt(wx,wy,drag.stroke);
787
+ return;
788
+ }
789
  if(S.phase!=="bound") return;
790
  let best=-1,bd=0.12*0.12;
791
  for(let i=0;i<S.obs.length;i++){
 
805
  overC.setPointerCapture(e.pointerId);
806
  });
807
  overC.addEventListener("pointermove",e=>{
 
808
  const rect=overC.getBoundingClientRect();
809
  const wx=p2w((e.clientX-rect.left)*overC.width/rect.width);
810
  const wy=p2w((e.clientY-rect.top)*overC.width/rect.width);
811
+ if(toolMode()==="erase") lastCursor=[wx,wy];
812
+ if(!drag){ if(toolMode()==="erase") drawOverlay(); return; }
813
+ if(drag.erase){ eraseAt(wx,wy,drag.stroke); return; }
814
  const dx=wx-drag.lx,dy=wy-drag.ly;
815
  if(dx||dy){applyGesture(drag.k,gestureTranslate(dx,dy));drag.lx=wx;drag.ly=wy;drag.moved=true;}
816
  });
817
  overC.addEventListener("pointerup",e=>{
818
  if(!drag) return;
819
+ if(drag.erase){ commitStroke(drag.stroke); drag=null; return; }
820
  overC.classList.remove("dragging");
821
  if(drag.moved) resettle(drag.k,S.mu[drag.k]);
822
  drag=null;
823
  });
824
+ overC.addEventListener("pointerleave",()=>{
825
+ if(lastCursor){ lastCursor=null; drawOverlay(); }
826
+ });
827
  overC.addEventListener("wheel",e=>{
828
  if(S.phase!=="bound") return;
829
  const rect=overC.getBoundingClientRect();
 
1124
  if(S.phase==="bound"){occlusionRefresh(false);renderScene();}
1125
  });
1126
 
1127
+ /* ------------- edit & look wiring (Bet 9) ------------- */
1128
+ document.getElementById("btn-undo-erase").addEventListener("click",()=>undoErase());
1129
+ document.getElementById("btn-restore-erase").addEventListener("click",restoreAllErases);
1130
+ document.querySelectorAll('input[name=tool]').forEach(el=>el.addEventListener("change",()=>{
1131
+ stage.classList.toggle("erasing",toolMode()==="erase");
1132
+ if(toolMode()!=="erase") lastCursor=null;
1133
+ drawOverlay();
1134
+ }));
1135
+ document.querySelectorAll('input[name=brush]').forEach(el=>el.addEventListener("change",drawOverlay));
1136
+ document.getElementById("bg-color").addEventListener("input",e=>{
1137
+ const h=e.target.value;
1138
+ backdrop.rgb=[parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
1139
+ backdrop.custom=true;renderScene();
1140
+ });
1141
+ document.getElementById("bg-file").addEventListener("change",e=>{
1142
+ const f=e.target.files[0];if(!f)return;
1143
+ const img=new Image();
1144
+ img.onload=()=>{
1145
+ const c=document.createElement("canvas");c.width=c.height=RES;
1146
+ const sc=Math.max(RES/img.width,RES/img.height); // cover-crop
1147
+ c.getContext("2d").drawImage(img,(RES-img.width*sc)/2,(RES-img.height*sc)/2,img.width*sc,img.height*sc);
1148
+ backdrop.img=c.getContext("2d").getImageData(0,0,RES,RES).data;
1149
+ backdrop.custom=true;renderScene();
1150
+ URL.revokeObjectURL(img.src);
1151
+ };
1152
+ img.src=URL.createObjectURL(f);
1153
+ });
1154
+ document.getElementById("btn-bg-clear").addEventListener("click",()=>{
1155
+ backdrop.img=null;document.getElementById("bg-file").value="";
1156
+ renderScene();
1157
+ });
1158
+ for(const [id,set] of [
1159
+ ["g-coarse",v=>GAINS.band[0]=v],["g-mid",v=>GAINS.band[1]=v],
1160
+ ["g-fine",v=>GAINS.band[2]=v],["g-op",v=>GAINS.opacity=v]]){
1161
+ const el=document.getElementById(id);
1162
+ el.addEventListener("input",()=>{
1163
+ const v=el.value/100; set(v);
1164
+ document.getElementById(id+"-v").textContent=v.toFixed(2);
1165
+ renderScene();
1166
+ });
1167
+ }
1168
+
1169
  /* ------------- boot ------------- */
1170
  document.getElementById("btn-scramble").addEventListener("click",scramble);
1171
  document.getElementById("btn-bind").addEventListener("click",()=>bind(true));
tests_edit.js ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Bet 9 battery — eraser / backdrop / band gains, tested headless.
2
+ 1. bandOf edges match the declared bands
3
+ 2. band-gain linearity: sum_b gain_b * render(band_b) == render(gain-scaled colors), exactly
4
+ 3. blendOverBackdrop: identities (a=1 field wins, a=0 backdrop wins), image backdrop path
5
+ 4. eraseMask geometry + skip veto
6
+ 5. erase -> undo round-trip: rendered field identical (order-free re-append)
7
+ 6. burn-to-template: template loses exactly the erased refs; undo restores them
8
+ */
9
+ "use strict";
10
+ const C = require("./studio/core.js");
11
+ const SC = require("./studio/studio_core.js");
12
+
13
+ let go = 0, fail = 0;
14
+ function T(name, ok, note) {
15
+ console.log((ok ? "GO " : "FAIL") + " " + name + (note ? " [" + note + "]" : ""));
16
+ ok ? go++ : fail++;
17
+ }
18
+ const rnd = (() => { let s = 424242; return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; })();
19
+ function randAtoms(n) {
20
+ const out = [];
21
+ for (let i = 0; i < n; i++) {
22
+ const su = 0.03 + 0.1 * rnd();
23
+ out.push([-0.8 + 1.6 * rnd(), -0.8 + 1.6 * rnd(), Math.PI * rnd(),
24
+ su, su * (0.4 + 0.5 * rnd()), 0.6 + 9 * rnd(), 2 * Math.PI * rnd(),
25
+ -1 + 2 * rnd(), -1 + 2 * rnd(), -1 + 2 * rnd()]);
26
+ }
27
+ return out;
28
+ }
29
+ const H = 64;
30
+
31
+ /* 1 ------------------------------------------------------------------ */
32
+ T("bandOf edges", SC.bandOf(1.99) === 0 && SC.bandOf(2.0) === 1 &&
33
+ SC.bandOf(4.99) === 1 && SC.bandOf(5.0) === 2, "edges at f=2,5 (4 & 10 c/img)");
34
+
35
+ /* 2 band-gain linearity ---------------------------------------------- */
36
+ {
37
+ const atoms = randAtoms(40);
38
+ const gains = [0.3, 1.7, 0.0];
39
+ // path A: per-band buffers scaled at composite time
40
+ const bands = [new Float32Array(3 * H * H), new Float32Array(3 * H * H), new Float32Array(3 * H * H)];
41
+ for (const a of atoms) C.renderPre([a], H, bands[SC.bandOf(a[5])]);
42
+ const A = new Float32Array(3 * H * H);
43
+ for (let b = 0; b < 3; b++) for (let i = 0; i < A.length; i++) A[i] += gains[b] * bands[b][i];
44
+ // path B: gain baked into atom colors, rendered once
45
+ const scaled = atoms.map(a => {
46
+ const g = gains[SC.bandOf(a[5])], c = a.slice();
47
+ c[7] *= g; c[8] *= g; c[9] *= g; return c;
48
+ });
49
+ const B = C.renderPre(scaled, H);
50
+ let maxd = 0;
51
+ for (let i = 0; i < A.length; i++) maxd = Math.max(maxd, Math.abs(A[i] - B[i]));
52
+ T("band gains exact by render linearity", maxd < 1e-6, "max |A-B| = " + maxd.toExponential(1));
53
+ }
54
+
55
+ /* 3 backdrop blending -------------------------------------------------*/
56
+ {
57
+ const atoms = randAtoms(25);
58
+ const pre = C.renderPre(atoms, H);
59
+ const img0 = C.sigmoidField(pre, H);
60
+ const alpha = SC.alphaFromPre(pre, H);
61
+
62
+ // a) alpha forced to 1 -> field unchanged
63
+ const one = new Float32Array(H * H).fill(1);
64
+ const imgA = new Uint8ClampedArray(img0);
65
+ SC.blendOverBackdrop(imgA, one, 1, [10, 200, 30], H);
66
+ let d = 0; for (let i = 0; i < imgA.length; i++) d = Math.max(d, Math.abs(imgA[i] - img0[i]));
67
+ T("alpha=1: field wins everywhere", d === 0, "max delta " + d);
68
+
69
+ // b) opacity 0 -> pure backdrop color
70
+ const imgB = new Uint8ClampedArray(img0);
71
+ SC.blendOverBackdrop(imgB, alpha, 0, [10, 200, 30], H);
72
+ let ok = true;
73
+ for (let i = 0; i < H * H; i++)
74
+ if (imgB[4 * i] !== 10 || imgB[4 * i + 1] !== 200 || imgB[4 * i + 2] !== 30) { ok = false; break; }
75
+ T("opacity=0: backdrop wins everywhere", ok);
76
+
77
+ // c) image backdrop path: where alpha==0 the backdrop image shows verbatim
78
+ const bg = new Uint8ClampedArray(4 * H * H);
79
+ for (let i = 0; i < H * H; i++) { bg[4 * i] = i % 251; bg[4 * i + 1] = (2 * i) % 251; bg[4 * i + 2] = (3 * i) % 251; bg[4 * i + 3] = 255; }
80
+ const imgC = new Uint8ClampedArray(img0);
81
+ SC.blendOverBackdrop(imgC, alpha, 1, bg, H);
82
+ ok = true;
83
+ let checked = 0;
84
+ for (let i = 0; i < H * H; i++) if (alpha[i] === 0) {
85
+ checked++;
86
+ if (imgC[4 * i] !== bg[4 * i] || imgC[4 * i + 1] !== bg[4 * i + 1] || imgC[4 * i + 2] !== bg[4 * i + 2]) { ok = false; break; }
87
+ }
88
+ T("image backdrop shows through empty pixels", ok && checked > 100, checked + " empty px checked");
89
+ }
90
+
91
+ /* 4 eraseMask ----------------------------------------------------------*/
92
+ {
93
+ const obs = [[0, 0, 0, .05, .05, 3, 0, 1, 0, 0], [0.09, 0, 0, .05, .05, 3, 0, 1, 0, 0],
94
+ [0.3, 0.3, 0, .05, .05, 3, 0, 1, 0, 0]];
95
+ const m = SC.eraseMask(obs, 0, 0, 0.12, null);
96
+ T("brush circle membership", m[0] === true && m[1] === true && m[2] === false);
97
+ const m2 = SC.eraseMask(obs, 0, 0, 0.12, i => i === 1);
98
+ T("skip veto respected", m2[0] === true && m2[1] === false && m2[2] === false);
99
+ }
100
+
101
+ /* 5 erase -> undo round trip (order-free re-append) --------------------*/
102
+ {
103
+ const obs = randAtoms(60);
104
+ const before = C.renderPre(obs, H);
105
+ const mask = SC.eraseMask(obs, 0.1, -0.1, 0.35, null);
106
+ const removed = [], kept = [];
107
+ for (let i = 0; i < obs.length; i++) (mask[i] ? removed : kept).push(obs[i]);
108
+ T("stroke removed something and kept something",
109
+ removed.length > 0 && kept.length > 0, removed.length + " erased / " + kept.length + " kept");
110
+ const mid = C.renderPre(kept, H);
111
+ let changed = 0; for (let i = 0; i < mid.length; i++) if (mid[i] !== before[i]) changed++;
112
+ T("erase changes the rendered field", changed > 0, changed + " px");
113
+ const after = C.renderPre(kept.concat(removed), H); // undo = append at the end
114
+ let maxd = 0; for (let i = 0; i < after.length; i++) maxd = Math.max(maxd, Math.abs(after[i] - before[i]));
115
+ T("undo restores the field exactly (order-free)", maxd < 1e-6, "max delta " + maxd.toExponential(1));
116
+ }
117
+
118
+ /* 6 burn-to-template ----------------------------------------------------*/
119
+ {
120
+ const template = randAtoms(30);
121
+ // scene build records src by REFERENCE, like the studio does
122
+ const src = template.map(ref => ({ li: 0, ref }));
123
+ const layer = { template: template.slice() };
124
+ const mask = SC.eraseMask(template, 0, 0, 0.4, null);
125
+ const burned = [];
126
+ for (let i = 0; i < template.length; i++) if (mask[i]) {
127
+ const idx = layer.template.indexOf(src[i].ref);
128
+ if (idx >= 0) { layer.template.splice(idx, 1); burned.push(src[i]); }
129
+ }
130
+ const nGone = mask.filter(Boolean).length;
131
+ T("burn removes exactly the erased refs", burned.length === nGone &&
132
+ layer.template.length === 30 - nGone, nGone + " burned");
133
+ T("burned refs really left the template",
134
+ burned.every(b => !layer.template.includes(b.ref)));
135
+ for (const b of burned) layer.template.push(b.ref); // undo
136
+ T("undo restores template membership", layer.template.length === 30 &&
137
+ burned.every(b => layer.template.includes(b.ref)));
138
+ }
139
+
140
+ console.log("\n" + go + " GO, " + fail + " FAIL");
141
+ process.exit(fail ? 1 : 0);