Spaces:
Running
Running
Upload 18 files
Browse files- README.md +56 -5
- requirements.txt +1 -1
- studio/studio.html +269 -9
- studio/studio_core.js +42 -0
- studio_core.js +42 -0
- studio_ui.html +228 -9
- 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 |

|
| 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 |

|
| 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
|
| 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 · wheel rotate · shift+wheel scale · shift+click =
|
| 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→layer fitting runs end to end on CPU.</p>
|
| 181 |
<p><span class="no">Not verified:</span> text→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→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 |
-
|
| 788 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · wheel rotate · shift+wheel scale · shift+click = hint · erase tool: brush deletes atoms</div>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
|
|
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
+
<div class="card">
|
| 138 |
+
<h2>EDIT & 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<4, mid 4–10, fine>10 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→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·color ≡ gain·contribution), verified in tests_edit.js.</p>
|
| 216 |
<p><span class="no">Not verified:</span> text→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→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 · wheel rotate · shift+wheel scale · shift+click =
|
| 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→layer fitting runs end to end on CPU.</p>
|
| 181 |
<p><span class="no">Not verified:</span> text→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→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 |
-
|
| 255 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 · wheel rotate · shift+wheel scale · shift+click = hint · erase tool: brush deletes atoms</div>
|
| 108 |
</div>
|
| 109 |
</div>
|
| 110 |
|
|
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
+
<div class="card">
|
| 138 |
+
<h2>EDIT & 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<4, mid 4–10, fine>10 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→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·color ≡ gain·contribution), verified in tests_edit.js.</p>
|
| 216 |
<p><span class="no">Not verified:</span> text→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→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);
|