From 1a19cbb90ae42f161cdddac2c4d620c62f8eb4df Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:13:38 +0200 Subject: [PATCH 01/87] =?UTF-8?q?feat(tools):=20Layer=20Cutter=20=E2=80=94?= =?UTF-8?q?=20browser=20tool=20to=20cut=20a=20drawing=20into=20clean=20tra?= =?UTF-8?q?nsparent=20part-layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-file HTML (no deps). Load image → white-knockout / magic-wand / brush / eraser / lasso to mask each part → grow/shrink/feather to tidy soft pencil edges → set a pivot per part → export a .zip of tight-cropped transparent PNGs (base with mover-holes punched) + rig.json in SuperXRigScreen v2 format (x,y,size,rot,pivX,pivY). Touch-first (pinch-zoom/pan); verified in-browser end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 539 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 tools/layer_cutter.html diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html new file mode 100644 index 0000000..db48350 --- /dev/null +++ b/tools/layer_cutter.html @@ -0,0 +1,539 @@ + + + + + +✂️ KMPMedia Layer Cutter + + + +
+

✂️ Layer Cutter

+ — no image — + + + +
+ +
+
+ +
+
+
🖼️
+
Open an image
+
Tap to pick from Photos or Files (any attachment)
· or paste a copied image · or drag one in ·
+
+
+
+ + +
+ +
+ + + + From 4b3a99513644b753040925b6442fafb6d21e4931 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:43:04 +0200 Subject: [PATCH 02/87] =?UTF-8?q?feat(tools):=20Layer=20Cutter=20=E2=80=94?= =?UTF-8?q?=20undo/redo=20history=20+=20wand=20add/remove=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Real undo/redo stacks (was single-level undo); ⌘Z / ⇧⌘Z + header ↩︎/↪︎ buttons. - Wand ✚adds / ✖removes toggle so a wrong flood is reverted directly, not only via history. - New action forks history (clears redo). Verified in-browser. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 43 ++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index db48350..7e8c6b5 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -77,6 +77,8 @@

✂️ Layer Cutter

— no image — + + @@ -109,8 +111,12 @@

Tool

+
+ + +
-

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected for the active layer. Pivot = the joint the part rotates around.

+

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -129,6 +135,7 @@

Clean up the selection

+

Knock-out white flood-fills the connected white background and keeps everything else — the fast first cut. Then Brush/Erase to fix, Grow to catch the soft pencil edge, Shrink/Feather to tidy.

@@ -173,9 +180,9 @@

Export

img:null, iw:0, ih:0, src:null, srcData:null, // native-res source canvas + cached pixel data layers:[], active:-1, - tool:'wand', brush:40, tol:40, bg:235, + tool:'wand', brush:40, tol:40, bg:235, wandSub:false, view:{zoom:1, x:0, y:0}, - undo:[], // {id, data:ImageData} + undo:[], redo:[], // history stacks: {id, data:ImageData} lasso:null, // in-progress lasso points (image coords) pointers:new Map(), pinch:null, lastPan:null, cursor:null, }; @@ -230,7 +237,7 @@

Export

S.src=document.createElement('canvas'); S.src.width=S.iw; S.src.height=S.ih; const sctx=S.src.getContext('2d', {willReadFrequently:true}); sctx.drawImage(img,0,0); S.srcData=sctx.getImageData(0,0,S.iw,S.ih); - S.layers=[]; S.active=-1; S.undo=[]; nextId=1; + S.layers=[]; S.active=-1; S.undo=[]; S.redo=[]; nextId=1; S.layers.push(makeLayer('fig','base')); S.layers.push(makeLayer('arm_r','mover')); S.active=1; @@ -298,11 +305,29 @@

Export

} /* ---------- mask editing (canvas-op based) ---------- */ +function snap(l){ return {id:l.id, data:l.mctx.getImageData(0,0,S.iw,S.ih)}; } function pushUndo(){ const l=activeLayer(); if(!l)return; - S.undo.push({id:l.id, data:l.mctx.getImageData(0,0,S.iw,S.ih)}); - if(S.undo.length>24) S.undo.shift(); } -document.getElementById('undo').onclick=()=>{ const u=S.undo.pop(); if(!u){toast('Nothing to undo');return;} - const l=S.layers.find(x=>x.id===u.id); if(l){ l.mctx.putImageData(u.data,0,0); draw(); } }; + S.undo.push(snap(l)); if(S.undo.length>40) S.undo.shift(); + S.redo.length=0; } // a new action forks history → clear redo +function restoreFrom(fromStack, toStack, verb){ + const u=fromStack.pop(); if(!u){ toast('Nothing to '+verb); return; } + const l=S.layers.find(x=>x.id===u.id); if(!l){ toast('Layer gone'); return; } + toStack.push(snap(l)); // save current so the inverse can bring it back + l.mctx.putImageData(u.data,0,0); S.active=S.layers.indexOf(l); renderLayerList(); draw(); toast(verb==='undo'?'Undo':'Redo'); +} +function doUndo(){ restoreFrom(S.undo, S.redo, 'undo'); } +function doRedo(){ restoreFrom(S.redo, S.undo, 'redo'); } +document.getElementById('undo').onclick=doUndo; +document.getElementById('redo').onclick=doRedo; +document.getElementById('hUndo').onclick=doUndo; +document.getElementById('hRedo').onclick=doRedo; +// wand add/remove mode (so a wrong flood is reverted directly, not only via history) +document.querySelectorAll('#wandMode button').forEach(b=>b.onclick=()=>{ + S.wandSub = b.dataset.wm==='sub'; + document.querySelectorAll('#wandMode button').forEach(x=>x.classList.toggle('on', x===b)); }); +// keyboard: ⌘/Ctrl+Z undo, ⇧⌘/Ctrl+Z or Ctrl+Y redo +window.addEventListener('keydown', e=>{ const mod=e.metaKey||e.ctrlKey; if(!mod)return; const k=e.key.toLowerCase(); + if(k==='z'){ e.preventDefault(); e.shiftKey?doRedo():doUndo(); } else if(k==='y'){ e.preventDefault(); doRedo(); } }); function paintDab(ix,iy,erase){ const l=activeLayer(); if(!l)return; const m=l.mctx; m.globalCompositeOperation = erase?'destination-out':'source-over'; @@ -390,7 +415,7 @@

Export

mid:{x:(pts[0].x+pts[1].x)/2,y:(pts[0].y+pts[1].y)/2}, zoom:S.view.zoom, vx:S.view.x, vy:S.view.y }; return; } const sp=evPos(e); const ip=s2i(sp.x,sp.y); if(S.tool==='pivot'){ const l=activeLayer(); if(l){ l.pivot={x:Math.round(ip.x),y:Math.round(ip.y)}; draw(); } return; } - if(S.tool==='wand'){ wandAt(ip.x,ip.y,false); return; } + if(S.tool==='wand'){ wandAt(ip.x,ip.y, S.wandSub); return; } if(S.tool==='lasso'){ S.lasso=[ip]; drawing=true; return; } // brush / erase drawing=true; pushUndo(); paintDab(ip.x,ip.y, S.tool==='erase'); lastImg=ip; draw(); From 1ffa63fcb642d5da16436933fb058a8ae7e222e4 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:20:19 +0200 Subject: [PATCH 03/87] feat(tools): Layer Cutter emits nexum rig-payload (rig + layer images) to app host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds '📡 Send to app (nexum)' → builds an og-rig-payload {rig, images:{name→dataURL}} and delivers it to an attached host: Android WebView bridge (window.NexumHost.receivePayload), ReactNative, or parent postMessage; falls back to saving rig_payload.json when standalone. Exposed window.__lc.buildNexumPayload for the host. Verified in-browser. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 7e8c6b5..966d0cd 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -155,14 +155,15 @@

Layers (back → front)

Export

- +
- - + + +
-

The zip has one transparent PNG per layer (named after the layer), the base with the mover-holes punched, and rig.json with each part's pivot + placement — exactly what the SUPER X puppet reads.

+

Send to app hands the layers + rig straight to the app over nexum (I bake the moving puppet, no files to pass around). Export .zip is the manual route: one transparent PNG per layer, the base with mover-holes punched, and rig.json.

@@ -515,6 +516,24 @@

Export

if(ta.style.display==='none'){ const {jsonStr}=await buildExport(); ta.value=jsonStr; ta.style.display='block'; } else ta.style.display='none'; }; +/* ---------- nexum payload: rig + the layer images, for the app host to bake ---------- */ +async function buildNexumPayload(){ + const {files, jsonStr}=await buildExport(); const images={}; + for(const f of files){ if(f.name.endsWith('.png')){ const b=new Blob([f.data],{type:'image/png'}); + images[f.name]=await new Promise(r=>{const fr=new FileReader();fr.onload=()=>r(fr.result);fr.readAsDataURL(b);}); } } + return { kind:'og-rig-payload', v:1, rig:JSON.parse(jsonStr), images }; +} +// Deliver to the app host if one is attached (Android WebView bridge / RN / iframe), else save the payload file. +async function sendToNexum(){ if(!S.img){toast('Load an image first');return;} + toast('Packing layers…'); const str=JSON.stringify(await buildNexumPayload()); + if(window.NexumHost && window.NexumHost.receivePayload){ window.NexumHost.receivePayload(str); toast('Sent to app ✓'); return; } + if(window.ReactNativeWebView && window.ReactNativeWebView.postMessage){ window.ReactNativeWebView.postMessage(str); toast('Sent to app ✓'); return; } + if(window.parent && window.parent!==window){ window.parent.postMessage(str,'*'); toast('Sent to host ✓'); return; } + const blob=new Blob([str],{type:'application/json'}); const a=document.createElement('a'); + a.href=URL.createObjectURL(blob); a.download='rig_payload.json'; document.body.appendChild(a); a.click(); a.remove(); + setTimeout(()=>URL.revokeObjectURL(a.href),4000); toast('No app host — saved rig_payload.json'); } +document.getElementById('toNexum').onclick=sendToNexum; + /* preview active layer isolated */ document.getElementById('previewBtn').onclick=async()=>{ const l=activeLayer(); if(!l)return; const W=S.iw,H=S.ih, sd=S.srcData.data; const md=l.mctx.getImageData(0,0,W,H); const box=bboxOf(md); @@ -558,7 +577,7 @@

Export

window.addEventListener('resize', resizeCanvas); resizeCanvas(); // debug hook for automated verification -window.__lc = { S, loadImage, buildExport, wandAt, zipStore }; +window.__lc = { S, loadImage, buildExport, buildNexumPayload, wandAt, zipStore }; From 1c33e1d7abbfe344350d269ea52aa2e8a4edca5b Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:31:35 +0200 Subject: [PATCH 04/87] =?UTF-8?q?feat(stage):=20cut-out=20puppet=20nexum?= =?UTF-8?q?=20plugin=20=E2=80=94=20OGCutoutRig=20bake=20+=20capability=20+?= =?UTF-8?q?=20cutter=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lib (com.solidkey.painpoints.stage): - OGCutoutRig / OGCutoutLayer + OGCutoutRigs codec: a base image + mover part-images, each with a joint pivot (fraction of canvas). The data a cut-out puppet needs. - OGOfflineCutoutRigAuthor: deterministic rig → looping OGStage (base still; movers swing about their joints, desynced, tappable). The honest on-device floor. - OGCutoutRigCapability (id="cutoutRig"): first-class OGAiCapability for the manifest next to illustrate; agent authors the OGStage, floor bakes when no AI. + bakeCutoutRig() helper. - Joint rotation with NO lib render change: mover frames are pivot-centred, so the stage's centre-rotation swings about the joint; x/y offset by (pivot-0.5) seats it. - 7 JVM tests green (:library:testDebugUnitTest). Cutter: 'Send to app' now emits an og-rig-payload in OGCutoutRig shape — full-canvas base (mover holes punched) + full-canvas pivot-centred mover frames. Verified in-browser: the joint maps exactly to canvas centre, pivot fractions + shape match the lib. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../solidkey/painpoints/stage/OGCutoutRig.kt | 137 ++++++++++++++++++ .../painpoints/stage/OGCutoutRigTest.kt | 104 +++++++++++++ tools/layer_cutter.html | 41 +++++- 3 files changed, 277 insertions(+), 5 deletions(-) create mode 100644 library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt create mode 100644 library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt new file mode 100644 index 0000000..27ab4f4 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt @@ -0,0 +1,137 @@ +package com.solidkey.painpoints.stage + +import com.solidkey.painpoints.ai.OGAiCapability +import com.solidkey.painpoints.ai.OGAiVector +import com.solidkey.painpoints.compositor.OGEasing +import com.solidkey.painpoints.compositor.OGKeyframedFloat +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * A **cut-out puppet rig**: a base image plus mover part-images, each carrying a joint [OGCutoutLayer.pivotX]/ + * [OGCutoutLayer.pivotY] (a fraction of the canvas). This is what the *Layer Cutter* tool produces once a + * drawing is separated into clean transparent part-layers — the data half of "a flat drawing becomes a + * movable puppet". + * + * ### How a limb swings about its real joint with the plain stage transform + * [OGStageLayer] rotates about the layer **centre** (there is no per-layer transform origin). So the mover + * frames are authored **pivot-centred** — the part is drawn on a full-canvas frame with its joint at the + * frame centre. Then [OGOfflineCutoutRigAuthor] rotates the layer about its centre (= the joint) and + * *afterwards* translates the joint to where it belongs ([OGStageView] applies `rotation` about centre, then + * `translation`). A rigid post-rotation translate keeps the swing anchored at the joint. No library render + * change is needed — the whole geometry lives in the frame + the x/y offset here. + */ +@Serializable +public data class OGCutoutRig( + public val width: Int = 1080, + public val height: Int = 1080, + public val durationMs: Long = 2600L, + public val fps: Int = 60, + /** Optional base coat behind every layer (SVG colour string); `null` = transparent. */ + public val background: String? = null, + public val layers: List = emptyList(), +) + +/** One part of a [OGCutoutRig]: the drawn [resource], its [role], and the joint it turns around. */ +@Serializable +public data class OGCutoutLayer( + public val id: String, + /** Bundled/registered image name the player resolves (defaults to [id]). */ + public val resource: String = id, + /** `"base"` (stays joined, still) or `"mover"` (swings about its joint). */ + public val role: String = "mover", + /** Joint X as a fraction of the canvas width (the mover frame is authored with the joint at centre). */ + public val pivotX: Float = 0.5f, + /** Joint Y as a fraction of the canvas height. */ + public val pivotY: Float = 0.5f, + /** Mover swing amplitude in degrees (ignored for a base). */ + public val swingDeg: Float = 9f, +) { + public val isBase: Boolean get() = role.equals("base", ignoreCase = true) +} + +/** JSON codec for [OGCutoutRig], reusing the lenient AI codec (tolerates fences/prose, case-insensitive). */ +public object OGCutoutRigs { + public val json: Json = OGAiVector.json + public fun encode(rig: OGCutoutRig): String = json.encodeToString(OGCutoutRig.serializer(), rig) + public fun decode(text: String): OGCutoutRig = + json.decodeFromString(OGCutoutRig.serializer(), OGAiVector.extractJson(text)) + public fun decodeOrNull(text: String): OGCutoutRig? = runCatching { decode(text) }.getOrNull() +} + +/** + * The **deterministic floor**: a cut-out rig → a looping [OGStage] puppet, with no AI and no network. The + * base sits still; each mover swings gently about its joint, desynced so the figure reads as alive. This is + * the honest fallback under [OGCutoutRigCapability] — the plugin always produces *something* movable even + * when no agent answers. Mirrors `OGOfflineStageAuthor`'s build-from-[OGStages]-builders style. + */ +public class OGOfflineCutoutRigAuthor { + public fun bake(rig: OGCutoutRig): OGStage { + val w = rig.width.coerceAtLeast(1) + val h = rig.height.coerceAtLeast(1) + val dur = rig.durationMs.coerceAtLeast(400L) + var moverIndex = 0 + val layers = rig.layers.map { l -> + if (l.isBase) { + OGStages.imageStill(id = l.id, resource = l.resource, widthFrac = 1f, heightFrac = 1f) + } else { + // Flip the lead direction and nudge amplitude per mover so limbs don't move in lockstep. + val sign = if (moverIndex % 2 == 0) 1f else -1f + val amp = l.swingDeg * (0.8f + 0.2f * (moverIndex % 3)) + moverIndex++ + OGStages.imageStill( + id = l.id, resource = l.resource, widthFrac = 1f, heightFrac = 1f, + x = OGKeyframedFloat.const(l.pivotX - 0.5f), // move the centred joint to its spot… + y = OGKeyframedFloat.const(l.pivotY - 0.5f), + rotationDeg = loopSwing(dur, amp * sign), // …then swing about it (rotation-first) + onTap = OGStages.tap(OGTapAction.BOUNCE), // tappable: poke a limb to react + ) + } + } + return OGStage.of( + *layers.toTypedArray(), + width = w, height = h, durationMs = dur, fps = rig.fps, background = rig.background, + ) + } + + /** rest → +amp → rest → −amp → rest across one loop, eased so it reverses smoothly at the extremes. */ + private fun loopSwing(dur: Long, amp: Float): OGKeyframedFloat { + val q = dur / 4 + return OGStages.eased( + OGEasing.EASE_IN_OUT, + 0L to 0f, q to amp, 2 * q to 0f, 3 * q to -amp, dur to 0f, + ) + } +} + +/** + * The **"cut-out puppet" capability** — a first-class [OGAiCapability] for the manifest, next to + * `OGStageAuthoring.illustrate`. The app sends an [OGCutoutRig]; a subscribed agent (over your nexum + * channel) returns a moving [OGStage], or [OGOfflineCutoutRigAuthor] does it on-device. Compose it into a + * manifest with `OGAiManifest.of(app, listOf(OGStageAuthoring.illustrate, OGCutoutRigCapability))`. + */ +public val OGCutoutRigCapability: OGAiCapability = + object : OGAiCapability { + override val id: String = "cutoutRig" + override val title: String = "Cut-out puppet" + override val description: String = + "Turn a cut-out rig (a base image plus mover part-images, each with a joint pivot given as a " + + "fraction of the canvas) into a looping OGStage puppet whose parts swing about their joints. " + + "Each mover frame is already pivot-centred, so animate its rotationDeg about the layer centre " + + "and offset its x/y by (pivot − 0.5) to seat the joint." + override val outputFormat: String = "one OGStage JSON object" + override fun promptFor(input: OGCutoutRig): String = + OGStages.stagePrompt( + "Author a lively looping puppet from this cut-out rig. The base layer stays still and " + + "joined; every mover swings gently about its joint, desynced. Rig:\n" + + OGCutoutRigs.encode(input), + ) + override fun decode(reply: String): OGStage? = OGStages.decodeOrNull(reply) + } + +/** Agent-first helper: use the agent's [agentReply] OGStage if it parses, else the deterministic floor. */ +public fun bakeCutoutRig( + rig: OGCutoutRig, + agentReply: String? = null, + floor: OGOfflineCutoutRigAuthor = OGOfflineCutoutRigAuthor(), +): OGStage = agentReply?.let { OGStages.decodeOrNull(it) } ?: floor.bake(rig) diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt new file mode 100644 index 0000000..77e4947 --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt @@ -0,0 +1,104 @@ +package com.solidkey.painpoints.stage + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Tests for the cut-out puppet rig → [OGStage] bake (the nexum "cutoutRig" capability + its offline floor). */ +class OGCutoutRigTest { + + private fun superxRig() = OGCutoutRig( + width = 1024, height = 572, durationMs = 2600L, fps = 60, + layers = listOf( + OGCutoutLayer(id = "fig", role = "base"), + OGCutoutLayer(id = "arm_r", role = "mover", pivotX = 0.43f, pivotY = 0.26f, swingDeg = 12f), + OGCutoutLayer(id = "leg_l", role = "mover", pivotX = 0.47f, pivotY = 0.70f, swingDeg = 8f), + ), + ) + + @Test + fun rig_round_trips_through_codec() { + val rig = superxRig() + val decoded = OGCutoutRigs.decode(OGCutoutRigs.encode(rig)) + assertEquals(rig, decoded) + } + + @Test + fun codec_is_lenient_about_prose_and_role_case() { + // extractJson strips the prose + fences; isBase ignores the role's letter-case. + val wrapped = "Here is the rig:\n```json\n" + + "{\"width\":100,\"height\":100,\"layers\":[{\"id\":\"b\",\"role\":\"BASE\"}]}\n```" + val rig = OGCutoutRigs.decodeOrNull(wrapped) + assertNotNull(rig) + assertEquals(1, rig.layers.size) + assertTrue(rig.layers[0].isBase) + } + + @Test + fun bake_produces_a_layer_per_part_with_matching_canvas() { + val stage = OGOfflineCutoutRigAuthor().bake(superxRig()) + assertEquals(3, stage.layers.size) + assertEquals(1024, stage.width) + assertEquals(572, stage.height) + assertEquals(2600L, stage.durationMs) + assertEquals(listOf("fig", "arm_r", "leg_l"), stage.layers.map { it.id }) + } + + @Test + fun base_stays_still_and_movers_swing_about_their_seated_joint() { + val stage = OGOfflineCutoutRigAuthor().bake(superxRig()) + val base = stage.layers.first { it.id == "fig" } + val arm = stage.layers.first { it.id == "arm_r" } + + // Base: no motion, seated at the centre (full-bleed). + assertTrue(base.rotationDeg.keyframes.isEmpty(), "base must not rotate") + assertEquals(0f, base.x.valueAt(0L)) + assertEquals(0f, base.y.valueAt(0L)) + + // Mover: the centred joint is translated to (pivot − 0.5), and it rotates. + assertEquals(0.43f - 0.5f, arm.x.valueAt(0L), 1e-4f) + assertEquals(0.26f - 0.5f, arm.y.valueAt(0L), 1e-4f) + assertTrue(arm.rotationDeg.keyframes.isNotEmpty(), "a mover must swing") + // rest at the loop ends, off-rest somewhere in the middle + assertEquals(0f, arm.rotationDeg.valueAt(0L), 1e-3f) + assertEquals(0f, arm.rotationDeg.valueAt(2600L), 1e-3f) + val mid = (0L..2600L step 130L).maxOf { abs(arm.rotationDeg.valueAt(it)) } + assertTrue(mid > 1f, "the arm should visibly swing (peak was $mid deg)") + } + + @Test + fun baked_stage_is_live_and_movers_are_tappable() { + val stage = OGOfflineCutoutRigAuthor().bake(superxRig()) + assertTrue(stage.isLive, "a puppet with swinging limbs must be live") + assertNotNull(stage.layers.first { it.id == "arm_r" }.onTap, "movers should be pokeable") + } + + @Test + fun capability_prompt_carries_the_rig_and_decodes_a_stage() { + val rig = superxRig() + assertEquals("cutoutRig", OGCutoutRigCapability.id) + val prompt = OGCutoutRigCapability.promptFor(rig) + assertTrue(prompt.contains("arm_r"), "prompt should embed the rig") + // a real OGStage reply decodes… + val baked = OGOfflineCutoutRigAuthor().bake(rig) + val reply = "Sure, here you go:\n" + OGStages.encode(baked) + val decoded = OGCutoutRigCapability.decode(reply) + assertNotNull(decoded) + assertEquals(baked.layers.size, decoded.layers.size) + } + + @Test + fun resilient_helper_prefers_agent_then_floor() { + val rig = superxRig() + // agent gives junk → floor bakes + val viaFloor = bakeCutoutRig(rig, agentReply = "sorry, no idea") + assertEquals(3, viaFloor.layers.size) + // agent gives a valid stage → used as-is + val agentStage = OGStage.of(OGStages.solid("bg", "#000000"), width = 10, height = 10) + val viaAgent = bakeCutoutRig(rig, agentReply = OGStages.encode(agentStage)) + assertEquals(1, viaAgent.layers.size) + assertEquals(10, viaAgent.width) + } +} diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 966d0cd..75d9f4f 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -516,12 +516,43 @@

Export

if(ta.style.display==='none'){ const {jsonStr}=await buildExport(); ta.value=jsonStr; ta.style.display='block'; } else ta.style.display='none'; }; -/* ---------- nexum payload: rig + the layer images, for the app host to bake ---------- */ +/* ---------- nexum payload: an OGCutoutRig + the layer images, for the app host to bake ---------- + The library's OGStageLayer rotates about the layer CENTRE, so each mover frame is drawn full-canvas + with its JOINT shifted to the centre — then the bake rotates about centre (= the joint) and offsets + x/y by (pivot − 0.5) to seat it. Base is full-canvas with the mover shapes punched out. */ +function fullCanvasDataURL(maskData, offsetX, offsetY, punchMasks){ + const W=S.iw,H=S.ih, sd=S.srcData.data, md=maskData.data; + const oc=document.createElement('canvas'); oc.width=W; oc.height=H; const octx=oc.getContext('2d'); + const out=octx.createImageData(W,H); const od=out.data; + for(let dy=0;dy=W||sy>=H) continue; + const si=(sy*W+sx)*4; let a=md[si+3]/255; if(!a) continue; + if(punchMasks) for(const mv of punchMasks) a*=(1-mv[si+3]/255); + const oi=(dy*W+dx)*4; od[oi]=sd[si]; od[oi+1]=sd[si+1]; od[oi+2]=sd[si+2]; od[oi+3]=Math.round(sd[si+3]*a); + } + octx.putImageData(out,0,0); return oc.toDataURL('image/png'); +} async function buildNexumPayload(){ - const {files, jsonStr}=await buildExport(); const images={}; - for(const f of files){ if(f.name.endsWith('.png')){ const b=new Blob([f.data],{type:'image/png'}); - images[f.name]=await new Promise(r=>{const fr=new FileReader();fr.onload=()=>r(fr.result);fr.readAsDataURL(b);}); } } - return { kind:'og-rig-payload', v:1, rig:JSON.parse(jsonStr), images }; + if(!S.srcData) return null; const W=S.iw,H=S.ih; + const punchMasks=[]; // movers (with punch on) to cut out of the base + for(const l of S.layers){ if(l.role==='mover' && l.punch){ const md=l.mctx.getImageData(0,0,W,H); + if(bboxOf(md)) punchMasks.push(md.data); } } + const images={}, rigLayers=[]; + for(const l of S.layers){ const md=l.mctx.getImageData(0,0,W,H); const box=bboxOf(md); if(!box) continue; + const name=sanitize(l.name); + if(l.role==='base'){ + images[name]=fullCanvasDataURL(md, 0, 0, punchMasks); + rigLayers.push({ id:name, resource:name, role:'base', pivotX:0.5, pivotY:0.5 }); + } else { + const pv=l.pivot || {x:box.x+box.w/2, y:box.y+box.h/2}; + const offX=Math.round(W/2 - pv.x), offY=Math.round(H/2 - pv.y); + images[name]=fullCanvasDataURL(md, offX, offY, null); + rigLayers.push({ id:name, resource:name, role:'mover', + pivotX:+(pv.x/W).toFixed(4), pivotY:+(pv.y/H).toFixed(4), swingDeg:10 }); + } + } + const rig={ width:W, height:H, durationMs:2600, fps:60, layers:rigLayers }; + return { kind:'og-rig-payload', v:1, rig, images }; } // Deliver to the app host if one is attached (Android WebView bridge / RN / iframe), else save the payload file. async function sendToNexum(){ if(!S.img){toast('Load an image first');return;} From d05d193de447edac1536a8573bf409fe55e760ea Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:07:24 +0200 Subject: [PATCH 05/87] =?UTF-8?q?feat(tools):=20Layer=20Cutter=20live=20'P?= =?UTF-8?q?review=20motion'=20=E2=80=94=20assemble=20+=20watch=20the=20pup?= =?UTF-8?q?pet=20move?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ▶ Preview motion: composites base (still) + movers (swing about their pinned pivot) on a dark stage via requestAnimationFrame, so you verify joints + see the WHOLE figure assemble before sending. Editing gated during preview (pan/zoom only); un-pinned movers flagged. Guidance hint updated: cut movers OFF the body, pin each joint. Verified in-browser (motion advances, cuts built, pivot respected, no console errors). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 50 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 75d9f4f..24d7bfa 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -147,8 +147,9 @@

Layers (back → front)

+
-

base = the body that stays joined. mover = a part you'll animate; tick “punch” so its shape is cut out of the base (no double image). Set each mover's 🎯 pivot at its joint.

+

base = the body, stays still & joined. mover = a limb you'll animate — cut it OFF the body itself (not a separate study, or it won't line up), tick “punch” so its shape is removed from the base, and pin its 🎯 joint (shoulder/hip/neck). Then ▶ Preview motion to watch the whole figure assemble + swing.

@@ -186,6 +187,7 @@

Export

undo:[], redo:[], // history stacks: {id, data:ImageData} lasso:null, // in-progress lasso points (image coords) pointers:new Map(), pinch:null, lastPan:null, cursor:null, + preview:false, previewT:0, // live assembled-motion preview }; let nextId = 1; @@ -275,10 +277,53 @@

Export

function s2i(sx,sy){ return {x:(sx-S.view.x)/S.view.zoom, y:(sy-S.view.y)/S.view.zoom}; } /* ---------- draw ---------- */ +/* ---------- live assembled-motion preview: base still, movers swing about their pinned joint ---------- */ +function buildCut(l){ + if(l._cut!==undefined && l._cut!==null) return l._cut; + const W=S.iw,H=S.ih,sd=S.srcData.data; const md=l.mctx.getImageData(0,0,W,H).data; + let any=false; for(let p=3;p8){ any=true; break; } } + if(!any){ l._cut=null; return null; } + const c=document.createElement('canvas'); c.width=W; c.height=H; const cx=c.getContext('2d'); + const out=cx.createImageData(W,H); const od=out.data; + for(let p=0;pl.role==='mover'&&!l.pivot)){ ctx.fillStyle='#ffd166'; ctx.font='13px sans-serif'; + ctx.fillText('⚠ pin a 🎯 joint on each mover so it swings', 12, H-14); } +} +let previewRAF=0, previewStart=0; +function setPreview(on){ + S.preview=on; const btn=document.getElementById('preview'); + if(on){ if(!S.img){ toast('Load + cut some parts first'); S.preview=false; return; } + S.layers.forEach(l=>{ l._cut=null; }); + S.layers.filter(l=>l.role==='mover').forEach((l,i)=>{ l._phase=i*1.3; }); + if(btn){ btn.classList.add('on'); btn.textContent='■ Stop preview'; } + previewStart=0; const loop=(ts)=>{ if(!S.preview) return; if(!previewStart) previewStart=ts; + S.previewT=(ts-previewStart)/1000; draw(); previewRAF=requestAnimationFrame(loop); }; + previewRAF=requestAnimationFrame(loop); toast('Preview — base still, movers swing at their joint'); + } else { if(previewRAF) cancelAnimationFrame(previewRAF); previewRAF=0; + if(btn){ btn.classList.remove('on'); btn.textContent='▶ Preview motion'; } + S.layers.forEach(l=>{ l._cut=null; }); draw(); } +} + function draw(){ const dpr=window.devicePixelRatio||1; const W=cv.width/dpr, H=cv.height/dpr; ctx.clearRect(0,0,W,H); if(!S.img) return; + if(S.preview){ renderPreview(W,H); return; } const v=S.view; ctx.save(); ctx.translate(v.x,v.y); ctx.scale(v.zoom,v.zoom); @@ -414,6 +459,7 @@

Export

if(S.pointers.size===2){ drawing=false; S.lasso=null; const pts=[...S.pointers.values()]; S.pinch={ d:Math.hypot(pts[0].x-pts[1].x,pts[0].y-pts[1].y), mid:{x:(pts[0].x+pts[1].x)/2,y:(pts[0].y+pts[1].y)/2}, zoom:S.view.zoom, vx:S.view.x, vy:S.view.y }; return; } + if(S.preview) return; // preview is play-only; single-finger does nothing const sp=evPos(e); const ip=s2i(sp.x,sp.y); if(S.tool==='pivot'){ const l=activeLayer(); if(l){ l.pivot={x:Math.round(ip.x),y:Math.round(ip.y)}; draw(); } return; } if(S.tool==='wand'){ wandAt(ip.x,ip.y, S.wandSub); return; } @@ -433,6 +479,7 @@

Export

S.view.x = mid.x - (S.pinch.mid.x - S.pinch.vx)*(nz/S.pinch.zoom); S.view.y = mid.y - (S.pinch.mid.y - S.pinch.vy)*(nz/S.pinch.zoom); draw(); return; } + if(S.preview) return; if(!drawing){ if(S.tool==='brush'||S.tool==='erase')draw(); return; } const ip=s2i(sp.x,sp.y); if(S.tool==='lasso'){ S.lasso.push(ip); draw(); return; } @@ -466,6 +513,7 @@

Export

bgEl.oninput=()=>{S.bg=+bgEl.value; document.getElementById('bgVal').textContent=bgEl.value;}; document.getElementById('addLayer').onclick=()=>{ if(!S.img){toast('Load an image first');return;} S.layers.push(makeLayer('part_'+(S.layers.length), 'mover')); S.active=S.layers.length-1; renderLayerList(); draw(); }; +document.getElementById('preview').onclick=()=>setPreview(!S.preview); /* ---------- bbox of a layer's alpha ---------- */ function bboxOf(data){ const W=S.iw,H=S.ih,d=data.data; let minx=W,miny=H,maxx=-1,maxy=-1; From 4aba26c8137e63f9b378e963a81890cef4b2c21e Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:07:07 +0200 Subject: [PATCH 06/87] =?UTF-8?q?feat(tools):=20Layer=20Cutter=20=E2=80=94?= =?UTF-8?q?=20save/open=20an=20ongoing=20cut=20(project=20file=20+=20auto-?= =?UTF-8?q?resume)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full session persistence so a cut can be stopped and continued: - 💾 Save: serialize image + every layer (mask PNG, role, punch, visibility, colour, pinned joint, swingDeg) to a .ogcut.json you can reopen. - 📂 Open: restore the whole project and keep cutting. - Best-effort autosave to localStorage (debounced, quota-safe) + a '↩︎ Resume last cut' button on the empty screen after an accidental reload. Verified in-browser: serialize→restore round-trip is pixel-exact (mask px, pivot, roles), reload→resume works, no console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 85 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 24d7bfa..347070d 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -80,7 +80,10 @@

✂️ Layer Cutter

+ + +
@@ -91,6 +94,7 @@

✂️ Layer Cutter

🖼️
Open an image
Tap to pick from Photos or Files (any attachment)
· or paste a copied image · or drag one in ·
+
@@ -267,6 +271,83 @@

Export

['dragleave','drop'].forEach(ev=>stageWrap.addEventListener(ev,e=>{ e.preventDefault(); emptyEl.classList.remove('drag'); })); stageWrap.addEventListener('drop',e=>{ const f=e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if(f)loadFile(f); }); +/* ---------- save / reopen an ongoing cut (full project: image + layers + masks + joints) ---------- */ +const AUTOSAVE_KEY='ogcut:autosave:v1'; +function serializeProject(){ + return { kind:'og-cutter-project', v:1, savedAt:new Date().toISOString(), + iw:S.iw, ih:S.ih, bg:S.bg, active:S.active, nextId, + image:S.src.toDataURL('image/png'), + layers:S.layers.map(l=>({ id:l.id, name:l.name, role:l.role, punch:l.punch, + visible:l.visible, color:l.color, pivot:l.pivot||null, swingDeg:l.swingDeg||null, + mask:l.mc.toDataURL('image/png') })) }; +} +function saveProject(){ + if(!S.img){ toast('Load + cut something first'); return; } + const blob=new Blob([JSON.stringify(serializeProject())],{type:'application/json'}); + const a=document.createElement('a'); a.href=URL.createObjectURL(blob); + a.download='layercutter_cut.ogcut.json'; document.body.appendChild(a); a.click(); + setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); },0); + toast('Saved — reopen it with 📂 Open to keep cutting'); +} +function restoreProject(proj, quiet){ + if(!proj || proj.kind!=='og-cutter-project'){ toast('Not a Layer Cutter project'); return; } + const img=new Image(); + img.onload=()=>{ + S.img=img; S.iw=proj.iw||img.naturalWidth; S.ih=proj.ih||img.naturalHeight; + if(typeof proj.bg==='number') S.bg=proj.bg; + S.src=document.createElement('canvas'); S.src.width=S.iw; S.src.height=S.ih; + const sctx=S.src.getContext('2d',{willReadFrequently:true}); sctx.drawImage(img,0,0); + S.srcData=sctx.getImageData(0,0,S.iw,S.ih); + S.layers=[]; S.undo=[]; S.redo=[]; S.preview=false; + const pls=proj.layers||[]; + nextId=Math.max(proj.nextId||1, ...pls.map(l=>(l.id||0)+1), 1); + S.active=Math.max(0, Math.min(proj.active??0, pls.length-1)); + let pending=pls.length; + const finish=()=>{ document.getElementById('dims').textContent=`${S.iw}×${S.ih}`; + document.getElementById('empty').style.display='none'; + fitView(); renderLayerList(); draw(); markDirty(); + if(!quiet) toast('Project opened — pick up where you left off'); }; + if(!pls.length){ finish(); return; } + pls.forEach((pl,idx)=>{ + const mc=document.createElement('canvas'); mc.width=S.iw; mc.height=S.ih; + const mctx=mc.getContext('2d',{willReadFrequently:true}); + const L={ id:pl.id||(idx+1), name:pl.name||('layer'+(idx+1)), role:pl.role||'mover', + punch:pl.punch!==undefined?pl.punch:(pl.role!=='base'), visible:pl.visible!==false, + color:pl.color||PALETTE[idx%PALETTE.length], mc, mctx, pivot:pl.pivot||null }; + if(pl.swingDeg) L.swingDeg=pl.swingDeg; + S.layers[idx]=L; + if(pl.mask){ const mi=new Image(); + mi.onload=()=>{ mctx.drawImage(mi,0,0); if(--pending===0) finish(); }; + mi.onerror=()=>{ if(--pending===0) finish(); }; mi.src=pl.mask; } + else if(--pending===0) finish(); + }); + }; + img.onerror=()=>toast('Project image failed to load'); + img.src=proj.image; +} +function openProjectFile(file){ + if(!file){ return; } + const r=new FileReader(); + r.onload=()=>{ try{ restoreProject(JSON.parse(r.result)); }catch(e){ toast('Could not read that project file'); } }; + r.readAsText(file); +} +/* best-effort autosave to the browser so an accidental reload doesn't lose the cut */ +let autosaveT=0; +function markDirty(){ clearTimeout(autosaveT); autosaveT=setTimeout(()=>{ + if(!S.img) return; + try{ localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(serializeProject())); } + catch(e){ /* quota / private mode — explicit 💾 Save still works */ } }, 2500); } +function hasAutosave(){ try{ return !!localStorage.getItem(AUTOSAVE_KEY); }catch(e){ return false; } } +function resumeAutosave(){ try{ const raw=localStorage.getItem(AUTOSAVE_KEY); + if(raw) restoreProject(JSON.parse(raw)); }catch(e){ toast('No session to resume'); } } + +document.getElementById('saveProj').onclick=saveProject; +document.getElementById('openProj').onclick=()=>document.getElementById('projFile').click(); +document.getElementById('projFile').onchange=e=>openProjectFile(e.target.files[0]); +const resumeBtn=document.getElementById('resumeBtn'); +if(resumeBtn && hasAutosave()){ resumeBtn.style.display='inline-block'; + resumeBtn.onclick=(e)=>{ e.stopPropagation(); resumeAutosave(); }; } + /* ---------- view ---------- */ function resizeCanvas(){ const dpr=window.devicePixelRatio||1; const r=stageWrap.getBoundingClientRect(); cv.width=Math.max(1,Math.round(r.width*dpr)); cv.height=Math.max(1,Math.round(r.height*dpr)); @@ -354,7 +435,7 @@

Export

function snap(l){ return {id:l.id, data:l.mctx.getImageData(0,0,S.iw,S.ih)}; } function pushUndo(){ const l=activeLayer(); if(!l)return; S.undo.push(snap(l)); if(S.undo.length>40) S.undo.shift(); - S.redo.length=0; } // a new action forks history → clear redo + S.redo.length=0; markDirty(); } // a new action forks history → clear redo (+ autosave) function restoreFrom(fromStack, toStack, verb){ const u=fromStack.pop(); if(!u){ toast('Nothing to '+verb); return; } const l=S.layers.find(x=>x.id===u.id); if(!l){ toast('Layer gone'); return; } @@ -461,7 +542,7 @@

Export

mid:{x:(pts[0].x+pts[1].x)/2,y:(pts[0].y+pts[1].y)/2}, zoom:S.view.zoom, vx:S.view.x, vy:S.view.y }; return; } if(S.preview) return; // preview is play-only; single-finger does nothing const sp=evPos(e); const ip=s2i(sp.x,sp.y); - if(S.tool==='pivot'){ const l=activeLayer(); if(l){ l.pivot={x:Math.round(ip.x),y:Math.round(ip.y)}; draw(); } return; } + if(S.tool==='pivot'){ const l=activeLayer(); if(l){ l.pivot={x:Math.round(ip.x),y:Math.round(ip.y)}; draw(); markDirty(); } return; } if(S.tool==='wand'){ wandAt(ip.x,ip.y, S.wandSub); return; } if(S.tool==='lasso'){ S.lasso=[ip]; drawing=true; return; } // brush / erase From c46f927da7981c949e5b554e975da08e74102425 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:16:09 +0200 Subject: [PATCH 07/87] =?UTF-8?q?feat(tools):=20Layer=20Cutter=20=E2=80=94?= =?UTF-8?q?=20z-order=20reorder=20+=20per-mover=20angle/speed=20+=20tuck-t?= =?UTF-8?q?o-body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three requested rig controls: - Front/back ordering: ▲▼ buttons on each layer row (ends disabled). - Per-mover motion: Angle (swingDeg 0–40°) + Speed (0.2–3×) sliders in a new 'Motion (selected mover)' panel; both drive the live ▶ Preview. - Tuck to body: ◀▲▼▶ nudge (tuckX/tuckY px) pulls a part toward the torso so the empty cut area (e.g. behind the right hand) stays hidden. Fields flow into Preview, the nexum rig payload (swingDeg/speed/offsetX/ offsetY per mover), and the save/resume project file. Verified in-browser: reorder, motion store+emit, serialize→restore round-trip, no console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 79 +++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 347070d..d4c4bb5 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -51,7 +51,13 @@ .layer select{background:var(--panel); color:var(--txt); border:1px solid var(--line); border-radius:6px; font-size:11px; padding:3px;} .layer .mini{background:transparent; border:none; min-height:28px; padding:3px 5px; flex:0 0 auto; font-size:14px;} + .mini[disabled]{opacity:.28; pointer-events:none;} .pill{font-size:10px; padding:1px 6px; border-radius:20px; background:#0005; color:var(--dim);} + .mrow{display:flex; align-items:center; gap:8px; margin:6px 0;} + .mrow label{font-size:11px; color:var(--dim); flex:0 0 78px;} + .mrow input[type=range]{flex:1 1 auto; min-width:60px;} + .mrow .mval{font-size:11px; color:var(--txt); flex:0 0 auto; min-width:34px; text-align:right;} + .nudge{display:inline-flex; gap:2px;} .nudge .mini, .mrow .mini{min-height:26px; padding:2px 7px; border:1px solid var(--line); border-radius:6px; background:var(--panel); color:var(--txt);} textarea{width:100%; height:120px; background:#0b0f1e; color:var(--accent2); border:1px solid var(--line); border-radius:8px; font-family:ui-monospace,Menlo,monospace; font-size:10px; padding:8px; resize:vertical;} .toast{position:fixed; left:50%; bottom:18px; transform:translateX(-50%); background:#000d; color:#fff; @@ -153,7 +159,14 @@

Layers (back → front)

-

base = the body, stays still & joined. mover = a limb you'll animate — cut it OFF the body itself (not a separate study, or it won't line up), tick “punch” so its shape is removed from the base, and pin its 🎯 joint (shoulder/hip/neck). Then ▶ Preview motion to watch the whole figure assemble + swing.

+

base = the body, stays still & joined. mover = a limb you'll animate — cut it OFF the body itself (not a separate study, or it won't line up), tick “punch” so its shape is removed from the base, and pin its 🎯 joint (shoulder/hip/neck). Use ▲▼ on a row to order front/back. Then ▶ Preview motion to watch the whole figure assemble + swing.

+ + + +
+

Motion (selected mover)

+
+

Angle = how far it swings, Speed = how fast. Tuck to body nudges the part a few px toward the torso so the empty cut area (e.g. behind the right hand) stays hidden. Changes show live in ▶ Preview.

@@ -207,7 +220,8 @@

Export

const mc = document.createElement('canvas'); mc.width=S.iw; mc.height=S.ih; const color = PALETTE[S.layers.length % PALETTE.length]; return { id:nextId++, name, role:role||'mover', punch:role!=='base', visible:true, color, - mc, mctx:mc.getContext('2d', {willReadFrequently:true}), pivot:null }; + mc, mctx:mc.getContext('2d', {willReadFrequently:true}), pivot:null, + swingDeg:10, speed:1, tuckX:0, tuckY:0 }; // per-mover motion: angle, speed, inward-tuck (px) } function activeLayer(){ return S.layers[S.active] || null; } function renderLayerList(){ @@ -222,18 +236,48 @@

Export

+ + `; row.onclick=(e)=>{ if(e.target.closest('input,select,button'))return; S.active=i; renderLayerList(); draw(); }; - row.querySelector('.nm').onchange=e=>{ l.name=e.target.value; }; - row.querySelector('.role').onchange=e=>{ l.role=e.target.value; l.punch=(l.role!=='base'); renderLayerList(); }; - row.querySelector('.pn').onchange=e=>{ l.punch=e.target.checked; }; - row.querySelector('.vis').onclick=()=>{ l.visible=!l.visible; renderLayerList(); draw(); }; + row.querySelector('.nm').onchange=e=>{ l.name=e.target.value; markDirty(); }; + row.querySelector('.role').onchange=e=>{ l.role=e.target.value; l.punch=(l.role!=='base'); renderLayerList(); markDirty(); }; + row.querySelector('.pn').onchange=e=>{ l.punch=e.target.checked; markDirty(); }; + row.querySelector('.up').onclick=()=>moveLayer(i,+1); + row.querySelector('.dn').onclick=()=>moveLayer(i,-1); + row.querySelector('.vis').onclick=()=>{ l.visible=!l.visible; renderLayerList(); draw(); markDirty(); }; row.querySelector('.del').onclick=()=>{ if(S.layers.length<=1){toast('Keep at least one layer');return;} - S.layers.splice(i,1); S.active=Math.min(S.active, S.layers.length-1); renderLayerList(); draw(); }; + S.layers.splice(i,1); S.active=Math.min(S.active, S.layers.length-1); renderLayerList(); draw(); markDirty(); }; box.appendChild(row); } - const pv = activeLayer()?.pivot; + renderMotion(); +} +// front/back z-order: dir +1 = toward front (higher index = drawn later = on top) +function moveLayer(i, dir){ const j=i+dir; if(j<0||j>=S.layers.length) return; + const activeId=activeLayer()?.id; const [l]=S.layers.splice(i,1); S.layers.splice(j,0,l); + S.active=S.layers.findIndex(x=>x.id===activeId); renderLayerList(); draw(); markDirty(); toast(dir>0?'Brought forward':'Sent back'); } + +// per-mover motion controls (angle / speed / inward-tuck) for the selected mover +function renderMotion(){ + const box=document.getElementById('motion'); if(!box) return; + const l=activeLayer(); + if(!l || l.role!=='mover'){ box.innerHTML='

Select a mover layer to set its swing angle, speed, and nudge it toward the body so the cut gap doesn\'t show.

'; return; } + box.innerHTML=` +
${l.swingDeg}°
+
${(+l.speed).toFixed(1)}×
+
+ + ${l.tuckX|0},${l.tuckY|0}
`; + const upd=()=>{ box.querySelector('.mOff').textContent=`${l.tuckX|0},${l.tuckY|0}`; draw(); markDirty(); }; + box.querySelector('.mAng').oninput=e=>{ l.swingDeg=+e.target.value; box.querySelector('.mAng').nextElementSibling.textContent=l.swingDeg+'°'; draw(); markDirty(); }; + box.querySelector('.mSpd').oninput=e=>{ l.speed=+e.target.value; box.querySelector('.mSpd').nextElementSibling.textContent=l.speed.toFixed(1)+'×'; draw(); markDirty(); }; + const STEP=8; + box.querySelector('.nL').onclick=()=>{ l.tuckX-=STEP; upd(); }; + box.querySelector('.nR').onclick=()=>{ l.tuckX+=STEP; upd(); }; + box.querySelector('.nU').onclick=()=>{ l.tuckY-=STEP; upd(); }; + box.querySelector('.nD').onclick=()=>{ l.tuckY+=STEP; upd(); }; + box.querySelector('.nRst').onclick=()=>{ l.tuckX=0; l.tuckY=0; upd(); }; } /* ---------- image load ---------- */ @@ -278,7 +322,8 @@

Export

iw:S.iw, ih:S.ih, bg:S.bg, active:S.active, nextId, image:S.src.toDataURL('image/png'), layers:S.layers.map(l=>({ id:l.id, name:l.name, role:l.role, punch:l.punch, - visible:l.visible, color:l.color, pivot:l.pivot||null, swingDeg:l.swingDeg||null, + visible:l.visible, color:l.color, pivot:l.pivot||null, + swingDeg:l.swingDeg??10, speed:l.speed??1, tuckX:l.tuckX||0, tuckY:l.tuckY||0, mask:l.mc.toDataURL('image/png') })) }; } function saveProject(){ @@ -313,8 +358,8 @@

Export

const mctx=mc.getContext('2d',{willReadFrequently:true}); const L={ id:pl.id||(idx+1), name:pl.name||('layer'+(idx+1)), role:pl.role||'mover', punch:pl.punch!==undefined?pl.punch:(pl.role!=='base'), visible:pl.visible!==false, - color:pl.color||PALETTE[idx%PALETTE.length], mc, mctx, pivot:pl.pivot||null }; - if(pl.swingDeg) L.swingDeg=pl.swingDeg; + color:pl.color||PALETTE[idx%PALETTE.length], mc, mctx, pivot:pl.pivot||null, + swingDeg:pl.swingDeg??10, speed:pl.speed??1, tuckX:pl.tuckX||0, tuckY:pl.tuckY||0 }; S.layers[idx]=L; if(pl.mask){ const mi=new Image(); mi.onload=()=>{ mctx.drawImage(mi,0,0); if(--pending===0) finish(); }; @@ -374,9 +419,11 @@

Export

ctx.fillStyle='#0f1326'; ctx.fillRect(0,0,W,H); ctx.save(); ctx.translate(S.view.x,S.view.y); ctx.scale(S.view.zoom,S.view.zoom); ctx.imageSmoothingEnabled=true; for(const l of S.layers){ if(!l.visible) continue; const cut=buildCut(l); if(!cut) continue; - if(l.role==='mover' && l.pivot){ const amp=(l.swingDeg||12)*Math.PI/180; - const ang=amp*Math.sin(S.previewT*2.2+(l._phase||0)); - ctx.save(); ctx.translate(l.pivot.x,l.pivot.y); ctx.rotate(ang); ctx.translate(-l.pivot.x,-l.pivot.y); + if(l.role==='mover'){ const tx=l.tuckX||0, ty=l.tuckY||0; + ctx.save(); ctx.translate(tx,ty); // tuck toward body (hide the cut gap) + if(l.pivot){ const amp=(l.swingDeg??10)*Math.PI/180, spd=l.speed??1; + const ang=amp*Math.sin(S.previewT*2.2*spd+(l._phase||0)); + ctx.translate(l.pivot.x,l.pivot.y); ctx.rotate(ang); ctx.translate(-l.pivot.x,-l.pivot.y); } ctx.drawImage(cut,0,0); ctx.restore(); } else ctx.drawImage(cut,0,0); } @@ -677,7 +724,9 @@

Export

const offX=Math.round(W/2 - pv.x), offY=Math.round(H/2 - pv.y); images[name]=fullCanvasDataURL(md, offX, offY, null); rigLayers.push({ id:name, resource:name, role:'mover', - pivotX:+(pv.x/W).toFixed(4), pivotY:+(pv.y/H).toFixed(4), swingDeg:10 }); + pivotX:+(pv.x/W).toFixed(4), pivotY:+(pv.y/H).toFixed(4), + swingDeg:(l.swingDeg??10), speed:+(l.speed??1).toFixed(2), + offsetX:+((l.tuckX||0)/W).toFixed(4), offsetY:+((l.tuckY||0)/H).toFixed(4) }); } } const rig={ width:W, height:H, durationMs:2600, fps:60, layers:rigLayers }; From 6f1ec2cec06dd708415a2d5e0f3054092cdf719e Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:35:28 +0200 Subject: [PATCH 08/87] =?UTF-8?q?feat(cutter):=20Face=20expressions=20mode?= =?UTF-8?q?=20=E2=80=94=20pose=20happy/sad/angry/shocked=20from=20cut=20fe?= =?UTF-8?q?atures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Özge ("build it in the cutter, full"): a second purpose for the Layer Cutter beyond the puppet rig. New "What are you making?" toggle: 🧍 Puppet rig (the existing cut+joints+ swing) vs 😀 Face expressions. In Face mode you cut a face's features once (brows/eyes/mouth as mover layers) and then pose named expressions — Neutral / Happy / Sad / Angry / Shocked — each a per-feature {dx,dy,rot,scale} relative to neutral. Pose by dragging a feature on the canvas (tap to select) or via nudge/Rotate/Scale controls. The assembled face composites live; ▶ Preview expressions morphs through the whole set (demonstrates blending). Poses persist in Save/Open and export into rig.json / the nexum payload as rig.expressions{expr:[{id,dx,dy,rot,scale}]}. Reuses the existing cut layers + buildCut; additive (puppet-rig flow unchanged). Mouth smile↔frown via drawn shapes is the next step (parametric mouth tool). Verified in-browser: mode toggle swaps panels, poses collect + export correctly, morph preview runs, no console errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 198 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 191 insertions(+), 7 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index d4c4bb5..029ba2d 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -106,8 +106,18 @@

✂️ Layer Cutter

@@ -989,7 +989,7 @@

Export

if(ta.style.display==='none'){ const {jsonStr}=await buildExport(); ta.value=jsonStr; ta.style.display='block'; } else ta.style.display='none'; }; -/* ---------- nexum payload: an OGCutoutRig + the layer images, for the app host to bake ---------- +/* ---------- host payload: an OGCutoutRig + the layer images, for the app host to bake ---------- The library's OGStageLayer rotates about the layer CENTRE, so each mover frame is drawn full-canvas with its JOINT shifted to the centre — then the bake rotates about centre (= the joint) and offsets x/y by (pivot − 0.5) to seat it. Base is full-canvas with the mover shapes punched out. */ @@ -1011,7 +1011,7 @@

Export

} octx.putImageData(out,0,0); return oc.toDataURL('image/png'); } -async function buildNexumPayload(){ +async function buildHostPayload(){ if(!S.srcData) return null; const W=S.iw,H=S.ih; const baseKnockKids=S.layers.filter(l=>l.role==='mover' && l.punch && l.parent==null && bboxOf(l.mctx.getImageData(0,0,W,H))); const images={}, rigLayers=[]; @@ -1039,15 +1039,15 @@

Export

return { kind:'og-rig-payload', v:1, rig, images }; } // Deliver to the app host if one is attached (Android WebView bridge / RN / iframe), else save the payload file. -async function sendToNexum(){ if(!S.img){toast('Load an image first');return;} - toast('Packing layers…'); const str=JSON.stringify(await buildNexumPayload()); - if(window.NexumHost && window.NexumHost.receivePayload){ window.NexumHost.receivePayload(str); toast('Sent to app ✓'); return; } +async function sendToHost(){ if(!S.img){toast('Load an image first');return;} + toast('Packing layers…'); const str=JSON.stringify(await buildHostPayload()); + if(window.AppHost && window.AppHost.receivePayload){ window.AppHost.receivePayload(str); toast('Sent to app ✓'); return; } if(window.ReactNativeWebView && window.ReactNativeWebView.postMessage){ window.ReactNativeWebView.postMessage(str); toast('Sent to app ✓'); return; } if(window.parent && window.parent!==window){ window.parent.postMessage(str,'*'); toast('Sent to host ✓'); return; } const blob=new Blob([str],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='rig_payload.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),4000); toast('No app host — saved rig_payload.json'); } -document.getElementById('toNexum').onclick=sendToNexum; +document.getElementById('toApp').onclick=sendToHost; /* preview active layer isolated */ document.getElementById('previewBtn').onclick=async()=>{ const l=activeLayer(); if(!l)return; @@ -1754,7 +1754,7 @@

Export

resizeCanvas(); applyMode(); // debug hook for automated verification -window.__lc = { S, loadImage, buildExport, buildNexumPayload, wandAt, zipStore, applyMode, collectExpressions, poseOf, EXPRS, +window.__lc = { S, loadImage, buildExport, buildHostPayload, wandAt, zipStore, applyMode, collectExpressions, poseOf, EXPRS, draw, ensurePose, renderWarpPanel, renderExprPose, fieldDisp, doUndo, doRedo, migrateAllWarp, mlsRigid, mlsControls, facsPose, facsRole, applyFacs, buildCut, punchKids, splitHoles, skinFillRGB, makeLayer }; From 754554a2f4d47de8bc66959f63372dd3228453ba Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:55:05 +0100 Subject: [PATCH 38/87] feat(cutter): export the OGPuppet pack (puppet.json + union-framed PNGs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alongside the existing rig.json/cropped PNGs, the export now also emits the library's OGPuppet format directly: every part composited onto ONE shared union frame (puppet/.png) + puppet.json (OGPuppetSpec shape — base, parts[] with pivots/swing/speed/tuck re-based to that frame, head flag, aspect). Drops straight into OGPuppet/OGPuppetView — no manual reframing. Geometry validated against the bumblebee rig (union/aspect/pivots match hand-derived values exactly). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 5c0b872..6a86561 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -944,6 +944,7 @@

Export

// unparented punch-movers are cut out of the base (limbs off the torso) const baseKnockKids=S.layers.filter(l=>l.role==='mover'&&l.punch&&l.parent==null); const files=[]; const rig={ rig:'custom', v:2, imageW:W, imageH:H, note:'from KMPMedia Layer Cutter', layers:{} }; + const parts=[]; // collected for the OGPuppet export (union-framed, drop-in for the lib primitive) for(const l of S.layers){ const md=l.mctx.getImageData(0,0,W,H); const box=bboxOf(md); if(!box)continue; const kids=punchKids(l).concat(l.role==='base'?baseKnockKids:[]); // pieces cut OUT of this layer (eye out of head, limbs off torso) @@ -970,13 +971,48 @@

Export

x:f3(pv.x/W), y:f3(pv.y/H), size:f3(box.w/W), rot:0, pivX:f3((pv.x-box.x)/box.w), pivY:f3((pv.y-box.y)/box.h), ...(par?{parent:sanitize(par.name)}:{}) }; + parts.push({ name:sanitize(l.name), box, oc, role:l.role, + swingDeg:+l.swingDeg||0, speed:+l.speed||1, tuckX:l.tuckX||0, tuckY:l.tuckY||0, pv }); } const exprs=collectExpressions(W,H); if(Object.keys(exprs).length) rig.expressions=exprs; const jsonStr=JSON.stringify(rig,null,2); files.push({name:'rig.json', data:new TextEncoder().encode(jsonStr)}); + await addPuppetFiles(files, parts); // also emit the OGPuppet pack (puppet.json + union-framed PNGs) return {files, jsonStr}; } +/* ---------- OGPuppet pack: every part composited onto ONE shared union frame + puppet.json, so the + export drops straight into the library's OGPuppet primitive (OGPuppetSpec / OGPuppetView) with no + reframing. Pivots/tuck are re-based from source pixels to fractions of that shared frame. ---------- */ +async function addPuppetFiles(files, parts){ + if(!parts.length) return; + const base=parts.find(p=>p.role==='base'); if(!base) return; // OGPuppet needs a still base + let minx=1e9,miny=1e9,maxx=-1e9,maxy=-1e9; + for(const p of parts){ const b=p.box; + if(b.xmaxx)maxx=b.x+b.w; if(b.y+b.h>maxy)maxy=b.y+b.h; } + const UW=maxx-minx, UH=maxy-miny; + const head=parts.find(p=>p.name==='head'); + const spec={ base:base.name, aspect:f3(UW/UH), grounded:false, parts:[] }; + if(head){ spec.headCenterX=f3((head.box.x+head.box.w/2-minx)/UW); + spec.headCenterY=f3((head.box.y+head.box.h/2-miny)/UH); } + for(const p of parts){ + // composite this part onto a shared union-sized canvas at its offset → a drop-in sprite + const uc=document.createElement('canvas'); uc.width=UW; uc.height=UH; + uc.getContext('2d').drawImage(p.oc, p.box.x-minx, p.box.y-miny); + const blob=await new Promise(res=>uc.toBlob(res,'image/png')); + files.push({name:'puppet/'+p.name+'.png', data:new Uint8Array(await blob.arrayBuffer())}); + if(p.role==='base') continue; // the base is the still backdrop, not a mover + const part={ res:p.name, pivotX:f3((p.pv.x-minx)/UW), pivotY:f3((p.pv.y-miny)/UH) }; + if(p.swingDeg) part.swingDeg=p.swingDeg; + if(p.speed!==1) part.speed=f3(p.speed); + if(p.tuckX) part.tuckX=f3(p.tuckX/UW); + if(p.tuckY) part.tuckY=f3(p.tuckY/UH); + if(p.name==='head') part.head=true; + spec.parts.push(part); + } + files.push({name:'puppet.json', data:new TextEncoder().encode(JSON.stringify(spec,null,2))}); +} + document.getElementById('export').onclick=async()=>{ if(!S.img){toast('Load an image first');return;} toast('Building zip…'); const {files,jsonStr}=await buildExport(); const zip=zipStore(files); const blob=new Blob([zip],{type:'application/zip'}); From 17bcb47efd145c1e23404643c4a10783549a38e7 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:36:05 +0100 Subject: [PATCH 39/87] =?UTF-8?q?feat(cutter):=20=F0=9F=A9=B9=20Patch=20ba?= =?UTF-8?q?se=20=E2=80=94=20clone-stamp=20+=20auto=20content-aware=20heal?= =?UTF-8?q?=20+=20texture-aware=20skin-fill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Heal the hole a lifted mover leaves so a swung limb reveals real coat/texture, not a flat fill or a gap. Three coordinated, all undoable (↩︎/⌘Z) + persisted: - 🩹 Patch base (clone-stamp tool): tap a source spot, paint over the hole; clones real source pixels (stripes/shading), radially feathered, aligned offset locked per stroke. Auto-clipped to the layer's own silhouette (base alpha = the selection) so you can't smear outside the figure. Paints onto a non-destructive per-layer backfill canvas UNDER the base — never touches the mover — and bakes under the base on export. - ✨ Auto-heal holes: one-tap texture-aware inpaint of every (non-see-through) cut-out hole on the layer (incl. unparented limbs cut off the torso), baked into the backfill, refinable afterwards with the clone brush. - Improved skin-fill: replaced the 8-neighbour-average wave (washed holes to a grey mean) with multi-source BFS nearest-colour + light smoothing, so boundary colour/texture carries inward. Byte-identical on a solid region (bee body); strictly better on textured/striped coats. backfill overrides the live skin-fill wherever it has alpha; serialized in the .ogcut project + baked into both the .zip/puppet pack export and host payload. Verified in-browser: heal fills disc area, clone copies exact stripe colour, silhouette clip drops 0px outside the figure, export bakes it, save/open round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 192 +++++++++++++++++++++++++++++++--------- 1 file changed, 152 insertions(+), 40 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 6a86561..4c35f84 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -127,6 +127,7 @@

Tool

+
@@ -137,6 +138,15 @@

Tool

+

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -257,6 +267,7 @@

Export

src:null, srcData:null, // native-res source canvas + cached pixel data layers:[], active:-1, tool:'wand', brush:40, tol:40, bg:235, wandSub:false, + cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneOff:null, // 🩹 clone-stamp: source spot + aligned offset while stroking view:{zoom:1, x:0, y:0}, undo:[], redo:[], // history stacks: {id, data:ImageData} lasso:null, // in-progress lasso points (image coords) @@ -452,6 +463,7 @@

Export

const sctx=S.src.getContext('2d', {willReadFrequently:true}); sctx.drawImage(img,0,0); S.srcData=sctx.getImageData(0,0,S.iw,S.ih); S.layers=[]; S.active=-1; S.undo=[]; S.redo=[]; nextId=1; + S.cloneSrc=null; S.cloneOff=null; S.cloneSampling=false; S.layers.push(makeLayer('fig','base')); S.layers.push(makeLayer('arm_r','mover')); S.active=1; @@ -494,7 +506,8 @@

Export

parent:l.parent??null, lips:(Array.isArray(l.lips)&&l.lips.length)?l.lips:null, poses:l.poses||{}, - mask:l.mc.toDataURL('image/png') })) }; + mask:l.mc.toDataURL('image/png'), + backfill:l.backfill?l.backfill.toDataURL('image/png'):null })) }; } function saveProject(){ if(!S.img){ toast('Load + cut something first'); return; } @@ -515,17 +528,18 @@

Export

const sctx=S.src.getContext('2d',{willReadFrequently:true}); sctx.drawImage(img,0,0); S.srcData=sctx.getImageData(0,0,S.iw,S.ih); S.layers=[]; S.undo=[]; S.redo=[]; S.preview=false; + S.cloneSrc=null; S.cloneOff=null; S.cloneSampling=false; const pls=proj.layers||[]; nextId=Math.max(proj.nextId||1, ...pls.map(l=>(l.id||0)+1), 1); S.active=Math.max(0, Math.min(proj.active??0, pls.length-1)); - let pending=pls.length; + let pending=0; const finish=()=>{ document.getElementById('dims').textContent=`${S.iw}×${S.ih}`; document.getElementById('empty').style.display='none'; S.mode=proj.mode||'rig'; S.expr=proj.expr||'neutral'; { const ci=document.getElementById('charName'); if(ci && typeof proj.name==='string') ci.value=proj.name; } fitView(); renderLayerList(); applyMode(); draw(); markDirty(); if(!quiet) toast('Project opened — pick up where you left off'); }; - if(!pls.length){ finish(); return; } + const done=()=>{ if(--pending===0) finish(); }; pls.forEach((pl,idx)=>{ const mc=document.createElement('canvas'); mc.width=S.iw; mc.height=S.ih; const mctx=mc.getContext('2d',{willReadFrequently:true}); @@ -538,11 +552,12 @@

Export

lips:(Array.isArray(pl.lips)&&pl.lips.length)?pl.lips:null, poses:pl.poses||{} }; S.layers[idx]=L; - if(pl.mask){ const mi=new Image(); - mi.onload=()=>{ mctx.drawImage(mi,0,0); if(--pending===0) finish(); }; - mi.onerror=()=>{ if(--pending===0) finish(); }; mi.src=pl.mask; } - else if(--pending===0) finish(); + if(pl.mask){ pending++; const mi=new Image(); + mi.onload=()=>{ mctx.drawImage(mi,0,0); done(); }; mi.onerror=done; mi.src=pl.mask; } + if(pl.backfill){ pending++; layerBackfill(L); const bi=new Image(); + bi.onload=()=>{ L.bfctx.drawImage(bi,0,0); done(); }; bi.onerror=done; bi.src=pl.backfill; } }); + if(pending===0) finish(); }; img.onerror=()=>toast('Project image failed to load'); img.src=proj.image; @@ -592,42 +607,64 @@

Export

for(const k of childLayers){ ((!S.skinFill||k.seeThrough)?clear:skin).push(dat(k)); } return {skin, clear}; } -// Spread a layer's OWN surrounding colour into its punched-out holes, so when the -// cut-out child (mouth/eye) moves for an expression it reveals SKIN, not a transparent -// gap. Pure inpaint: seed with the layer's non-hole pixels, then grow (wave dilation, -// 8-neighbour average) inward over the hole until filled. Works in source space, -// returns Uint8ClampedArray(W*H*3) of skin RGB, or null if there are no holes. -function skinFillRGB(ownMask, holeMasks){ - if(!S.skinFill || !holeMasks || !holeMasks.length) return null; +// Inpaint a layer's punched-out holes with its OWN surrounding colour/texture, so a +// moved cut-out (mouth/eye/limb) reveals coat, not a transparent gap. Two-step: +// (1) multi-source BFS so every hole pixel takes the colour of its NEAREST known pixel +// — this carries real boundary colour (stripes, shading) inward instead of washing +// the whole hole to one grey mean (the old 8-neighbour-average did the latter); +// (2) a few light smoothing passes over hole pixels only, to melt the nearest-colour +// seams while keeping the texture gradient. +// For a solid region (e.g. the bee body) nearest-colour == the one colour everywhere, so +// the result is byte-for-byte what the old average produced — safe, strictly better on texture. +// smoothPasses: 2 for the live skin-fill, less to keep more texture for a baked heal. +// Returns {out:Uint8ClampedArray(W*H*3), hole:Uint8Array, filled:Uint8Array} or null. +function inpaintRGB(ownMask, holeMasks, smoothPasses){ const W=S.iw,H=S.ih, sd=S.srcData.data, N=W*H; - const rgb=new Float32Array(N*3), cur=new Uint8Array(N), hole=new Uint8Array(N); + const hole=new Uint8Array(N), known=new Uint8Array(N), filled=new Uint8Array(N); + let a=new Float32Array(N*3); let hx0=W,hy0=H,hx1=-1,hy1=-1, anyHole=false, sr=0,sg=0,sb=0,sn=0; for(let y=0;yhx1)hx1=x;if(yhy1)hy1=y; } - else { rgb[p*3]=sd[o]; rgb[p*3+1]=sd[o+1]; rgb[p*3+2]=sd[o+2]; cur[p]=1; sr+=sd[o];sg+=sd[o+1];sb+=sd[o+2];sn++; } + else { a[p*3]=sd[o]; a[p*3+1]=sd[o+1]; a[p*3+2]=sd[o+2]; known[p]=1; filled[p]=1; sr+=sd[o];sg+=sd[o+1];sb+=sd[o+2];sn++; } } if(!anyHole || !sn) return null; - hx0=Math.max(0,hx0-1);hy0=Math.max(0,hy0-1);hx1=Math.min(W-1,hx1+1);hy1=Math.min(H-1,hy1+1); - let grew=true, guard=0; - while(grew && guard++<3000){ grew=false; const add=[]; - for(let y=hy0;y<=hy1;y++)for(let x=hx0;x<=hx1;x++){ const p=y*W+x; - if(cur[p]||!hole[p]) continue; - let r=0,g=0,b=0,n=0; - for(let dy=-1;dy<=1;dy++)for(let dx=-1;dx<=1;dx++){ if(!dx&&!dy)continue; - const xx=x+dx,yy=y+dy; if(xx<0||yy<0||xx>=W||yy>=H)continue; const q=yy*W+xx; - if(cur[q]){ r+=rgb[q*3];g+=rgb[q*3+1];b+=rgb[q*3+2];n++; } } - if(n) add.push(p,r/n,g/n,b/n); - } - if(add.length){ grew=true; for(let i=0;i0,x0,y0,x0,yExport if(!any){ l._cut=null; return null; } const {skin:skinMasks, clear:clearMasks}=splitHoles(punchKids(l)); // skin-filled holes vs see-through holes const skin=skinFillRGB(md, skinMasks); // skin to pour into the skin-filled holes (null when none) + const bf=l.backfill ? l.bfctx.getImageData(0,0,W,H).data : null; // 🩹 clone-stamped / auto-healed pixels (override skin) const c=document.createElement('canvas'); c.width=W; c.height=H; const cx=c.getContext('2d'); const out=cx.createImageData(W,H); const od=out.data; for(let p=0;pExport if(skin){ let hfSkin=1; for(const kd of skinMasks) hfSkin*=(1-kd[o+3]/255); const t=1-hfSkin; od[o]=sd[o]*(1-t)+skin[p*3]*t; od[o+1]=sd[o+1]*(1-t)+skin[p*3+1]*t; od[o+2]=sd[o+2]*(1-t)+skin[p*3+2]*t; od[o+3]=Math.round(sd[o+3]*(ma/255)*hfClear); } - else { od[o]=sd[o]; od[o+1]=sd[o+1]; od[o+2]=sd[o+2]; od[o+3]=Math.round(sd[o+3]*(ma/255)*hfClear); } } + else { od[o]=sd[o]; od[o+1]=sd[o+1]; od[o+2]=sd[o+2]; od[o+3]=Math.round(sd[o+3]*(ma/255)*hfClear); } + if(bf){ const bt=bf[o+3]/255; if(bt){ od[o]=od[o]*(1-bt)+bf[o]*bt; od[o+1]=od[o+1]*(1-bt)+bf[o+1]*bt; od[o+2]=od[o+2]*(1-bt)+bf[o+2]*bt; } } } cx.putImageData(out,0,0); l._cut=c; return c; } function layerById(id){ return S.layers.find(x=>x.id===id) || null; } @@ -702,6 +741,7 @@

Export

ctx.translate(v.x,v.y); ctx.scale(v.zoom,v.zoom); ctx.imageSmoothingEnabled=true; ctx.drawImage(S.src,0,0); + for(let i=0;iExport ctx.beginPath(); for(let k=0;k60) S.undo.shift(); S.redo.length=0; markDirty(); } @@ -762,6 +808,11 @@

Export

l._cut=null; renderLayerList(); if(typeof renderExprPose==='function') renderExprPose(); if(typeof renderWarpPanel==='function') renderWarpPanel(); draw(); toast(verb==='undo'?'Undo':'Redo'); return; } + if(u.k==='backfill'){ + toStack.push(snapBackfill(l)); // save current backfill so the inverse restores it + layerBackfill(l); l.bfctx.putImageData(u.data,0,0); l._cut=null; + S.active=S.layers.indexOf(l); renderLayerList(); draw(); toast(verb==='undo'?'Undo':'Redo'); return; + } toStack.push(snap(l)); // save current mask so the inverse restores it l.mctx.putImageData(u.data,0,0); l._cut=null; S.active=S.layers.indexOf(l); renderLayerList(); draw(); toast(verb==='undo'?'Undo':'Redo'); } @@ -784,6 +835,40 @@

Export

m.fillStyle = l.color; m.beginPath(); m.arc(ix,iy,S.brush/2,0,7); m.fill(); m.globalCompositeOperation='source-over'; } +// 🩹 clone-stamp one soft dab: copy a brush-sized patch of ORIGINAL source texture, +// translated by the locked source→dest offset, onto the layer's backfill — radially +// feathered AND clipped to the layer's own silhouette (so you can't smear outside it). +function paintCloneDab(ix,iy){ const l=activeLayer(); if(!l||!S.cloneSrc||!S.cloneOff)return; + const r=Math.max(2,S.brush/2), d=Math.ceil(r*2)+2; + const dx0=Math.round(ix-r), dy0=Math.round(iy-r); // dest patch top-left (image coords) + const sx0=dx0+S.cloneOff.dx, sy0=dy0+S.cloneOff.dy; // matching source patch top-left + const t=document.createElement('canvas'); t.width=d; t.height=d; const tc=t.getContext('2d'); + tc.drawImage(S.src, sx0,sy0,d,d, 0,0,d,d); // cloned texture from the source spot + tc.globalCompositeOperation='destination-in'; // ∩ radial soft brush + const inner=r*(1-Math.min(0.95,(S.cloneSoft||0)*0.95)); + const g=tc.createRadialGradient(d/2,d/2,Math.max(0,inner), d/2,d/2,r); + g.addColorStop(0,'rgba(0,0,0,1)'); g.addColorStop(1,'rgba(0,0,0,0)'); + tc.fillStyle=g; tc.fillRect(0,0,d,d); + tc.drawImage(l.mc, dx0,dy0,d,d, 0,0,d,d); // ∩ layer silhouette (mc alpha) + tc.globalCompositeOperation='source-over'; + layerBackfill(l); l.bfctx.drawImage(t, dx0,dy0); } + +// ✨ Auto-heal: bake the texture-aware inpaint of every (non-see-through) cut-out hole on +// this layer into its backfill, in one tap. Undoable, and refinable afterwards with 🩹. +function autoHealHoles(){ const l=activeLayer(); if(!l||!S.img)return; + // same hole set the export punches: this layer's cut-out children + (for the base) unparented limbs cut off the torso + const baseKnock = l.role==='base' ? S.layers.filter(k=>k.role==='mover'&&k.punch&&k.parent==null) : []; + const kids=punchKids(l).concat(baseKnock).filter(k=>!k.seeThrough); + if(!kids.length){ toast('No cut-out holes on this layer yet — cut a mover off it first'); return; } + const md=l.mctx.getImageData(0,0,S.iw,S.ih).data; + const holeMasks=kids.map(k=>k.mctx.getImageData(0,0,S.iw,S.ih).data); + const res=inpaintRGB(md, holeMasks, 1); if(!res){ toast('Nothing to heal'); return; } + pushBackfillUndo(l); layerBackfill(l); + const img=l.bfctx.getImageData(0,0,S.iw,S.ih), dd=img.data, {out,hole}=res; + for(let p=0;pExport if(S.tool==='pivot'){ const l=activeLayer(); if(l){ l.pivot={x:Math.round(ip.x),y:Math.round(ip.y)}; draw(); markDirty(); } return; } if(S.tool==='wand'){ wandAt(ip.x,ip.y, S.wandSub); return; } if(S.tool==='lasso'){ S.lasso=[ip]; drawing=true; return; } + if(S.tool==='clone'){ + if(S.cloneSampling){ S.cloneSrc={x:Math.round(ip.x),y:Math.round(ip.y)}; S.cloneSampling=false; + const b=document.getElementById('cloneSample'); if(b)b.classList.remove('on'); + draw(); toast('🎯 Source set — now paint over the hole'); return; } + if(!S.cloneSrc){ toast('Tap 🎯 Set source spot first, then a clean patch of coat'); return; } + const l=activeLayer(); if(!l)return; + drawing=true; S.cloneOff={dx:S.cloneSrc.x-ip.x, dy:S.cloneSrc.y-ip.y}; // lock aligned offset for this stroke + pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; + } // brush / erase drawing=true; pushUndo(); paintDab(ip.x,ip.y, S.tool==='erase'); lastImg=ip; draw(); }); @@ -887,9 +981,14 @@

Export

draw(); return; } if(S.preview) return; if(S.mode==='face'){ if(S.warpDrag) warpMove(sp); else if(S.faceDrag) faceMove(sp); return; } - if(!drawing){ if(S.tool==='brush'||S.tool==='erase')draw(); return; } + if(!drawing){ if(S.tool==='brush'||S.tool==='erase'||S.tool==='clone')draw(); return; } const ip=s2i(sp.x,sp.y); if(S.tool==='lasso'){ S.lasso.push(ip); draw(); return; } + if(S.tool==='clone'){ + if(lastImg){ const dx=ip.x-lastImg.x, dy=ip.y-lastImg.y; const steps=Math.max(1,Math.hypot(dx,dy)/(S.brush/3)); + for(let s=1;s<=steps;s++)paintCloneDab(lastImg.x+dx*s/steps, lastImg.y+dy*s/steps); } + else paintCloneDab(ip.x,ip.y); + lastImg=ip; draw(); return; } if(S.tool==='brush'||S.tool==='erase'){ const erase=S.tool==='erase'; // interpolate between last and current for smooth stroke if(lastImg){ const dx=ip.x-lastImg.x, dy=ip.y-lastImg.y; const steps=Math.max(1,Math.hypot(dx,dy)/(S.brush/3)); @@ -921,7 +1020,15 @@

Export

/* ---------- tool buttons & sliders ---------- */ document.querySelectorAll('#tools button').forEach(b=>b.onclick=()=>{ - S.tool=b.dataset.tool; document.querySelectorAll('#tools button').forEach(x=>x.classList.toggle('on',x===b)); }); + S.tool=b.dataset.tool; document.querySelectorAll('#tools button').forEach(x=>x.classList.toggle('on',x===b)); + const cp=document.getElementById('cloneParams'); if(cp) cp.style.display = (S.tool==='clone')?'block':'none'; + if(S.tool!=='clone'){ S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } + draw(); }); +document.getElementById('cloneSample').onclick=()=>{ const b=document.getElementById('cloneSample'); + S.cloneSampling=!S.cloneSampling; b.classList.toggle('on',S.cloneSampling); + toast(S.cloneSampling?'Tap a clean patch of coat to set the clone source':'Sampling off'); }; +document.getElementById('autoHeal').onclick=autoHealHoles; +{ const se=document.getElementById('soft'); if(se) se.oninput=()=>{ S.cloneSoft=+se.value/100; document.getElementById('softVal').textContent=se.value; }; } const brushEl=document.getElementById('brush'), tolEl=document.getElementById('tol'), bgEl=document.getElementById('bg'); brushEl.oninput=()=>{S.brush=+brushEl.value; document.getElementById('brushVal').textContent=brushEl.value;}; tolEl.oninput=()=>{S.tol=+tolEl.value; document.getElementById('tolVal').textContent=tolEl.value;}; @@ -950,6 +1057,7 @@

Export

const kids=punchKids(l).concat(l.role==='base'?baseKnockKids:[]); // pieces cut OUT of this layer (eye out of head, limbs off torso) const {skin:skinMasks, clear:clearMasks}=splitHoles(kids); const skin=skinFillRGB(md.data, skinMasks); // skin-fill the non-see-through holes + const bf=l.backfill ? l.bfctx.getImageData(0,0,W,H).data : null; // 🩹 clone/auto-heal pixels bake into the export const oc=document.createElement('canvas'); oc.width=box.w; oc.height=box.h; const octx=oc.getContext('2d'); const out=octx.createImageData(box.w,box.h); const od=out.data; const mdd=md.data; for(let y=0;yExport od[oi]=sd[si]*(1-t)+skin[sp*3]*t; od[oi+1]=sd[si+1]*(1-t)+skin[sp*3+1]*t; od[oi+2]=sd[si+2]*(1-t)+skin[sp*3+2]*t; od[oi+3]=Math.round(sd[si+3]*(mdd[si+3]/255)*hfClear); } else { od[oi]=sd[si]; od[oi+1]=sd[si+1]; od[oi+2]=sd[si+2]; od[oi+3]=Math.round(sd[si+3]*(mdd[si+3]/255)*hfClear); } + if(bf){ const bt=bf[si+3]/255; if(bt){ od[oi]=od[oi]*(1-bt)+bf[si]*bt; od[oi+1]=od[oi+1]*(1-bt)+bf[si+1]*bt; od[oi+2]=od[oi+2]*(1-bt)+bf[si+2]*bt; } } } octx.putImageData(out,0,0); const blob=await new Promise(res=>oc.toBlob(res,'image/png')); @@ -1029,7 +1138,7 @@

Export

The library's OGStageLayer rotates about the layer CENTRE, so each mover frame is drawn full-canvas with its JOINT shifted to the centre — then the bake rotates about centre (= the joint) and offsets x/y by (pivot − 0.5) to seat it. Base is full-canvas with the mover shapes punched out. */ -function fullCanvasDataURL(maskData, offsetX, offsetY, holes){ +function fullCanvasDataURL(maskData, offsetX, offsetY, holes, bfData){ const W=S.iw,H=S.ih, sd=S.srcData.data, md=maskData.data; const {skin:skinMasks, clear:clearMasks}=holes||{skin:[],clear:[]}; const skin=skinFillRGB(md, skinMasks); // pour skin into the skin-filled holes; see-through holes stay transparent (video behind) @@ -1044,6 +1153,7 @@

Export

od[oi]=sd[si]*(1-t)+skin[sp*3]*t; od[oi+1]=sd[si+1]*(1-t)+skin[sp*3+1]*t; od[oi+2]=sd[si+2]*(1-t)+skin[sp*3+2]*t; od[oi+3]=Math.round(sd[si+3]*(md[si+3]/255)*hfClear); } else { od[oi]=sd[si]; od[oi+1]=sd[si+1]; od[oi+2]=sd[si+2]; od[oi+3]=Math.round(sd[si+3]*(md[si+3]/255)*hfClear); } + if(bfData){ const bt=bfData[si+3]/255; if(bt){ od[oi]=od[oi]*(1-bt)+bfData[si]*bt; od[oi+1]=od[oi+1]*(1-bt)+bfData[si+1]*bt; od[oi+2]=od[oi+2]*(1-bt)+bfData[si+2]*bt; } } } octx.putImageData(out,0,0); return oc.toDataURL('image/png'); } @@ -1053,14 +1163,15 @@

Export

const images={}, rigLayers=[]; for(const l of S.layers){ const md=l.mctx.getImageData(0,0,W,H); const box=bboxOf(md); if(!box) continue; const name=sanitize(l.name); + const bfData=l.backfill ? l.bfctx.getImageData(0,0,W,H).data : null; // 🩹 clone/auto-heal pixels bake into the host payload const kids=punchKids(l).concat(l.role==='base'?baseKnockKids:[]); // child pieces cut OUT of this layer (+ limbs off the base) if(l.role==='base'){ - images[name]=fullCanvasDataURL(md, 0, 0, splitHoles(kids)); + images[name]=fullCanvasDataURL(md, 0, 0, splitHoles(kids), bfData); rigLayers.push({ id:name, resource:name, role:'base', pivotX:0.5, pivotY:0.5 }); } else { const pv=l.pivot || {x:box.x+box.w/2, y:box.y+box.h/2}; const offX=Math.round(W/2 - pv.x), offY=Math.round(H/2 - pv.y); - images[name]=fullCanvasDataURL(md, offX, offY, kids.length?splitHoles(kids):null); + images[name]=fullCanvasDataURL(md, offX, offY, kids.length?splitHoles(kids):null, bfData); const par=(l.parent!=null)?layerById(l.parent):null; const ml={ id:name, resource:name, role:'mover', pivotX:+(pv.x/W).toFixed(4), pivotY:+(pv.y/H).toFixed(4), @@ -1792,7 +1903,8 @@

Export

// debug hook for automated verification window.__lc = { S, loadImage, buildExport, buildHostPayload, wandAt, zipStore, applyMode, collectExpressions, poseOf, EXPRS, draw, ensurePose, renderWarpPanel, renderExprPose, fieldDisp, doUndo, doRedo, migrateAllWarp, - mlsRigid, mlsControls, facsPose, facsRole, applyFacs, buildCut, punchKids, splitHoles, skinFillRGB, makeLayer }; + mlsRigid, mlsControls, facsPose, facsRole, applyFacs, buildCut, punchKids, splitHoles, skinFillRGB, makeLayer, + inpaintRGB, layerBackfill, paintCloneDab, autoHealHoles }; From aacc39d4e5aa84b1d2670784b56b9cb9c5ac0c19 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:43:59 +0100 Subject: [PATCH 40/87] feat(cutter): reveal mover holes in the edit view while patching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hole a lifted mover leaves was only visible in motion preview — in the static editor the mover sits on top of its own hole, so you couldn't see the gap you were trying to patch. Now picking the 🩹 Patch base tool lifts the movers off the base (destination-out their footprints) and tints the empty holes magenta behind the coat; the backfill is drawn over, so the magenta recedes live as you clone/auto-heal. Selecting Patch also auto-selects the base layer (so holes show + you paint the right layer). Magenta only for the active base's non-see-through, visible cut-out children (incl. unparented limbs). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 4c35f84..1a8ce7c 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -145,7 +145,7 @@

Tool

-

🩹 Patch base heals the hole a lifted mover leaves — so when it swings away you see real coat/texture, not a flat colour or a gap. Tap 🎯 Set source spot → tap a clean patch of coat → then paint over the hole; it clones real pixels (stripes, shading) from source to hole. Painting is auto-clipped to this layer's shape, so you can't smear outside the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ✨ Auto-heal holes fills them all in one tap. ↩︎ / ⌘Z undoes any patch.

+

🩹 Patch base heals the hole a lifted mover leaves — so when it swings away you see real coat/texture, not a flat colour or a gap. Picking this tool lifts the movers so the holes glow magenta (you can finally see the gap you're patching; the magenta recedes as you fill it). Tap 🎯 Set source spot → tap a clean patch of coat → then paint over the magenta hole; it clones real pixels (stripes, shading) from source to hole. Painting is auto-clipped to this layer's shape, so you can't smear outside the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ✨ Auto-heal holes fills them all in one tap. ↩︎ / ⌘Z undoes any patch.

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -741,10 +741,32 @@

Export

ctx.translate(v.x,v.y); ctx.scale(v.zoom,v.zoom); ctx.imageSmoothingEnabled=true; ctx.drawImage(S.src,0,0); - for(let i=0;il.role==='base')) : null; + if(revealBase){ + // 🩹 PATCH MODE: lift the movers off this base so the hole they leave is actually visible. + // Punch each cut-out footprint transparent, tint the empty hole magenta, then show the + // patched pixels filling it — so you can see the gap AND watch it fill as you clone/heal. + const W0=S.iw,H0=S.ih; + const kids=punchKids(revealBase) + .concat(revealBase.role==='base'?S.layers.filter(k=>k.role==='mover'&&k.punch&&k.parent==null):[]) + .filter(k=>!k.seeThrough && k.visible); + ctx.globalCompositeOperation='destination-out'; // lift movers → holes go transparent + for(const k of kids) ctx.drawImage(k.mc,0,0); + ctx.globalCompositeOperation='source-over'; + if(kids.length){ // magenta backdrop BEHIND, so it shows only in the empty holes + const mh=document.createElement('canvas'); mh.width=W0; mh.height=H0; const mx=mh.getContext('2d'); + for(const k of kids) mx.drawImage(k.mc,0,0); + mx.globalCompositeOperation='source-in'; mx.fillStyle='rgba(255,45,170,0.80)'; mx.fillRect(0,0,W0,H0); + ctx.globalCompositeOperation='destination-over'; ctx.drawImage(mh,0,0); ctx.globalCompositeOperation='source-over'; + } + if(revealBase.backfill) ctx.drawImage(revealBase.backfill,0,0); // patched texture covers the magenta where you've healed + ctx.globalAlpha=0.30; ctx.drawImage(revealBase.mc,0,0); ctx.globalAlpha=1; // faint tint of the base you're patching + } else { + for(let i=0;iExport document.querySelectorAll('#tools button').forEach(b=>b.onclick=()=>{ S.tool=b.dataset.tool; document.querySelectorAll('#tools button').forEach(x=>x.classList.toggle('on',x===b)); const cp=document.getElementById('cloneParams'); if(cp) cp.style.display = (S.tool==='clone')?'block':'none'; - if(S.tool!=='clone'){ S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } + if(S.tool==='clone'){ // Patch works on the base — select it so the holes show + you paint the right layer + const bi=S.layers.findIndex(l=>l.role==='base'); + if(bi>=0 && (!activeLayer()||activeLayer().role!=='base')){ S.active=bi; renderLayerList(); } + toast('🩹 Holes glow magenta — tap 🎯 Set source, then paint them over'); + } else { S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } draw(); }); document.getElementById('cloneSample').onclick=()=>{ const b=document.getElementById('cloneSample'); S.cloneSampling=!S.cloneSampling; b.classList.toggle('on',S.cloneSampling); From 419b7e81258fbea6f499d735e52d3f490f591128 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:07:36 +0100 Subject: [PATCH 41/87] =?UTF-8?q?feat(cutter):=20holes=20live=20on=20the?= =?UTF-8?q?=20layer=20they're=20cut=20from=20=E2=80=94=20auto-jump=20+=20h?= =?UTF-8?q?ole-count=20badge=20+=20one=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch/Auto-heal no longer scold 'no cut-out holes on this layer' when the wrong layer is selected: they auto-jump to the layer the piece was actually cut FROM (a mover's parent, or the base) and heal there. Every layer row with cut-outs now shows a ◳ N hole-count badge so the holes have a visible home. Role dropdown now enforces exactly one base per image (marking a new base demotes the old one). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 1a8ce7c..11817a7 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -55,6 +55,7 @@ .layer .mini{background:transparent; border:none; min-height:30px; padding:3px 7px; flex:0 0 auto; font-size:15px;} .mini[disabled]{opacity:.28; pointer-events:none;} .pill{font-size:10px; padding:1px 6px; border-radius:20px; background:#0005; color:var(--dim);} + .hbadge{font-size:10px; font-weight:600; padding:1px 7px; border-radius:20px; background:#d81b9433; color:#ff6fc4; border:1px solid #ff6fc455; flex:0 0 auto; white-space:nowrap;} .mrow{display:flex; align-items:center; gap:8px; margin:6px 0;} .mrow label{font-size:11px; color:var(--dim); flex:0 0 78px;} .mrow input[type=range]{flex:1 1 auto; min-width:60px;} @@ -326,6 +327,7 @@

Export

+ ${holeKids(l).length?`◳ ${holeKids(l).length}`:''} @@ -346,7 +348,10 @@

Export

const nm=row.querySelector('.nm'); nm.onfocus=()=>{ if(S.active!==i) setActive(i); }; // tapping the name also selects nm.onchange=e=>{ l.name=e.target.value; markDirty(); }; - row.querySelector('.role').onchange=e=>{ l.role=e.target.value; l.punch=(l.role!=='base'); renderLayerList(); markDirty(); }; + row.querySelector('.role').onchange=e=>{ l.role=e.target.value; + if(l.role==='base'){ const old=S.layers.filter(k=>k!==l&&k.role==='base'); old.forEach(k=>{ k.role='mover'; k.punch=true; }); // exactly one base per image + if(old.length) toast('🧍 '+l.name+' is now the base — '+old.map(k=>k.name).join(', ')+' became mover'+(old.length>1?'s':'')); } + l.punch=(l.role!=='base'); S.layers.forEach(k=>{k._cut=null;}); renderLayerList(); draw(); markDirty(); }; row.querySelector('.pn').onchange=e=>{ l.punch=e.target.checked; S.layers.forEach(k=>{k._cut=null;}); renderLayerList(); draw(); markDirty(); }; const stEl=row.querySelector('.st'); if(stEl) stEl.onchange=e=>{ l.seeThrough=e.target.checked; S.layers.forEach(k=>{k._cut=null;}); draw(); markDirty(); @@ -598,6 +603,11 @@

Export

/* ---------- live assembled-motion preview: base still, movers swing about their pinned joint ---------- */ // child layers 'punch'ed OUT of layer l (e.g. an eye cut out of the head it rides) function punchKids(l){ return S.layers.filter(k=>k!==l && k.punch && k.parent===l.id); } +// every mover cut OUT of layer l — the pieces that leave holes in it: its own punch-children +// PLUS, for the base, the unparented limbs cut straight off the torso. This is the hole-set the +// export punches and the Patch/Auto-heal tools fill, so a layer "has holes" iff this is non-empty. +function holeKids(l){ return punchKids(l).concat(l.role==='base'?S.layers.filter(k=>k.role==='mover'&&k.punch&&k.parent==null):[]); } +function openHoleKids(l){ return holeKids(l).filter(k=>!k.seeThrough); } // the ones that actually need healing (not see-through) // Split a set of cut-out child layers into the holes that get SKIN vs the holes left // SEE-THROUGH (real transparent hole — so a video/background behind the image shows // through). A hole is see-through if that child is marked see-through, OR skin-fill is @@ -877,11 +887,13 @@

Export

// ✨ Auto-heal: bake the texture-aware inpaint of every (non-see-through) cut-out hole on // this layer into its backfill, in one tap. Undoable, and refinable afterwards with 🩹. -function autoHealHoles(){ const l=activeLayer(); if(!l||!S.img)return; - // same hole set the export punches: this layer's cut-out children + (for the base) unparented limbs cut off the torso - const baseKnock = l.role==='base' ? S.layers.filter(k=>k.role==='mover'&&k.punch&&k.parent==null) : []; - const kids=punchKids(l).concat(baseKnock).filter(k=>!k.seeThrough); - if(!kids.length){ toast('No cut-out holes on this layer yet — cut a mover off it first'); return; } +function autoHealHoles(){ if(!S.img)return; let l=activeLayer(); + // the hole set the export punches lives on whatever the piece was cut FROM. If the selected + // layer has none, auto-jump to a layer that does — so a wrong selection heals instead of scolding. + if(!l || !openHoleKids(l).length){ const alt=S.layers.find(L=>openHoleKids(L).length); + if(alt){ l=alt; S.active=S.layers.indexOf(alt); renderLayerList(); toast('✨ Healing '+alt.name+' — that’s where the holes are'); } } + const kids=l?openHoleKids(l):[]; + if(!kids.length){ toast('No cut-out holes anywhere yet — tick “cut out” on a mover first'); return; } const md=l.mctx.getImageData(0,0,S.iw,S.ih).data; const holeMasks=kids.map(k=>k.mctx.getImageData(0,0,S.iw,S.ih).data); const res=inpaintRGB(md, holeMasks, 1); if(!res){ toast('Nothing to heal'); return; } @@ -1044,10 +1056,18 @@

Export

document.querySelectorAll('#tools button').forEach(b=>b.onclick=()=>{ S.tool=b.dataset.tool; document.querySelectorAll('#tools button').forEach(x=>x.classList.toggle('on',x===b)); const cp=document.getElementById('cloneParams'); if(cp) cp.style.display = (S.tool==='clone')?'block':'none'; - if(S.tool==='clone'){ // Patch works on the base — select it so the holes show + you paint the right layer - const bi=S.layers.findIndex(l=>l.role==='base'); - if(bi>=0 && (!activeLayer()||activeLayer().role!=='base')){ S.active=bi; renderLayerList(); } - toast('🩹 Holes glow magenta — tap 🎯 Set source, then paint them over'); + if(S.tool==='clone'){ // Patch heals the layer the hole LIVES in — auto-jump there so the holes show + const cur=activeLayer(); + // if a mover is selected, patch the layer it was cut FROM (its parent, or the base); + // otherwise find any layer that actually has holes; fall back to the base. + let target = (cur&&cur.role==='mover') ? (cur.parent!=null?layerById(cur.parent):S.layers.find(l=>l.role==='base')) : cur; + if(!target || !openHoleKids(target).length){ + target = S.layers.find(l=>openHoleKids(l).length) || S.layers.find(l=>l.role==='base') || cur; + } + if(target){ const ti=S.layers.indexOf(target); if(ti>=0 && ti!==S.active){ S.active=ti; renderLayerList(); } } + const n = target?openHoleKids(target).length:0; + toast(n ? ('🩹 Patching '+target.name+' — '+n+' hole'+(n>1?'s':'')+' glow magenta; tap 🎯 Set source then paint') + : '🩹 No holes yet — tick “cut out” on a mover first, then Patch heals the layer beneath it'); } else { S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } draw(); }); document.getElementById('cloneSample').onclick=()=>{ const b=document.getElementById('cloneSample'); From 2cd3bd86c22f5421f586c4dbf3c577926d8a1917 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:17:15 +0100 Subject: [PATCH 42/87] fix(cutter): reveal holes on the layer being patched, not only the base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch mode only ever lifted movers off the base layer, so a hole cut into the head/mouth (a mover) stayed invisible — the magenta reveal showed the base instead. Now the reveal follows the active layer when it has holes (eye off the head → reveal the head), falling back to any holed layer, then the base. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 11817a7..ef5834e 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -751,15 +751,21 @@

Export

ctx.translate(v.x,v.y); ctx.scale(v.zoom,v.zoom); ctx.imageSmoothingEnabled=true; ctx.drawImage(S.src,0,0); - const revealBase = (S.tool==='clone') ? (activeLayer()&&activeLayer().role==='base'?activeLayer():S.layers.find(l=>l.role==='base')) : null; + // 🩹 PATCH MODE reveals the holes on the layer you're patching — the ACTIVE layer if it has + // holes (eye cut off the head → reveal the HEAD, not the base), else any layer that does, else + // fall back to the base. (Previously it only ever revealed the base, so head/mouth holes stayed + // invisible — the exact "I can't see the holes" problem.) + const revealBase = (S.tool==='clone') ? ( + (activeLayer() && openHoleKids(activeLayer()).length) ? activeLayer() + : (S.layers.find(l=>openHoleKids(l).length) + || (activeLayer()&&activeLayer().role==='base'?activeLayer():S.layers.find(l=>l.role==='base'))) + ) : null; if(revealBase){ - // 🩹 PATCH MODE: lift the movers off this base so the hole they leave is actually visible. - // Punch each cut-out footprint transparent, tint the empty hole magenta, then show the - // patched pixels filling it — so you can see the gap AND watch it fill as you clone/heal. + // lift the pieces off this layer so the hole they leave is actually visible: punch each + // cut-out footprint transparent, tint the empty hole magenta, then show the patched pixels + // filling it — so you can see the gap AND watch it fill as you clone/heal. const W0=S.iw,H0=S.ih; - const kids=punchKids(revealBase) - .concat(revealBase.role==='base'?S.layers.filter(k=>k.role==='mover'&&k.punch&&k.parent==null):[]) - .filter(k=>!k.seeThrough && k.visible); + const kids=holeKids(revealBase).filter(k=>!k.seeThrough && k.visible); ctx.globalCompositeOperation='destination-out'; // lift movers → holes go transparent for(const k of kids) ctx.drawImage(k.mc,0,0); ctx.globalCompositeOperation='source-over'; From 0ced816602a456fa0928ae2bf05eda588299f781 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:36:15 +0100 Subject: [PATCH 43/87] feat(cutter): tell the user when a hole can't be patched because it's see-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A see-through (🔲) cut-out is intentionally kept as a real transparent gap, so it's excluded from the magenta reveal + Patch/Auto-heal. Previously that was silent, so the hole looked un-patchable for no visible reason. Now Patch/Auto-heal point to the exact toggle: 'untick 🔲 see-through on to patch it'. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index ef5834e..21e0c2e 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -899,7 +899,10 @@

Export

if(!l || !openHoleKids(l).length){ const alt=S.layers.find(L=>openHoleKids(L).length); if(alt){ l=alt; S.active=S.layers.indexOf(alt); renderLayerList(); toast('✨ Healing '+alt.name+' — that’s where the holes are'); } } const kids=l?openHoleKids(l):[]; - if(!kids.length){ toast('No cut-out holes anywhere yet — tick “cut out” on a mover first'); return; } + if(!kids.length){ + const stOnly = S.layers.flatMap(L=>holeKids(L)).filter(k=>k.seeThrough); + toast(stOnly.length ? ('🔲 '+stOnly[0].name+'’s hole is SEE-THROUGH (kept as a real gap) — untick 🔲 see-through on it to heal it') + : 'No cut-out holes anywhere yet — tick “cut out” on a mover first'); return; } const md=l.mctx.getImageData(0,0,S.iw,S.ih).data; const holeMasks=kids.map(k=>k.mctx.getImageData(0,0,S.iw,S.ih).data); const res=inpaintRGB(md, holeMasks, 1); if(!res){ toast('Nothing to heal'); return; } @@ -1072,8 +1075,10 @@

Export

} if(target){ const ti=S.layers.indexOf(target); if(ti>=0 && ti!==S.active){ S.active=ti; renderLayerList(); } } const n = target?openHoleKids(target).length:0; + const stOnly = target ? holeKids(target).filter(k=>k.seeThrough) : []; // holes hidden from patching because they're see-through toast(n ? ('🩹 Patching '+target.name+' — '+n+' hole'+(n>1?'s':'')+' glow magenta; tap 🎯 Set source then paint') - : '🩹 No holes yet — tick “cut out” on a mover first, then Patch heals the layer beneath it'); + : stOnly.length ? ('🔲 '+target.name+'’s hole is set SEE-THROUGH (kept as a real gap) — untick 🔲 see-through on '+stOnly[0].name+' to patch it') + : '🩹 No holes yet — tick “cut out” on a mover first, then Patch heals the layer beneath it'); } else { S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } draw(); }); document.getElementById('cloneSample').onclick=()=>{ const b=document.getElementById('cloneSample'); From 52d2df744bbdf807c72c231778ce56148fbcfe1d Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:47:35 +0100 Subject: [PATCH 44/87] =?UTF-8?q?fix(cutter):=20tapping/dragging=20the=20m?= =?UTF-8?q?agenta=20hole=20now=20auto-fills=20it=20(no=20=F0=9F=8E=AF=20so?= =?UTF-8?q?urce=20step=20needed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 'the magenta never changes': the 🩹 Patch brush was a silent no-op until you tapped 🎯 Set source spot first — so painting the hole directly did nothing. Now, with no source set, tapping/dragging the magenta runs the (verified working) content-aware auto-heal on the right layer. 🎯 Set source still enables exact texture cloning. Reproduced + confirmed the heal visually covers the magenta in a real browser before/after. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 21e0c2e..0171692 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -146,7 +146,7 @@

Tool

-

🩹 Patch base heals the hole a lifted mover leaves — so when it swings away you see real coat/texture, not a flat colour or a gap. Picking this tool lifts the movers so the holes glow magenta (you can finally see the gap you're patching; the magenta recedes as you fill it). Tap 🎯 Set source spot → tap a clean patch of coat → then paint over the magenta hole; it clones real pixels (stripes, shading) from source to hole. Painting is auto-clipped to this layer's shape, so you can't smear outside the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ✨ Auto-heal holes fills them all in one tap. ↩︎ / ⌘Z undoes any patch.

+

🩹 Patch base heals the hole a lifted mover leaves — so when it swings away you see real coat/texture, not a flat colour or a gap. Picking this tool lifts the movers so the holes glow magenta (you can finally see the gap you're patching; the magenta recedes as you fill it). Easiest: just tap or drag across the magenta and it auto-fills with the surrounding texture. For exact texture (stripes/shading): tap 🎯 Set source spot → tap a clean patch of coat → then paint over the magenta; it clones real pixels from source to hole. Painting is auto-clipped to this layer's shape, so you can't smear outside the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ✨ Auto-heal holes fills them all in one tap. ↩︎ / ⌘Z undoes any patch.

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -1002,7 +1002,7 @@

Export

if(S.cloneSampling){ S.cloneSrc={x:Math.round(ip.x),y:Math.round(ip.y)}; S.cloneSampling=false; const b=document.getElementById('cloneSample'); if(b)b.classList.remove('on'); draw(); toast('🎯 Source set — now paint over the hole'); return; } - if(!S.cloneSrc){ toast('Tap 🎯 Set source spot first, then a clean patch of coat'); return; } + if(!S.cloneSrc){ autoHealHoles(); return; } // no source set → just content-aware heal the holes (so tapping the magenta "fills the gap" works without the 🎯 step) const l=activeLayer(); if(!l)return; drawing=true; S.cloneOff={dx:S.cloneSrc.x-ip.x, dy:S.cloneSrc.y-ip.y}; // lock aligned offset for this stroke pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; @@ -1076,7 +1076,7 @@

Export

if(target){ const ti=S.layers.indexOf(target); if(ti>=0 && ti!==S.active){ S.active=ti; renderLayerList(); } } const n = target?openHoleKids(target).length:0; const stOnly = target ? holeKids(target).filter(k=>k.seeThrough) : []; // holes hidden from patching because they're see-through - toast(n ? ('🩹 Patching '+target.name+' — '+n+' hole'+(n>1?'s':'')+' glow magenta; tap 🎯 Set source then paint') + toast(n ? ('🩹 Patching '+target.name+' — '+n+' hole'+(n>1?'s':'')+' glow magenta. Tap/drag the magenta to auto-fill it, or 🎯 Set source to clone exact texture.') : stOnly.length ? ('🔲 '+target.name+'’s hole is set SEE-THROUGH (kept as a real gap) — untick 🔲 see-through on '+stOnly[0].name+' to patch it') : '🩹 No holes yet — tick “cut out” on a mover first, then Patch heals the layer beneath it'); } else { S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } From 101e3eb9438681794fdc2cf0e25bf0aa01637522 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:01:37 +0100 Subject: [PATCH 45/87] =?UTF-8?q?fix(cutter):=20re-sync=20canvas=20buffer?= =?UTF-8?q?=20to=20its=20CSS=20box=20each=20draw=20=E2=80=94=20fixes=20tou?= =?UTF-8?q?ch=20landing=20offset=20from=20the=20cursor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 'the patch fills below the magenta': on mobile the viewport (URL bar show/hide) and panel relayout after a project loads resize the canvas's CSS box without reliably firing 'resize', leaving a stale buffer. Touch→image mapping uses view + the live getBoundingClientRect (correct), but draw() rendered through the stale buffer, so the image showed vertically scaled/offset from where touches land. You tap the magenta hole, the fill lands below it. Fix: syncCanvasSize() runs at the top of every draw(), resizing the buffer + resetting the dpr transform whenever it no longer matches the CSS box. Verified in a real browser with her project: synced drift 0px, forced-stale buffer drift -270px, after draw() 0px. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 0171692..c310d01 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -594,6 +594,16 @@

Export

function resizeCanvas(){ const dpr=window.devicePixelRatio||1; const r=stageWrap.getBoundingClientRect(); cv.width=Math.max(1,Math.round(r.width*dpr)); cv.height=Math.max(1,Math.round(r.height*dpr)); ctx.setTransform(dpr,0,0,dpr,0,0); draw(); } +// Keep the canvas BUFFER in lockstep with its on-screen CSS box. On mobile the viewport +// changes (URL bar show/hide, panel relayout after a project loads) resize the canvas's CSS +// box without always firing a 'resize' event — leaving a stale buffer. The image then renders +// vertically scaled, so it sits OFFSET from where touches map (touch uses view + live rect, +// which stay correct) → you tap the magenta hole but the fill lands below it. Re-syncing here, +// before every draw, guarantees render and touch-mapping share one coordinate space. +function syncCanvasSize(){ const dpr=window.devicePixelRatio||1; const r=stageWrap.getBoundingClientRect(); + const w=Math.max(1,Math.round(r.width*dpr)), h=Math.max(1,Math.round(r.height*dpr)); + if(w>1 && h>1 && (cv.width!==w || cv.height!==h)){ cv.width=w; cv.height=h; ctx.setTransform(dpr,0,0,dpr,0,0); return true; } + return false; } function fitView(){ const r=stageWrap.getBoundingClientRect(); const z=Math.min(r.width/S.iw, r.height/S.ih)*0.95; S.view.zoom=z; S.view.x=(r.width-S.iw*z)/2; S.view.y=(r.height-S.ih*z)/2; } @@ -741,6 +751,7 @@

Export

} function draw(){ + syncCanvasSize(); // buffer must match its CSS box or render drifts from touch-mapping const dpr=window.devicePixelRatio||1; const W=cv.width/dpr, H=cv.height/dpr; ctx.clearRect(0,0,W,H); if(!S.img) return; From 380b253e0d6fbd6b9e785d01323ebc162d9f7d3c Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:24:25 +0100 Subject: [PATCH 46/87] =?UTF-8?q?fix(cutter):=20clip=20patch=20reveal=20to?= =?UTF-8?q?=20the=20sheet=20=E2=80=94=20kill=20the=20un-healable=20pink=20?= =?UTF-8?q?overhang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A raised limb's cut-out silhouette can hang far outside the figure it was cut from (84% of the elf's arm did). Patch mode punched + magenta-tinted the WHOLE silhouette, but heal only fills where the cut-out overlaps the sheet's own opaque mask — so the overhang glowed magenta forever: 'pink on top of the purple that patching won't remove'. Now the reveal footprint is clipped to revealBase.mc (hole = union(kids) ∩ sheet mask), matching exactly what heal can fill. Verified in-browser on her elf project: 37,247px overhang removed, real 7,008px hole heals to 96.9%. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index c310d01..ce6f377 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -777,12 +777,19 @@

Export

// filling it — so you can see the gap AND watch it fill as you clone/heal. const W0=S.iw,H0=S.ih; const kids=holeKids(revealBase).filter(k=>!k.seeThrough && k.visible); - ctx.globalCompositeOperation='destination-out'; // lift movers → holes go transparent - for(const k of kids) ctx.drawImage(k.mc,0,0); + // The REAL hole is only where a cut-out overlaps the sheet it was cut FROM — a raised limb's + // silhouette can hang far outside the figure (84% of the elf's arm did), and that overhang is + // NOT figure material: it can't be healed (nothing to fill from). So clip the footprint to + // revealBase's own mask — otherwise the overhang glows magenta forever ("pink that patching + // won't remove"). hole = union(kids) ∩ revealBase.mc. + const hole=document.createElement('canvas'); hole.width=W0; hole.height=H0; const hx=hole.getContext('2d'); + for(const k of kids) hx.drawImage(k.mc,0,0); + hx.globalCompositeOperation='destination-in'; hx.drawImage(revealBase.mc,0,0); hx.globalCompositeOperation='source-over'; + ctx.globalCompositeOperation='destination-out'; ctx.drawImage(hole,0,0); // lift movers → holes go transparent ctx.globalCompositeOperation='source-over'; if(kids.length){ // magenta backdrop BEHIND, so it shows only in the empty holes const mh=document.createElement('canvas'); mh.width=W0; mh.height=H0; const mx=mh.getContext('2d'); - for(const k of kids) mx.drawImage(k.mc,0,0); + mx.drawImage(hole,0,0); mx.globalCompositeOperation='source-in'; mx.fillStyle='rgba(255,45,170,0.80)'; mx.fillRect(0,0,W0,H0); ctx.globalCompositeOperation='destination-over'; ctx.drawImage(mh,0,0); ctx.globalCompositeOperation='source-over'; } From 5a59c59c42cde4ffd1cd39a28dd45e676a2cc160 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:43:58 +0100 Subject: [PATCH 47/87] fix(cutter): patch/heal always targets the sheet, not the selected mover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Özge: 'I want to heal the belt UNDER the arm, but I can only patch an area on the arm.' The arm is a separate mover layer; the belt lives on the base. But paintCloneDab / autoHealHoles painted activeLayer() — if she had the arm selected, the brush was clipped to the arm's silhouette, so she could never reach the belt behind it. New patchTarget() helper = the sheet a piece was cut FROM (active layer only if IT has holes, else first holed layer, else base). Reveal, clone-stamp and auto-heal all route through it, so patching paints the BASE belt regardless of which layer is selected. Verified in-browser on her elf project: arm selected → clone dab wrote 2400px to the base backfill, 0px to the arm. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index ce6f377..5cb48fe 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -618,6 +618,15 @@

Export

// export punches and the Patch/Auto-heal tools fill, so a layer "has holes" iff this is non-empty. function holeKids(l){ return punchKids(l).concat(l.role==='base'?S.layers.filter(k=>k.role==='mover'&&k.punch&&k.parent==null):[]); } function openHoleKids(l){ return holeKids(l).filter(k=>!k.seeThrough); } // the ones that actually need healing (not see-through) +// The layer 🩹 Patch / ✨ Auto-heal operate ON — always the SHEET a piece was cut FROM (the one +// holding the hole), NEVER the mover you have selected. You heal the belt that the ARM hides, so +// the brush must paint the BASE, not the arm layer. Prefer the active layer only if IT has holes; +// otherwise the first layer that does; otherwise the base. (Was activeLayer() → patching the arm.) +function patchTarget(){ const a=activeLayer(); + if(a && openHoleKids(a).length) return a; + return S.layers.find(l=>openHoleKids(l).length) + || (a && a.role==='base' ? a : S.layers.find(l=>l.role==='base')) + || a || null; } // Split a set of cut-out child layers into the holes that get SKIN vs the holes left // SEE-THROUGH (real transparent hole — so a video/background behind the image shows // through). A hole is see-through if that child is marked see-through, OR skin-fill is @@ -766,11 +775,7 @@

Export

// holes (eye cut off the head → reveal the HEAD, not the base), else any layer that does, else // fall back to the base. (Previously it only ever revealed the base, so head/mouth holes stayed // invisible — the exact "I can't see the holes" problem.) - const revealBase = (S.tool==='clone') ? ( - (activeLayer() && openHoleKids(activeLayer()).length) ? activeLayer() - : (S.layers.find(l=>openHoleKids(l).length) - || (activeLayer()&&activeLayer().role==='base'?activeLayer():S.layers.find(l=>l.role==='base'))) - ) : null; + const revealBase = (S.tool==='clone') ? patchTarget() : null; if(revealBase){ // lift the pieces off this layer so the hole they leave is actually visible: punch each // cut-out footprint transparent, tint the empty hole magenta, then show the patched pixels @@ -894,7 +899,7 @@

Export

// 🩹 clone-stamp one soft dab: copy a brush-sized patch of ORIGINAL source texture, // translated by the locked source→dest offset, onto the layer's backfill — radially // feathered AND clipped to the layer's own silhouette (so you can't smear outside it). -function paintCloneDab(ix,iy){ const l=activeLayer(); if(!l||!S.cloneSrc||!S.cloneOff)return; +function paintCloneDab(ix,iy){ const l=patchTarget(); if(!l||!S.cloneSrc||!S.cloneOff)return; const r=Math.max(2,S.brush/2), d=Math.ceil(r*2)+2; const dx0=Math.round(ix-r), dy0=Math.round(iy-r); // dest patch top-left (image coords) const sx0=dx0+S.cloneOff.dx, sy0=dy0+S.cloneOff.dy; // matching source patch top-left @@ -911,7 +916,7 @@

Export

// ✨ Auto-heal: bake the texture-aware inpaint of every (non-see-through) cut-out hole on // this layer into its backfill, in one tap. Undoable, and refinable afterwards with 🩹. -function autoHealHoles(){ if(!S.img)return; let l=activeLayer(); +function autoHealHoles(){ if(!S.img)return; let l=patchTarget(); // the hole set the export punches lives on whatever the piece was cut FROM. If the selected // layer has none, auto-jump to a layer that does — so a wrong selection heals instead of scolding. if(!l || !openHoleKids(l).length){ const alt=S.layers.find(L=>openHoleKids(L).length); @@ -1083,14 +1088,8 @@

Export

document.querySelectorAll('#tools button').forEach(b=>b.onclick=()=>{ S.tool=b.dataset.tool; document.querySelectorAll('#tools button').forEach(x=>x.classList.toggle('on',x===b)); const cp=document.getElementById('cloneParams'); if(cp) cp.style.display = (S.tool==='clone')?'block':'none'; - if(S.tool==='clone'){ // Patch heals the layer the hole LIVES in — auto-jump there so the holes show - const cur=activeLayer(); - // if a mover is selected, patch the layer it was cut FROM (its parent, or the base); - // otherwise find any layer that actually has holes; fall back to the base. - let target = (cur&&cur.role==='mover') ? (cur.parent!=null?layerById(cur.parent):S.layers.find(l=>l.role==='base')) : cur; - if(!target || !openHoleKids(target).length){ - target = S.layers.find(l=>openHoleKids(l).length) || S.layers.find(l=>l.role==='base') || cur; - } + if(S.tool==='clone'){ // Patch heals the SHEET the hole LIVES in — auto-jump there so the holes show + const target = patchTarget(); // same target the brush + auto-heal paint (the base, never the selected mover) if(target){ const ti=S.layers.indexOf(target); if(ti>=0 && ti!==S.active){ S.active=ti; renderLayerList(); } } const n = target?openHoleKids(target).length:0; const stOnly = target ? holeKids(target).filter(k=>k.seeThrough) : []; // holes hidden from patching because they're see-through From 0b90366edbf7e764accde97a91d271393e824db2 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:57:24 +0100 Subject: [PATCH 48/87] =?UTF-8?q?fix(cutter):=20patch=20grows=20the=20base?= =?UTF-8?q?=20UNDER=20the=20mover=20=E2=80=94=20rebuild=20belt=20behind=20?= =?UTF-8?q?the=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Özge: 'I want to heal the belt under the arm, but I can only patch the arm / the arm is still hiding it.' Root cause: the base figure mask has a VOID where a mover sits (the elf's arm overlapped the figure by only 16%). Patch treated just that 16% sliver as the hole, so 84% of the under-arm area was unreachable — and my previous 'clip to the figure' commit made it worse (that overhang IS the work area). The base must GROW to cover the whole footprint the mover vacates when it swings. - Reveal: lift the WHOLE mover footprint (not clipped to the figure) → the arm stops hiding what's below; the full footprint glows magenta. - Auto-heal: inpaint over grownSheet = base ∪ hole-footprints; the footprint is the hole, the surrounding real belt/coat is the source. Fills 99.2% of the full footprint (was ~16%). - Clone paint: clip to the grown mask (captured per stroke) so you can paint belt INTO the footprint, not just on the figure. - buildCut: composite the backfill whole (drawImage) so the base grows to include the healed footprint — base now fills 99.9% of it, so the puppet shows coat/belt behind the swinging arm. Backward-compatible: a piece cut fully inside its sheet (eye-in-head) has grownSheet == sheet, so behaviour is unchanged. Verified in-browser on the elf. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 5cb48fe..6bcc127 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -627,6 +627,13 @@

Export

return S.layers.find(l=>openHoleKids(l).length) || (a && a.role==='base' ? a : S.layers.find(l=>l.role==='base')) || a || null; } +// The sheet GROWN to include the footprints of the pieces cut from it — the base plus the holes it +// must fill when those movers swing away. This is the region Patch/Auto-heal may paint into (so you +// can rebuild the belt UNDER the arm, not just the sliver where the arm overlapped the figure). +function grownSheet(l){ const c=document.createElement('canvas'); c.width=S.iw; c.height=S.ih; + const x=c.getContext('2d',{willReadFrequently:true}); x.drawImage(l.mc,0,0); + for(const k of holeKids(l).filter(k=>!k.seeThrough && k.visible)) x.drawImage(k.mc,0,0); + return c; } // Split a set of cut-out child layers into the holes that get SKIN vs the holes left // SEE-THROUGH (real transparent hole — so a video/background behind the image shows // through). A hole is see-through if that child is marked see-through, OR skin-fill is @@ -701,7 +708,6 @@

Export

if(!any){ l._cut=null; return null; } const {skin:skinMasks, clear:clearMasks}=splitHoles(punchKids(l)); // skin-filled holes vs see-through holes const skin=skinFillRGB(md, skinMasks); // skin to pour into the skin-filled holes (null when none) - const bf=l.backfill ? l.bfctx.getImageData(0,0,W,H).data : null; // 🩹 clone-stamped / auto-healed pixels (override skin) const c=document.createElement('canvas'); c.width=W; c.height=H; const cx=c.getContext('2d'); const out=cx.createImageData(W,H); const od=out.data; for(let p=0;pExport od[o]=sd[o]*(1-t)+skin[p*3]*t; od[o+1]=sd[o+1]*(1-t)+skin[p*3+1]*t; od[o+2]=sd[o+2]*(1-t)+skin[p*3+2]*t; od[o+3]=Math.round(sd[o+3]*(ma/255)*hfClear); } else { od[o]=sd[o]; od[o+1]=sd[o+1]; od[o+2]=sd[o+2]; od[o+3]=Math.round(sd[o+3]*(ma/255)*hfClear); } - if(bf){ const bt=bf[o+3]/255; if(bt){ od[o]=od[o]*(1-bt)+bf[o]*bt; od[o+1]=od[o+1]*(1-bt)+bf[o+1]*bt; od[o+2]=od[o+2]*(1-bt)+bf[o+2]*bt; } } } - cx.putImageData(out,0,0); l._cut=c; return c; + } + cx.putImageData(out,0,0); + // 🩹 clone-stamped / auto-healed pixels composite OVER the base — and because they can extend + // BEYOND the figure mask (rebuilt belt under where the arm swings away), drawImage them whole so + // the base GROWS to include the healed footprint, not just the pixels already inside md. + if(l.backfill) cx.drawImage(l.backfill,0,0); + l._cut=c; return c; } function layerById(id){ return S.layers.find(x=>x.id===id) || null; } // a layer's own swing angle at the current preview time @@ -782,14 +793,13 @@

Export

// filling it — so you can see the gap AND watch it fill as you clone/heal. const W0=S.iw,H0=S.ih; const kids=holeKids(revealBase).filter(k=>!k.seeThrough && k.visible); - // The REAL hole is only where a cut-out overlaps the sheet it was cut FROM — a raised limb's - // silhouette can hang far outside the figure (84% of the elf's arm did), and that overhang is - // NOT figure material: it can't be healed (nothing to fill from). So clip the footprint to - // revealBase's own mask — otherwise the overhang glows magenta forever ("pink that patching - // won't remove"). hole = union(kids) ∩ revealBase.mc. + // The hole = the mover's WHOLE footprint. When the arm swings away the base must have coat/belt + // behind it, so the base has to GROW to cover the full footprint — even the part hanging outside + // the current figure mask (84% of the elf's arm). We lift the whole mover off (so it stops + // hiding what's below) and glow the footprint magenta; ✨ Auto-heal then rebuilds base texture + // there (sampled from the real belt/coat around it) and the magenta recedes as it fills. const hole=document.createElement('canvas'); hole.width=W0; hole.height=H0; const hx=hole.getContext('2d'); for(const k of kids) hx.drawImage(k.mc,0,0); - hx.globalCompositeOperation='destination-in'; hx.drawImage(revealBase.mc,0,0); hx.globalCompositeOperation='source-over'; ctx.globalCompositeOperation='destination-out'; ctx.drawImage(hole,0,0); // lift movers → holes go transparent ctx.globalCompositeOperation='source-over'; if(kids.length){ // magenta backdrop BEHIND, so it shows only in the empty holes @@ -910,7 +920,8 @@

Export

const g=tc.createRadialGradient(d/2,d/2,Math.max(0,inner), d/2,d/2,r); g.addColorStop(0,'rgba(0,0,0,1)'); g.addColorStop(1,'rgba(0,0,0,0)'); tc.fillStyle=g; tc.fillRect(0,0,d,d); - tc.drawImage(l.mc, dx0,dy0,d,d, 0,0,d,d); // ∩ layer silhouette (mc alpha) + const clip=S._strokeClip||l.mc; // ∩ base ∪ hole footprints (so you can paint belt UNDER the arm, not just on the figure) + tc.drawImage(clip, dx0,dy0,d,d, 0,0,d,d); tc.globalCompositeOperation='source-over'; layerBackfill(l); l.bfctx.drawImage(t, dx0,dy0); } @@ -926,7 +937,10 @@

Export

const stOnly = S.layers.flatMap(L=>holeKids(L)).filter(k=>k.seeThrough); toast(stOnly.length ? ('🔲 '+stOnly[0].name+'’s hole is SEE-THROUGH (kept as a real gap) — untick 🔲 see-through on it to heal it') : 'No cut-out holes anywhere yet — tick “cut out” on a mover first'); return; } - const md=l.mctx.getImageData(0,0,S.iw,S.ih).data; + // Inpaint over the GROWN sheet (base ∪ hole footprints): the whole footprint is the hole to fill, + // the surrounding real base pixels (belt/coat) are the source. This rebuilds the base UNDER the + // mover — including the part outside the original figure mask — instead of only the thin overlap. + const md=grownSheet(l).getContext('2d',{willReadFrequently:true}).getImageData(0,0,S.iw,S.ih).data; const holeMasks=kids.map(k=>k.mctx.getImageData(0,0,S.iw,S.ih).data); const res=inpaintRGB(md, holeMasks, 1); if(!res){ toast('Nothing to heal'); return; } pushBackfillUndo(l); layerBackfill(l); @@ -1026,8 +1040,9 @@

Export

const b=document.getElementById('cloneSample'); if(b)b.classList.remove('on'); draw(); toast('🎯 Source set — now paint over the hole'); return; } if(!S.cloneSrc){ autoHealHoles(); return; } // no source set → just content-aware heal the holes (so tapping the magenta "fills the gap" works without the 🎯 step) - const l=activeLayer(); if(!l)return; + const l=patchTarget(); if(!l)return; // paint the SHEET the hole lives in, never the selected mover drawing=true; S.cloneOff={dx:S.cloneSrc.x-ip.x, dy:S.cloneSrc.y-ip.y}; // lock aligned offset for this stroke + S._strokeClip=grownSheet(l); // clip clone paint to base ∪ footprints (so you can rebuild belt under the arm) pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; } // brush / erase From 208a6feb8acd922af7cf3a0a349ea71ad209554d Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:10:44 +0100 Subject: [PATCH 49/87] fix(cutter): drop best-effort Auto-heal; clone source is now FIXED (non-aligned) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Özge: 'auto-heal is terrible, we don't need it' + 'the source spot moves when I drag to paint the belt — it should keep using the belt pattern until I pick another spot.' - Removed the ✨ Auto-heal button + its no-source tap trigger. The BFS nearest-colour fill streaked badly on textured coats (she saw long vertical smears). Clone-stamp from a picked spot is the only patch path now. (The autoHealHoles fn stays defined/exported but is no longer wired to UI.) - Clone is now NON-ALIGNED: each dab samples the source patch ALWAYS centred on the picked spot (S.cloneSrc), instead of a per-stroke aligned offset that drifted the source along with the brush. So you keep laying down the same belt/coat pattern until you tap 🎯 to pick a new spot. Removed S.cloneOff use. - No-source tap now arms 🎯 source-picking (+ hint) instead of auto-healing. - Refreshed the Patch hint/toasts to match (no auto-fill wording). Verified in-browser on the elf: no JS errors, button gone, two dabs at different dest positions both sampled the fixed source neighbourhood. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 6bcc127..fb99b85 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -141,12 +141,11 @@

Tool

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -906,13 +905,14 @@

Export

m.fillStyle = l.color; m.beginPath(); m.arc(ix,iy,S.brush/2,0,7); m.fill(); m.globalCompositeOperation='source-over'; } -// 🩹 clone-stamp one soft dab: copy a brush-sized patch of ORIGINAL source texture, -// translated by the locked source→dest offset, onto the layer's backfill — radially -// feathered AND clipped to the layer's own silhouette (so you can't smear outside it). -function paintCloneDab(ix,iy){ const l=patchTarget(); if(!l||!S.cloneSrc||!S.cloneOff)return; +// 🩹 clone-stamp one soft dab: copy a brush-sized patch of ORIGINAL source texture from the FIXED +// source spot (non-aligned — the spot stays put until you pick a new one, so you keep laying down +// the same belt/coat pattern), onto the layer's backfill — radially feathered AND clipped to the +// work area (so you can't smear outside the area behind the mover + the figure). +function paintCloneDab(ix,iy){ const l=patchTarget(); if(!l||!S.cloneSrc)return; const r=Math.max(2,S.brush/2), d=Math.ceil(r*2)+2; const dx0=Math.round(ix-r), dy0=Math.round(iy-r); // dest patch top-left (image coords) - const sx0=dx0+S.cloneOff.dx, sy0=dy0+S.cloneOff.dy; // matching source patch top-left + const sx0=Math.round(S.cloneSrc.x-r), sy0=Math.round(S.cloneSrc.y-r); // source ALWAYS centred on the picked spot const t=document.createElement('canvas'); t.width=d; t.height=d; const tc=t.getContext('2d'); tc.drawImage(S.src, sx0,sy0,d,d, 0,0,d,d); // cloned texture from the source spot tc.globalCompositeOperation='destination-in'; // ∩ radial soft brush @@ -1039,9 +1039,11 @@

Export

if(S.cloneSampling){ S.cloneSrc={x:Math.round(ip.x),y:Math.round(ip.y)}; S.cloneSampling=false; const b=document.getElementById('cloneSample'); if(b)b.classList.remove('on'); draw(); toast('🎯 Source set — now paint over the hole'); return; } - if(!S.cloneSrc){ autoHealHoles(); return; } // no source set → just content-aware heal the holes (so tapping the magenta "fills the gap" works without the 🎯 step) + if(!S.cloneSrc){ // no source yet → arm source-picking so the first tap sets the spot + S.cloneSampling=true; const b=document.getElementById('cloneSample'); if(b)b.classList.add('on'); + toast('🎯 First tap a clean patch of belt/coat to copy from, then paint over the magenta'); return; } const l=patchTarget(); if(!l)return; // paint the SHEET the hole lives in, never the selected mover - drawing=true; S.cloneOff={dx:S.cloneSrc.x-ip.x, dy:S.cloneSrc.y-ip.y}; // lock aligned offset for this stroke + drawing=true; S._strokeClip=grownSheet(l); // clip clone paint to base ∪ footprints (so you can rebuild belt under the arm) pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; } @@ -1104,19 +1106,18 @@

Export

S.tool=b.dataset.tool; document.querySelectorAll('#tools button').forEach(x=>x.classList.toggle('on',x===b)); const cp=document.getElementById('cloneParams'); if(cp) cp.style.display = (S.tool==='clone')?'block':'none'; if(S.tool==='clone'){ // Patch heals the SHEET the hole LIVES in — auto-jump there so the holes show - const target = patchTarget(); // same target the brush + auto-heal paint (the base, never the selected mover) + const target = patchTarget(); // same target the brush paints (the base, never the selected mover) if(target){ const ti=S.layers.indexOf(target); if(ti>=0 && ti!==S.active){ S.active=ti; renderLayerList(); } } const n = target?openHoleKids(target).length:0; const stOnly = target ? holeKids(target).filter(k=>k.seeThrough) : []; // holes hidden from patching because they're see-through - toast(n ? ('🩹 Patching '+target.name+' — '+n+' hole'+(n>1?'s':'')+' glow magenta. Tap/drag the magenta to auto-fill it, or 🎯 Set source to clone exact texture.') + toast(n ? ('🩹 Patching '+target.name+' — '+n+' area'+(n>1?'s':'')+' glow magenta. Tap 🎯 Set source on clean belt/coat, then paint over the magenta.') : stOnly.length ? ('🔲 '+target.name+'’s hole is set SEE-THROUGH (kept as a real gap) — untick 🔲 see-through on '+stOnly[0].name+' to patch it') - : '🩹 No holes yet — tick “cut out” on a mover first, then Patch heals the layer beneath it'); + : '🩹 No holes yet — tick “cut out” on a mover first, then Patch rebuilds what’s behind it'); } else { S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } draw(); }); document.getElementById('cloneSample').onclick=()=>{ const b=document.getElementById('cloneSample'); S.cloneSampling=!S.cloneSampling; b.classList.toggle('on',S.cloneSampling); - toast(S.cloneSampling?'Tap a clean patch of coat to set the clone source':'Sampling off'); }; -document.getElementById('autoHeal').onclick=autoHealHoles; + toast(S.cloneSampling?'Tap a clean patch of belt/coat to copy from':'Sampling off'); }; { const se=document.getElementById('soft'); if(se) se.oninput=()=>{ S.cloneSoft=+se.value/100; document.getElementById('softVal').textContent=se.value; }; } const brushEl=document.getElementById('brush'), tolEl=document.getElementById('tol'), bgEl=document.getElementById('bg'); brushEl.oninput=()=>{S.brush=+brushEl.value; document.getElementById('brushVal').textContent=brushEl.value;}; From ae184e334a3314b4e3e2dc3820b3c1a637b221e7 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:59:02 +0100 Subject: [PATCH 50/87] feat(cutter): clone source shows its copy footprint + live ghost preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Özge: 'set-source needs to be clear — it doesn't seem to apply the pattern correctly. Put a border on the selected spot so I can see what's actually copied and will be applied when I drag.' The source was a tiny fixed crosshair — gave no sense of WHAT or HOW MUCH gets copied. Now, in clone mode: - The source spot is ringed at BRUSH SIZE (solid green + centre dot) = exactly the circular patch that gets copied. - A live GHOST of those very pixels is previewed under the cursor (75% opacity, dashed green ring, same size) so you see what will land where before/while you drag. Both rings match = the copy footprint. - No source yet → amber dashed ring hinting to pick one. Brush/erase keep the plain white cursor ring. Verified in-browser: draw() runs error-free with the new markers. (Tab was backgrounded at 1x1 so pixel-readback was N/A; validated the visual design by reconstructing the overlay offline.) Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index fb99b85..7aa10b9 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -828,13 +828,32 @@

Export

if(S.lasso && S.lasso.length){ ctx.strokeStyle='#fff'; ctx.lineWidth=1.5; ctx.setLineDash([5,4]); ctx.beginPath(); for(let k=0;k Date: Mon, 26 Oct 2026 16:50:37 +0100 Subject: [PATCH 51/87] =?UTF-8?q?feat(cutter):=20GIMP-style=20clone/heal?= =?UTF-8?q?=20=E2=80=94=20moving=20aligned=20region=20+=20Infuse=20tone-ma?= =?UTF-8?q?tch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch base no longer stamps the same fixed spot per dab (which tiled one texture, reading as a flat colour fill). The picked spot is now an ANCHOR: dragging within a stroke copies a parallel MOVING region of belt/coat; each new stroke re-anchors to the picked spot (non-aligned across strokes = no drift). New Infuse slider shifts the copied patch's mean colour toward the hole's real surrounding tone (sampled from the original) so it melds in seamlessly instead of looking pasted. Ghost preview follows the moving source during a stroke. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 58 +++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 7aa10b9..9f56e25 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -145,7 +145,9 @@

Tool

-

🩹 Patch base rebuilds the coat/belt hidden behind a mover, so when it swings away you see real texture — not a gap. Picking this tool lifts the movers off so the area behind them glows magenta (that's what you're rebuilding; the magenta recedes as you paint). Tap 🎯 Set source spot → tap a clean patch of belt/coat → then paint over the magenta: every stroke copies that same spot's texture (it stays put until you pick a new spot). Painting is clipped to the area behind the mover + the figure, so you can't smear outside it. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ↩︎ / ⌘Z undoes any patch.

+ + +

🩹 Patch base rebuilds the coat/belt hidden behind a mover, so when it swings away you see real texture — not a gap. Picking this tool lifts the movers off so the area behind them glows magenta (that's what you're rebuilding; the magenta recedes as you paint). Tap 🎯 Set source spot → tap a clean patch of belt/coat → then paint over the magenta. This works like a GIMP clone/heal brush: the spot you pick is the anchor, and as you drag in one stroke it copies a whole moving region (not the same dot over and over) — so you transfer a real swath of texture. Each new stroke re-anchors to the picked spot (no drift). Infuse = how strongly the copied patch is tone-matched to the real pixels bordering the hole, so it melds in seamlessly instead of looking like a pasted square. Softness feathers the edge. Painting is clipped to the area behind the mover + the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ↩︎ / ⌘Z undoes any patch.

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -267,7 +269,7 @@

Export

src:null, srcData:null, // native-res source canvas + cached pixel data layers:[], active:-1, tool:'wand', brush:40, tol:40, bg:235, wandSub:false, - cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneOff:null, // 🩹 clone-stamp: source spot + aligned offset while stroking + cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneHeal:0.55, cloneOff:null, // 🩹 clone/heal: source spot + aligned offset while stroking + infuse strength view:{zoom:1, x:0, y:0}, undo:[], redo:[], // history stacks: {id, data:ImageData} lasso:null, // in-progress lasso points (image coords) @@ -836,14 +838,18 @@

Export

// what will land where before you drag. Both rings are the same size = the copy footprint. if(S.tool==='clone'){ const rz=Math.max(3,S.brush*v.zoom/2); // ring radius on screen - if(S.cloneSrc){ const px=v.x+S.cloneSrc.x*v.zoom, py=v.y+S.cloneSrc.y*v.zoom; + if(S.cloneSrc){ + // the source centre — follows the cursor in parallel while a stroke is down (aligned), else the picked spot + const scx = (S.cloneOff && S.cursor) ? s2i(S.cursor.x,S.cursor.y).x - S.cloneOff.x : S.cloneSrc.x; + const scy = (S.cloneOff && S.cursor) ? s2i(S.cursor.x,S.cursor.y).y - S.cloneOff.y : S.cloneSrc.y; + const px=v.x+scx*v.zoom, py=v.y+scy*v.zoom; // the copy footprint at the source (solid green ring + centre dot) ctx.strokeStyle='#39d98a'; ctx.lineWidth=2.5; ctx.beginPath(); ctx.arc(px,py,rz,0,7); ctx.stroke(); ctx.fillStyle='#39d98a'; ctx.beginPath(); ctx.arc(px,py,2.5,0,7); ctx.fill(); // live ghost preview of the copied pixels under the cursor if(S.cursor){ const ri=Math.max(2,S.brush/2), di=Math.ceil(ri*2)+2; const t=document.createElement('canvas'); t.width=di; t.height=di; const tc=t.getContext('2d'); - tc.drawImage(S.src, Math.round(S.cloneSrc.x-ri),Math.round(S.cloneSrc.y-ri),di,di, 0,0,di,di); + tc.drawImage(S.src, Math.round(scx-ri),Math.round(scy-ri),di,di, 0,0,di,di); tc.globalCompositeOperation='destination-in'; tc.fillStyle='#000'; tc.beginPath(); tc.arc(di/2,di/2,ri,0,7); tc.fill(); ctx.save(); ctx.globalAlpha=0.75; ctx.imageSmoothingEnabled=true; @@ -924,16 +930,31 @@

Export

m.fillStyle = l.color; m.beginPath(); m.arc(ix,iy,S.brush/2,0,7); m.fill(); m.globalCompositeOperation='source-over'; } -// 🩹 clone-stamp one soft dab: copy a brush-sized patch of ORIGINAL source texture from the FIXED -// source spot (non-aligned — the spot stays put until you pick a new one, so you keep laying down -// the same belt/coat pattern), onto the layer's backfill — radially feathered AND clipped to the -// work area (so you can't smear outside the area behind the mover + the figure). +// 🩹 clone/heal one soft dab. Like GIMP's clone brush: the picked spot is an ANCHOR, and while you +// drag within a stroke the source MOVES in parallel (S.cloneOff, set on pointer-down) — so a drag +// copies a real moving region of belt/coat, not the same dot tiled. Each new stroke re-anchors to +// the picked spot (non-aligned across strokes → no drift). With Infuse>0 the copied patch is also +// tone-matched to the REAL pixels bordering the hole (sampled from the original around the dest), so +// it melds in seamlessly instead of looking like a pasted square. Feathered + clipped to the work area. function paintCloneDab(ix,iy){ const l=patchTarget(); if(!l||!S.cloneSrc)return; const r=Math.max(2,S.brush/2), d=Math.ceil(r*2)+2; const dx0=Math.round(ix-r), dy0=Math.round(iy-r); // dest patch top-left (image coords) - const sx0=Math.round(S.cloneSrc.x-r), sy0=Math.round(S.cloneSrc.y-r); // source ALWAYS centred on the picked spot + // aligned source: dest − offset (offset = firstTouch − pickedSpot). No offset yet → fall back to fixed spot. + const srcCx = S.cloneOff ? ix - S.cloneOff.x : S.cloneSrc.x; + const srcCy = S.cloneOff ? iy - S.cloneOff.y : S.cloneSrc.y; + const sx0=Math.round(srcCx-r), sy0=Math.round(srcCy-r); const t=document.createElement('canvas'); t.width=d; t.height=d; const tc=t.getContext('2d'); - tc.drawImage(S.src, sx0,sy0,d,d, 0,0,d,d); // cloned texture from the source spot + tc.drawImage(S.src, sx0,sy0,d,d, 0,0,d,d); // cloned texture from the (moving) source + // ✨ Infuse: shift the patch's mean colour toward the dest hole's surrounding tone so it blends in. + const heal=S.cloneHeal||0; + if(heal>0){ try{ + const sMean=meanRGB(S.src, sx0,sy0,d,d); // tone of the copied texture + const dMean=meanRGB(S.src, dx0,dy0,d,d) || sMean; // tone of the real pixels around the hole + if(sMean&&dMean){ const img=tc.getImageData(0,0,d,d), p=img.data; + const kr=heal*(dMean[0]-sMean[0]), kg=heal*(dMean[1]-sMean[1]), kb=heal*(dMean[2]-sMean[2]); + for(let i=0;iExport tc.globalCompositeOperation='source-over'; layerBackfill(l); l.bfctx.drawImage(t, dx0,dy0); } +// mean RGB of an area of an image/canvas, over its OPAQUE pixels only (transparent hole pixels ignored). +// Returns [r,g,b] or null if nothing opaque. Used by Infuse to read the hole's real surrounding tone. +let _meanCv=null; +function meanRGB(srcImg, x,y,w,h){ + if(!_meanCv){ _meanCv=document.createElement('canvas'); } + _meanCv.width=w; _meanCv.height=h; const c=_meanCv.getContext('2d'); + c.clearRect(0,0,w,h); c.drawImage(srcImg, x,y,w,h, 0,0,w,h); + const p=c.getImageData(0,0,w,h).data; let r=0,g=0,b=0,n=0; + for(let i=0;i24){ r+=p[i]; g+=p[i+1]; b+=p[i+2]; n++; } } + return n? [r/n,g/n,b/n] : null; } +function clamp8(v){ return v<0?0:v>255?255:v; } + // ✨ Auto-heal: bake the texture-aware inpaint of every (non-see-through) cut-out hole on // this layer into its backfill, in one tap. Undoable, and refinable afterwards with 🩹. function autoHealHoles(){ if(!S.img)return; let l=patchTarget(); @@ -1063,6 +1096,8 @@

Export

toast('🎯 First tap a clean patch of belt/coat to copy from, then paint over the magenta'); return; } const l=patchTarget(); if(!l)return; // paint the SHEET the hole lives in, never the selected mover drawing=true; + // anchor the moving source to this stroke's start: as you drag, source = dest − offset (GIMP-aligned within the stroke) + S.cloneOff={x: ip.x - S.cloneSrc.x, y: ip.y - S.cloneSrc.y}; S._strokeClip=grownSheet(l); // clip clone paint to base ∪ footprints (so you can rebuild belt under the arm) pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; } @@ -1109,7 +1144,7 @@

Export

S.undo.pop(); renderWarpPanel(); draw(); // undo the snapshot we took for the create } else markDirty(); } - drawing=false; lastImg=null; } + drawing=false; lastImg=null; S.cloneOff=null; } // drop the aligned offset so the next stroke re-anchors to the picked spot cv.addEventListener('pointerup', endPointer); cv.addEventListener('pointercancel', endPointer); cv.addEventListener('pointerleave', e=>{ if(!drawing)S.cursor=null; draw(); }); @@ -1138,6 +1173,7 @@

Export

S.cloneSampling=!S.cloneSampling; b.classList.toggle('on',S.cloneSampling); toast(S.cloneSampling?'Tap a clean patch of belt/coat to copy from':'Sampling off'); }; { const se=document.getElementById('soft'); if(se) se.oninput=()=>{ S.cloneSoft=+se.value/100; document.getElementById('softVal').textContent=se.value; }; } +{ const ie=document.getElementById('infuse'); if(ie) ie.oninput=()=>{ S.cloneHeal=+ie.value/100; document.getElementById('infuseVal').textContent=ie.value; }; } const brushEl=document.getElementById('brush'), tolEl=document.getElementById('tol'), bgEl=document.getElementById('bg'); brushEl.oninput=()=>{S.brush=+brushEl.value; document.getElementById('brushVal').textContent=brushEl.value;}; tolEl.oninput=()=>{S.tol=+tolEl.value; document.getElementById('tolVal').textContent=tolEl.value;}; From ad4bf28fb0ec321e6f11310799a0832cc87c5671 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:22:59 +0100 Subject: [PATCH 52/87] fix(cutter): Infuse no longer tints the patch toward the lifted arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infuse sampled the dest tone from the ORIGINAL at the hole — but the original still has the mover (arm) there, so it pulled the belt copy toward arm-skin colour (Özge: 'blending with the cutout arm part'). Now: (1) Infuse defaults to 0 = a clean area-copy + feathered stamp (her requested model); (2) when turned up, dest tone is read from a per-stroke S._cleanSrc = original with the hole footprints punched OUT, so the arm's own pixels are transparent and can never be averaged in. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 9f56e25..43d1bce 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -145,9 +145,9 @@

Tool

- - -

🩹 Patch base rebuilds the coat/belt hidden behind a mover, so when it swings away you see real texture — not a gap. Picking this tool lifts the movers off so the area behind them glows magenta (that's what you're rebuilding; the magenta recedes as you paint). Tap 🎯 Set source spot → tap a clean patch of belt/coat → then paint over the magenta. This works like a GIMP clone/heal brush: the spot you pick is the anchor, and as you drag in one stroke it copies a whole moving region (not the same dot over and over) — so you transfer a real swath of texture. Each new stroke re-anchors to the picked spot (no drift). Infuse = how strongly the copied patch is tone-matched to the real pixels bordering the hole, so it melds in seamlessly instead of looking like a pasted square. Softness feathers the edge. Painting is clipped to the area behind the mover + the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ↩︎ / ⌘Z undoes any patch.

+ + +

🩹 Patch base rebuilds the coat/belt hidden behind a mover, so when it swings away you see real texture — not a gap. Picking this tool lifts the movers off so the area behind them glows magenta (that's what you're rebuilding; the magenta recedes as you paint). Tap 🎯 Set source spot → tap a clean patch of belt/coat → then paint over the magenta. This works like a GIMP clone/heal brush: the spot you pick is the anchor, and as you drag in one stroke it copies a whole moving region (not the same dot over and over) — so you transfer a real swath of texture. Each new stroke re-anchors to the picked spot (no drift). Infuse (default 0 = off) optionally tone-matches the copied patch to the real coat bordering the hole — the lifted mover's own pixels (e.g. the arm) are excluded, so it never tints toward the arm. Leave it at 0 for a clean copy; nudge it up only if a patch's tone looks off against its surroundings. Softness feathers the edge so overlapping stamps connect smoothly. Painting is clipped to the area behind the mover + the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ↩︎ / ⌘Z undoes any patch.

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -269,7 +269,7 @@

Export

src:null, srcData:null, // native-res source canvas + cached pixel data layers:[], active:-1, tool:'wand', brush:40, tol:40, bg:235, wandSub:false, - cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneHeal:0.55, cloneOff:null, // 🩹 clone/heal: source spot + aligned offset while stroking + infuse strength + cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneHeal:0, cloneOff:null, // 🩹 clone/heal: source spot + aligned offset while stroking + infuse strength (0 = clean copy) view:{zoom:1, x:0, y:0}, undo:[], redo:[], // history stacks: {id, data:ImageData} lasso:null, // in-progress lasso points (image coords) @@ -945,11 +945,13 @@

Export

const sx0=Math.round(srcCx-r), sy0=Math.round(srcCy-r); const t=document.createElement('canvas'); t.width=d; t.height=d; const tc=t.getContext('2d'); tc.drawImage(S.src, sx0,sy0,d,d, 0,0,d,d); // cloned texture from the (moving) source - // ✨ Infuse: shift the patch's mean colour toward the dest hole's surrounding tone so it blends in. + // ✨ Infuse: shift the patch's mean colour toward the REAL coat bordering the hole so it blends in. + // Dest tone comes from S._cleanSrc (original with the hole punched out) → the lifted mover's own + // pixels (the arm) are transparent there and can't be averaged in. Default heal 0 = clean raw copy. const heal=S.cloneHeal||0; if(heal>0){ try{ const sMean=meanRGB(S.src, sx0,sy0,d,d); // tone of the copied texture - const dMean=meanRGB(S.src, dx0,dy0,d,d) || sMean; // tone of the real pixels around the hole + const dMean=meanRGB(S._cleanSrc||S.src, dx0,dy0,d,d) || sMean;// tone of the REAL coat around the hole (arm punched out) if(sMean&&dMean){ const img=tc.getImageData(0,0,d,d), p=img.data; const kr=heal*(dMean[0]-sMean[0]), kg=heal*(dMean[1]-sMean[1]), kb=heal*(dMean[2]-sMean[2]); for(let i=0;iExport for(let i=0;i24){ r+=p[i]; g+=p[i+1]; b+=p[i+2]; n++; } } return n? [r/n,g/n,b/n] : null; } function clamp8(v){ return v<0?0:v>255?255:v; } +// A copy of the original source with the layer's hole footprints (the lifted movers, e.g. the arm) +// punched OUT (transparent). Infuse reads dest tone from this so it matches the real coat bordering +// the hole and never averages in the mover's own pixels. Built once per stroke, cleared on pointer-up. +function cleanSrcFor(l){ + const w=S.src.width,h=S.src.height; const c=document.createElement('canvas'); c.width=w; c.height=h; + const cx=c.getContext('2d'); cx.drawImage(S.src,0,0); + const kids=holeKids(l).filter(k=>!k.seeThrough && k.visible); + cx.globalCompositeOperation='destination-out'; for(const k of kids) cx.drawImage(k.mc,0,0); + cx.globalCompositeOperation='source-over'; return c; } // ✨ Auto-heal: bake the texture-aware inpaint of every (non-see-through) cut-out hole on // this layer into its backfill, in one tap. Undoable, and refinable afterwards with 🩹. @@ -1098,6 +1109,7 @@

Export

drawing=true; // anchor the moving source to this stroke's start: as you drag, source = dest − offset (GIMP-aligned within the stroke) S.cloneOff={x: ip.x - S.cloneSrc.x, y: ip.y - S.cloneSrc.y}; + S._cleanSrc=cleanSrcFor(l); // original with the hole punched out — so Infuse tone-matches the real coat, never the lifted arm S._strokeClip=grownSheet(l); // clip clone paint to base ∪ footprints (so you can rebuild belt under the arm) pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; } @@ -1144,7 +1156,7 @@

Export

S.undo.pop(); renderWarpPanel(); draw(); // undo the snapshot we took for the create } else markDirty(); } - drawing=false; lastImg=null; S.cloneOff=null; } // drop the aligned offset so the next stroke re-anchors to the picked spot + drawing=false; lastImg=null; S.cloneOff=null; S._cleanSrc=null; } // drop the aligned offset + clean-source cache; next stroke re-anchors to the picked spot cv.addEventListener('pointerup', endPointer); cv.addEventListener('pointercancel', endPointer); cv.addEventListener('pointerleave', e=>{ if(!drawing)S.cursor=null; draw(); }); From 48270bb7e8ab8f9acb0ba73701884e9ed0aabca4 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:34:31 +0100 Subject: [PATCH 53/87] fix(cutter): fixed-source stamp (not aligned) + much bigger brush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Özge 'nope' on the aligned clone: over a big hole, dragging swept the source off the thin clean belt into the arm/coat and stamped garbage. Reverted to her stated model — a FIXED picked source area stamped at each touched point ('area copy on the set area, stamp on the clicked points with smooth connections'); feathered overlap = smooth joins. Also the root 'looks like one colour' cause: brush capped at 200px on a 2400px image (she had 21px) → tiny patch tiled. Brush max 200->600, default 40->120 so one stamp grabs a real swath. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 43d1bce..b17f4d8 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -131,8 +131,8 @@

Tool

- - + +
@@ -147,7 +147,7 @@

Tool

-

🩹 Patch base rebuilds the coat/belt hidden behind a mover, so when it swings away you see real texture — not a gap. Picking this tool lifts the movers off so the area behind them glows magenta (that's what you're rebuilding; the magenta recedes as you paint). Tap 🎯 Set source spot → tap a clean patch of belt/coat → then paint over the magenta. This works like a GIMP clone/heal brush: the spot you pick is the anchor, and as you drag in one stroke it copies a whole moving region (not the same dot over and over) — so you transfer a real swath of texture. Each new stroke re-anchors to the picked spot (no drift). Infuse (default 0 = off) optionally tone-matches the copied patch to the real coat bordering the hole — the lifted mover's own pixels (e.g. the arm) are excluded, so it never tints toward the arm. Leave it at 0 for a clean copy; nudge it up only if a patch's tone looks off against its surroundings. Softness feathers the edge so overlapping stamps connect smoothly. Painting is clipped to the area behind the mover + the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ↩︎ / ⌘Z undoes any patch.

+

🩹 Patch base rebuilds the coat/belt hidden behind a mover, so when it swings away you see real texture — not a gap. Picking this tool lifts the movers off so the area behind them glows magenta (that's what you're rebuilding; the magenta recedes as you paint). Tap 🎯 Set source spot → tap a clean patch of belt/coat → then stamp over the magenta (tap, or drag to lay a trail). Every stamp copies that same picked area (a brush-sized chunk — the green ring shows exactly what's copied), dropped at each point you touch — so the source stays put on the clean belt and never drifts into the arm. Make the brush big so each stamp grabs a real swath of texture (small brush = tiny patch tiled = looks flat). Re-tap 🎯 on a different clean spot to grab a different bit (buckle vs strap). Softness feathers each stamp so overlapping ones connect smoothly. Infuse (default 0 = off) optionally tone-matches each stamp to the real coat bordering the hole (the lifted arm's pixels are excluded, so it never tints toward the arm) — nudge it up only if a patch's tone looks off. Painting is clipped to the area behind the mover + the figure. It paints onto a non-destructive back-layer under the base (never touches the mover) and bakes under the base on export. ↩︎ / ⌘Z undoes any patch.

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -268,7 +268,7 @@

Export

img:null, iw:0, ih:0, src:null, srcData:null, // native-res source canvas + cached pixel data layers:[], active:-1, - tool:'wand', brush:40, tol:40, bg:235, wandSub:false, + tool:'wand', brush:120, tol:40, bg:235, wandSub:false, cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneHeal:0, cloneOff:null, // 🩹 clone/heal: source spot + aligned offset while stroking + infuse strength (0 = clean copy) view:{zoom:1, x:0, y:0}, undo:[], redo:[], // history stacks: {id, data:ImageData} @@ -839,9 +839,7 @@

Export

if(S.tool==='clone'){ const rz=Math.max(3,S.brush*v.zoom/2); // ring radius on screen if(S.cloneSrc){ - // the source centre — follows the cursor in parallel while a stroke is down (aligned), else the picked spot - const scx = (S.cloneOff && S.cursor) ? s2i(S.cursor.x,S.cursor.y).x - S.cloneOff.x : S.cloneSrc.x; - const scy = (S.cloneOff && S.cursor) ? s2i(S.cursor.x,S.cursor.y).y - S.cloneOff.y : S.cloneSrc.y; + const scx = S.cloneSrc.x, scy = S.cloneSrc.y; // fixed source — the picked spot you stamp from const px=v.x+scx*v.zoom, py=v.y+scy*v.zoom; // the copy footprint at the source (solid green ring + centre dot) ctx.strokeStyle='#39d98a'; ctx.lineWidth=2.5; ctx.beginPath(); ctx.arc(px,py,rz,0,7); ctx.stroke(); @@ -939,9 +937,9 @@

Export

function paintCloneDab(ix,iy){ const l=patchTarget(); if(!l||!S.cloneSrc)return; const r=Math.max(2,S.brush/2), d=Math.ceil(r*2)+2; const dx0=Math.round(ix-r), dy0=Math.round(iy-r); // dest patch top-left (image coords) - // aligned source: dest − offset (offset = firstTouch − pickedSpot). No offset yet → fall back to fixed spot. - const srcCx = S.cloneOff ? ix - S.cloneOff.x : S.cloneSrc.x; - const srcCy = S.cloneOff ? iy - S.cloneOff.y : S.cloneSrc.y; + // FIXED source: every dab copies the SAME picked area ("stamp the set area at the clicked points"). + // Non-aligned on purpose — over a big hole an aligned source sweeps off the clean belt into the arm. + const srcCx = S.cloneSrc.x, srcCy = S.cloneSrc.y; const sx0=Math.round(srcCx-r), sy0=Math.round(srcCy-r); const t=document.createElement('canvas'); t.width=d; t.height=d; const tc=t.getContext('2d'); tc.drawImage(S.src, sx0,sy0,d,d, 0,0,d,d); // cloned texture from the (moving) source @@ -1107,8 +1105,6 @@

Export

toast('🎯 First tap a clean patch of belt/coat to copy from, then paint over the magenta'); return; } const l=patchTarget(); if(!l)return; // paint the SHEET the hole lives in, never the selected mover drawing=true; - // anchor the moving source to this stroke's start: as you drag, source = dest − offset (GIMP-aligned within the stroke) - S.cloneOff={x: ip.x - S.cloneSrc.x, y: ip.y - S.cloneSrc.y}; S._cleanSrc=cleanSrcFor(l); // original with the hole punched out — so Infuse tone-matches the real coat, never the lifted arm S._strokeClip=grownSheet(l); // clip clone paint to base ∪ footprints (so you can rebuild belt under the arm) pushBackfillUndo(l); l._cut=null; paintCloneDab(ip.x,ip.y); lastImg=ip; draw(); return; From 0fe91b3992ef0546e5e04e429f5b19bb54a7d87e Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:43:06 +0100 Subject: [PATCH 54/87] =?UTF-8?q?feat(cutter):=20=E2=96=AD=20Copy-a-box=20?= =?UTF-8?q?patch=20mode=20(region=20copy=E2=86=92paste),=20now=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rounds of 'it copies one colour': the round clone-brush samples a disc, and from a fairly uniform coat area that genuinely reads as one tone — wrong paradigm for her mental model ('area copy on the set area, stamp on the clicked points'). Added a real marquee: ▭ Grab a box → drag a rectangle over clean belt/coat → captures that exact patch (buckle, strap, weave and all) into S.clonePatch → tap/drag over the magenta to stamp it at each point, with a live ghost preview and feathered edges (blurred inset-rect mask) so overlaps connect smoothly. Clipped to the grown sheet, onto the non-destructive backfill, undoable. Box is the default mode; the old round brush stays under '🖌 Brush stamp'. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 98 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 4 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index b17f4d8..287a566 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -141,13 +141,23 @@

Tool

Two fingers (or mouse-wheel) to zoom & pan. The coloured overlay shows what's selected. Switch the wand to ✖ removes to un-select a wrong flood — or just ↩︎/↪︎ in the top bar (⌘Z / ⇧⌘Z) to undo/redo any step. Pivot = the joint the part rotates around.

@@ -270,6 +280,7 @@

Export

layers:[], active:-1, tool:'wand', brush:120, tol:40, bg:235, wandSub:false, cloneSrc:null, cloneSampling:false, cloneSoft:0.6, cloneHeal:0, cloneOff:null, // 🩹 clone/heal: source spot + aligned offset while stroking + infuse strength (0 = clean copy) + cloneMode:'box', clonePatch:null, boxCapturing:false, boxDrag:null, // ▭ box copy-paste: grab a rectangle of texture, then stamp that exact patch view:{zoom:1, x:0, y:0}, undo:[], redo:[], // history stacks: {id, data:ImageData} lasso:null, // in-progress lasso points (image coords) @@ -836,7 +847,24 @@

Export

// 🩹 clone: show EXACTLY what's copied. The source spot is ringed at brush size (= the patch that // gets copied), and a live ghost of those very pixels is previewed under the cursor so you can see // what will land where before you drag. Both rings are the same size = the copy footprint. - if(S.tool==='clone'){ + if(S.tool==='clone' && S.cloneMode==='box'){ + // ▭ box copy-paste overlay: the rectangle being grabbed, then a ghost of the grabbed patch under the cursor + if(S.boxDrag){ const b=S.boxDrag; + const x=v.x+Math.min(b.x0,b.x1)*v.zoom, y=v.y+Math.min(b.y0,b.y1)*v.zoom; + const w=Math.abs(b.x1-b.x0)*v.zoom, h=Math.abs(b.y1-b.y0)*v.zoom; + ctx.strokeStyle='#39d98a'; ctx.lineWidth=2; ctx.setLineDash([7,5]); ctx.strokeRect(x,y,w,h); ctx.setLineDash([]); + } else if(S.clonePatch && S.cursor){ // ghost of the grabbed patch, centred on the cursor + const pw=S.clonePatch.width*v.zoom, ph=S.clonePatch.height*v.zoom; + ctx.save(); ctx.globalAlpha=0.7; ctx.imageSmoothingEnabled=true; + ctx.drawImage(S.clonePatch, S.cursor.x-pw/2, S.cursor.y-ph/2, pw, ph); ctx.restore(); + ctx.strokeStyle='#39d98a'; ctx.lineWidth=1.5; ctx.setLineDash([6,4]); + ctx.strokeRect(S.cursor.x-pw/2, S.cursor.y-ph/2, pw, ph); ctx.setLineDash([]); + } else if(S.cursor && S.boxCapturing){ // armed to grab → amber hint crosshair + ctx.strokeStyle='#ffd166cc'; ctx.lineWidth=1.5; + ctx.beginPath(); ctx.moveTo(S.cursor.x-12,S.cursor.y); ctx.lineTo(S.cursor.x+12,S.cursor.y); + ctx.moveTo(S.cursor.x,S.cursor.y-12); ctx.lineTo(S.cursor.x,S.cursor.y+12); ctx.stroke(); + } + } else if(S.tool==='clone'){ const rz=Math.max(3,S.brush*v.zoom/2); // ring radius on screen if(S.cloneSrc){ const scx = S.cloneSrc.x, scy = S.cloneSrc.y; // fixed source — the picked spot you stamp from @@ -986,6 +1014,35 @@

Export

cx.globalCompositeOperation='destination-out'; for(const k of kids) cx.drawImage(k.mc,0,0); cx.globalCompositeOperation='source-over'; return c; } +// ▭ Grab the dragged rectangle of ORIGINAL texture into S.clonePatch (the thing you then stamp). +// This copies a real rectangle — buckle, strap, weave and all — so what you stamp keeps its detail, +// not a single averaged tone. Too-small drags are rejected (an accidental tap). +function captureBox(b){ + const x0=Math.min(b.x0,b.x1), y0=Math.min(b.y0,b.y1); + const w=Math.round(Math.abs(b.x1-b.x0)), h=Math.round(Math.abs(b.y1-b.y0)); + if(w<6||h<6){ toast('Box too small — drag a rectangle over a clean bit of belt/coat'); return false; } + const c=document.createElement('canvas'); c.width=w; c.height=h; + c.getContext('2d').drawImage(S.src, Math.round(x0),Math.round(y0),w,h, 0,0,w,h); + S.clonePatch=c; S.cloneMode='box'; + toast('▭ Grabbed a '+w+'×'+h+' patch — now tap or drag over the magenta to stamp it'); return true; } + +// ▭ Stamp the grabbed patch centred at (ix,iy), with feathered edges so overlapping stamps connect +// smoothly, clipped to the work area (base ∪ hole footprints). Onto the non-destructive backfill. +function pasteClonePatch(ix,iy){ const l=patchTarget(); if(!l||!S.clonePatch)return; + const pw=S.clonePatch.width, ph=S.clonePatch.height; + const dx0=Math.round(ix-pw/2), dy0=Math.round(iy-ph/2); + const t=document.createElement('canvas'); t.width=pw; t.height=ph; const tc=t.getContext('2d'); + tc.drawImage(S.clonePatch,0,0); + // feather: a blurred inset rectangle as the alpha mask → soft edges incl. corners (smooth joins) + const f=Math.max(0, Math.min(Math.min(pw,ph)/2-1, (S.cloneSoft||0)*Math.min(pw,ph)*0.45)); + tc.globalCompositeOperation='destination-in'; + tc.filter = f>0.5?('blur('+f.toFixed(1)+'px)'):'none'; tc.fillStyle='#000'; + tc.fillRect(f,f, pw-2*f, ph-2*f); tc.filter='none'; + const clip=S._strokeClip||l.mc; // ∩ base ∪ footprints (still destination-in → intersect) + tc.drawImage(clip, dx0,dy0,pw,ph, 0,0,pw,ph); + tc.globalCompositeOperation='source-over'; + layerBackfill(l); l.bfctx.drawImage(t, dx0,dy0); } + // ✨ Auto-heal: bake the texture-aware inpaint of every (non-see-through) cut-out hole on // this layer into its backfill, in one tap. Undoable, and refinable afterwards with 🩹. function autoHealHoles(){ if(!S.img)return; let l=patchTarget(); @@ -1097,6 +1154,16 @@

Export

if(S.tool==='wand'){ wandAt(ip.x,ip.y, S.wandSub); return; } if(S.tool==='lasso'){ S.lasso=[ip]; drawing=true; return; } if(S.tool==='clone'){ + // ▭ box mode: grab a rectangle, or stamp the grabbed patch + if(S.boxCapturing){ S.boxDrag={x0:ip.x,y0:ip.y,x1:ip.x,y1:ip.y}; drawing=true; return; } + if(S.cloneMode==='box'){ + if(!S.clonePatch){ S.boxCapturing=true; const gb=document.getElementById('boxGrab'); if(gb)gb.classList.add('on'); + S.boxDrag={x0:ip.x,y0:ip.y,x1:ip.x,y1:ip.y}; drawing=true; + toast('Drag a rectangle over a clean bit of belt/coat to copy it'); return; } + const l=patchTarget(); if(!l)return; drawing=true; + S._strokeClip=grownSheet(l); + pushBackfillUndo(l); l._cut=null; pasteClonePatch(ip.x,ip.y); lastImg=ip; draw(); return; + } if(S.cloneSampling){ S.cloneSrc={x:Math.round(ip.x),y:Math.round(ip.y)}; S.cloneSampling=false; const b=document.getElementById('cloneSample'); if(b)b.classList.remove('on'); draw(); toast('🎯 Source set — now paint over the hole'); return; } @@ -1130,6 +1197,13 @@

Export

const ip=s2i(sp.x,sp.y); if(S.tool==='lasso'){ S.lasso.push(ip); draw(); return; } if(S.tool==='clone'){ + if(S.boxDrag){ S.boxDrag.x1=ip.x; S.boxDrag.y1=ip.y; draw(); return; } // ▭ sizing the grab rectangle + if(S.cloneMode==='box'){ // ▭ stamp the patch along the drag trail + if(S.clonePatch){ const sp2=Math.max(6,Math.min(S.clonePatch.width,S.clonePatch.height)/3); + if(lastImg){ const dx=ip.x-lastImg.x, dy=ip.y-lastImg.y; const steps=Math.max(1,Math.hypot(dx,dy)/sp2); + for(let s=1;s<=steps;s++)pasteClonePatch(lastImg.x+dx*s/steps, lastImg.y+dy*s/steps); } + else pasteClonePatch(ip.x,ip.y); lastImg=ip; draw(); } + return; } if(lastImg){ const dx=ip.x-lastImg.x, dy=ip.y-lastImg.y; const steps=Math.max(1,Math.hypot(dx,dy)/(S.brush/3)); for(let s=1;s<=steps;s++)paintCloneDab(lastImg.x+dx*s/steps, lastImg.y+dy*s/steps); } else paintCloneDab(ip.x,ip.y); @@ -1143,6 +1217,8 @@

Export

}); function endPointer(e){ S.pointers.delete(e.pointerId); if(S.pointers.size<2)S.pinch=null; + if(S.boxDrag){ captureBox(S.boxDrag); S.boxDrag=null; S.boxCapturing=false; // ▭ finish grabbing the rectangle + const gb=document.getElementById('boxGrab'); if(gb)gb.classList.remove('on'); drawing=false; lastImg=null; draw(); return; } if(S.tool==='lasso'&&drawing&&S.lasso){ fillLasso(S.lasso); S.lasso=null; } if(S.faceDrag){ S.faceDrag=null; markDirty(); } if(S.warpDrag){ const wd=S.warpDrag; S.warpDrag=null; @@ -1172,14 +1248,28 @@

Export

if(target){ const ti=S.layers.indexOf(target); if(ti>=0 && ti!==S.active){ S.active=ti; renderLayerList(); } } const n = target?openHoleKids(target).length:0; const stOnly = target ? holeKids(target).filter(k=>k.seeThrough) : []; // holes hidden from patching because they're see-through - toast(n ? ('🩹 Patching '+target.name+' — '+n+' area'+(n>1?'s':'')+' glow magenta. Tap 🎯 Set source on clean belt/coat, then paint over the magenta.') + toast(n ? ('🩹 Patching '+target.name+' — '+n+' area'+(n>1?'s':'')+' glow magenta. Tap ▭ Grab a box, drag a rectangle over clean belt/coat, then stamp it over the magenta.') : stOnly.length ? ('🔲 '+target.name+'’s hole is set SEE-THROUGH (kept as a real gap) — untick 🔲 see-through on '+stOnly[0].name+' to patch it') : '🩹 No holes yet — tick “cut out” on a mover first, then Patch rebuilds what’s behind it'); - } else { S.cloneSampling=false; const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); } + } else { S.cloneSampling=false; S.boxCapturing=false; S.boxDrag=null; + const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); + const gb=document.getElementById('boxGrab'); if(gb)gb.classList.remove('on'); } draw(); }); document.getElementById('cloneSample').onclick=()=>{ const b=document.getElementById('cloneSample'); S.cloneSampling=!S.cloneSampling; b.classList.toggle('on',S.cloneSampling); toast(S.cloneSampling?'Tap a clean patch of belt/coat to copy from':'Sampling off'); }; +function setCloneMode(m){ S.cloneMode=m; S.cloneSampling=false; S.boxCapturing=false; S.boxDrag=null; + document.getElementById('modeBox').classList.toggle('on', m==='box'); + document.getElementById('modeBrush').classList.toggle('on', m==='brush'); + document.getElementById('boxRow').style.display = m==='box' ? 'flex':'none'; + document.getElementById('brushRow').style.display= m==='brush'? 'flex':'none'; + const cs=document.getElementById('cloneSample'); if(cs)cs.classList.remove('on'); + const gb=document.getElementById('boxGrab'); if(gb)gb.classList.remove('on'); draw(); } +document.getElementById('modeBox').onclick=()=>setCloneMode('box'); +document.getElementById('modeBrush').onclick=()=>setCloneMode('brush'); +document.getElementById('boxGrab').onclick=()=>{ const gb=document.getElementById('boxGrab'); + S.cloneMode='box'; S.clonePatch=null; S.boxCapturing=true; S.boxDrag=null; gb.classList.add('on'); + toast('Drag a rectangle over a clean bit of belt/coat to copy it'); draw(); }; { const se=document.getElementById('soft'); if(se) se.oninput=()=>{ S.cloneSoft=+se.value/100; document.getElementById('softVal').textContent=se.value; }; } { const ie=document.getElementById('infuse'); if(ie) ie.oninput=()=>{ S.cloneHeal=+ie.value/100; document.getElementById('infuseVal').textContent=ie.value; }; } const brushEl=document.getElementById('brush'), tolEl=document.getElementById('tol'), bgEl=document.getElementById('bg'); From 84aa34055aa2da5aef6f35d0050e6b02c0ec1d63 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:26:54 +0100 Subject: [PATCH 55/87] fix(cutter): Infuse ignores near-black ink outline when tone-matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infuse shifted the whole patch toward the mean colour of the real coat bordering the hole. Near the top of the figure that border includes the black ink outline / dark hair, dragging the mean (and the patch) dark. meanRGB now takes an optional minLuma and skips near-black pixels (INK_DARK=48) for both source and dest tone, matching coat→coat; falls back to the plain mean if a region is entirely dark. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 287a566..cf4a7f4 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -976,8 +976,12 @@

Export

// pixels (the arm) are transparent there and can't be averaged in. Default heal 0 = clean raw copy. const heal=S.cloneHeal||0; if(heal>0){ try{ - const sMean=meanRGB(S.src, sx0,sy0,d,d); // tone of the copied texture - const dMean=meanRGB(S._cleanSrc||S.src, dx0,dy0,d,d) || sMean;// tone of the REAL coat around the hole (arm punched out) + // Tone-match COAT→COAT: skip near-black ink outline / dark hair pixels (INK_DARK) so the border + // mean reflects the coat colour, not the outline — else patching near the top of the figure drags + // the whole patch dark. Fall back to the plain mean if a region is entirely dark. + const INK_DARK=48; + const sMean=meanRGB(S.src, sx0,sy0,d,d, INK_DARK) || meanRGB(S.src, sx0,sy0,d,d); // tone of the copied texture + const dMean=meanRGB(S._cleanSrc||S.src, dx0,dy0,d,d, INK_DARK) || meanRGB(S._cleanSrc||S.src, dx0,dy0,d,d) || sMean; // REAL coat around the hole (arm + ink outline excluded) if(sMean&&dMean){ const img=tc.getImageData(0,0,d,d), p=img.data; const kr=heal*(dMean[0]-sMean[0]), kg=heal*(dMean[1]-sMean[1]), kb=heal*(dMean[2]-sMean[2]); for(let i=0;iExport // mean RGB of an area of an image/canvas, over its OPAQUE pixels only (transparent hole pixels ignored). // Returns [r,g,b] or null if nothing opaque. Used by Infuse to read the hole's real surrounding tone. let _meanCv=null; -function meanRGB(srcImg, x,y,w,h){ +function meanRGB(srcImg, x,y,w,h, minLuma){ if(!_meanCv){ _meanCv=document.createElement('canvas'); } _meanCv.width=w; _meanCv.height=h; const c=_meanCv.getContext('2d'); c.clearRect(0,0,w,h); c.drawImage(srcImg, x,y,w,h, 0,0,w,h); const p=c.getImageData(0,0,w,h).data; let r=0,g=0,b=0,n=0; - for(let i=0;i24){ r+=p[i]; g+=p[i+1]; b+=p[i+2]; n++; } } + for(let i=0;i255?255:v; } // A copy of the original source with the layer's hole footprints (the lifted movers, e.g. the arm) From 15ff3f76853f242fbf125a59af1964a980972ee6 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:06:05 +0100 Subject: [PATCH 56/87] =?UTF-8?q?feat(cutter):=20=E2=9C=A8=20Auto-heal=20n?= =?UTF-8?q?ow=20uses=20multi-scale=20PatchMatch=20content-aware=20fill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old auto-heal was a multi-source BFS nearest-colour fill + blur — fine for a solid body, but a radial smear on a big structured region (a face/coat behind a lifted limb), which is why large magenta holes never filled right. New engine (pmComplete + patchMatchInpaint): Barnes-2009 PatchMatch NNF (propagation + random search) with Wexler/Simakov coherence voting, run coarse→fine over an image pyramid so real texture/structure propagates into the hole centre. Runs on a ≤384px downscaled crop around the hole for speed (<1s) then bilinear-upscales the filled region back; source is sampled only from real coat inside the grown sheet (outside-sheet pixels excluded). Deferred one frame behind a toast so the UI shows progress. Falls back to the old inpaintRGB if there is no clean source. Verified in Node: reconstructs a stripe pattern to ~0 error vs ~111 for the flat mean, no NaN/gaps. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 118 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 7 deletions(-) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index cf4a7f4..105c5e9 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -703,6 +703,104 @@

Export

else { out[p*3]=a[p*3];out[p*3+1]=a[p*3+1];out[p*3+2]=a[p*3+2]; } } return {out, hole, filled}; } + +// ── Multi-scale PatchMatch content-aware fill ────────────────────────────────── +// Reconstructs a BIG structured hole (a face/coat behind a lifted limb) by copying real +// PATCHES of the surrounding texture — not a radial nearest-colour smear. Barnes 2009 +// PatchMatch NNF (propagation + random search) + Wexler/Simakov coherence voting, run +// coarse→fine over an image pyramid so structure propagates into the hole centre. +// Pure typed arrays; verified in Node (reconstructs a stripe pattern to ~0 error). +function _mulberry32(a){ return function(){ a|=0; a=a+0x6D2B79F5|0; let t=Math.imul(a^a>>>15,1|a); t=t+Math.imul(t^t>>>7,61|t)^t; return ((t^t>>>14)>>>0)/4294967296; }; } +function _downRGB(rgb,w,h,nw,nh){ const out=new Float32Array(nw*nh*3); + for(let y=0;y=2?1:0; } + return out; } +function _bfsInit(content,hole,W,H){ const Nn=W*H,fl=new Uint8Array(Nn),q=new Int32Array(Nn); let hd=0,tl=0,sr=0,sg=0,sb=0,sn=0; + for(let p=0;p0,x0,ypw-1?pw-1:x0); y0=y0<0?0:(y0>ph-1?ph-1:y0); const x1=Math.min(pw-1,x0+1),y1=Math.min(ph-1,y0+1); + let tx=fx-x0; tx=tx<0?0:(tx>1?1:tx); let ty=fy-y0; ty=ty<0?0:(ty>1?1:ty); + for(let c=0;c<3;c++){ const a=prev[(y0*pw+x0)*3+c],b=prev[(y0*pw+x1)*3+c],cc=prev[(y1*pw+x0)*3+c],dd=prev[(y1*pw+x1)*3+c]; + content[p*3+c]=(a*(1-tx)+b*tx)*(1-ty)+(cc*(1-tx)+dd*tx)*ty; } } } +function _emLevel(content,real,hole,W,H,P,emIters,rng){ const Nn=W*H,posW=W-P+1,posH=H-P+1; if(posW<=0||posH<=0) return; + const integ=new Int32Array((W+1)*(H+1)); + for(let y=0;y{ const x2=x+P,y2=y+P; return integ[y2*(W+1)+x2]-integ[y*(W+1)+x2]-integ[y2*(W+1)+x]+integ[y*(W+1)+x]; }; + const srcList=[],srcValid=new Uint8Array(posW*posH),tgtActive=new Uint8Array(posW*posH); + for(let y=0;y=best) return s; } return s; } + for(let y=0;y=0&&nbx=0){ const sx=sp%posW+step,sy=(sp/posW)|0; + if(sx>=0&&sx=0&&nby=0){ const sx=sp%posW,sy=((sp/posW)|0)+step; + if(sy>=0&&sy=1){ const rx=bsx+(((rng()*2-1)*radius)|0),ry=bsy+(((rng()*2-1)*radius)|0); + if(rx>=0&&rx=0&&ry0){ content[p*3]=acc[p*3]/wgt[p];content[p*3+1]=acc[p*3+1]/wgt[p];content[p*3+2]=acc[p*3+2]/wgt[p]; } + } } +function pmComplete(fullRGB,fullHole,W,H,P,seed){ const rng=_mulberry32(seed>>>0||1); + const pyr=[{w:W,h:H,rgb:fullRGB,hole:fullHole}]; let w=W,h=H,rgb=fullRGB,hole=fullHole; + while(Math.min(w,h)>Math.max(24,P*2)){ const nw=Math.max(1,w>>1),nh=Math.max(1,h>>1); rgb=_downRGB(rgb,w,h,nw,nh); hole=_downHole(hole,w,h,nw,nh); pyr.unshift({w:nw,h:nh,rgb,hole}); w=nw;h=nh; } + let prev=null; + for(let i=0;i8){ sheet[p]=1; let hf=1; for(const kd of holeMasks) hf*=(1-kd[o+3]/255); + if(hf<0.5){ hole[p]=1; any=true; if(xhx1)hx1=x;if(yhy1)hy1=y; } } } + if(!any) return null; + const MARG=Math.round(Math.max(W,H)*0.05)+10; // context ring of real coat to sample from + const rx0=Math.max(0,hx0-MARG), ry0=Math.max(0,hy0-MARG), rx1=Math.min(W-1,hx1+MARG), ry1=Math.min(H-1,hy1+MARG); + const rw=rx1-rx0+1, rh=ry1-ry0+1, WORK=384, sc=Math.min(1,WORK/Math.max(rw,rh)); + const ww=Math.max(8,Math.round(rw*sc)), wh=Math.max(8,Math.round(rh*sc)), P=7; + const wrgb=new Float32Array(ww*wh*3), whole=new Uint8Array(ww*wh); + for(let y=0;yww-1?ww-1:x0); y0=y0<0?0:(y0>wh-1?wh-1:y0); const x1=Math.min(ww-1,x0+1),y1=Math.min(wh-1,y0+1); + let tx=gx-x0;tx=tx<0?0:(tx>1?1:tx); let ty=gy-y0;ty=ty<0?0:(ty>1?1:ty); + for(let c=0;c<3;c++){ const a=filled[(y0*ww+x0)*3+c],b=filled[(y0*ww+x1)*3+c],cc=filled[(y1*ww+x0)*3+c],dd=filled[(y1*ww+x1)*3+c]; + out[p*3+c]=(a*(1-tx)+b*tx)*(1-ty)+(cc*(1-tx)+dd*tx)*ty; } } + return {out, hole}; +} // Live skin-fill poured into the cut-out holes at composite time (gated by the 🩹 checkbox). function skinFillRGB(ownMask, holeMasks){ if(!S.skinFill || !holeMasks || !holeMasks.length) return null; @@ -1066,12 +1164,18 @@

Export

// mover — including the part outside the original figure mask — instead of only the thin overlap. const md=grownSheet(l).getContext('2d',{willReadFrequently:true}).getImageData(0,0,S.iw,S.ih).data; const holeMasks=kids.map(k=>k.mctx.getImageData(0,0,S.iw,S.ih).data); - const res=inpaintRGB(md, holeMasks, 1); if(!res){ toast('Nothing to heal'); return; } - pushBackfillUndo(l); layerBackfill(l); - const img=l.bfctx.getImageData(0,0,S.iw,S.ih), dd=img.data, {out,hole}=res; - for(let p=0;p{ setTimeout(()=>{ + let res=null; try{ res=patchMatchInpaint(md, holeMasks); }catch(e){ res=null; } // PatchMatch content-aware fill + if(!res) res=inpaintRGB(md, holeMasks, 1); // fallback: nearest-colour fill + if(!res){ toast('Nothing to heal'); return; } + pushBackfillUndo(l); layerBackfill(l); + const img=l.bfctx.getImageData(0,0,S.iw,S.ih), dd=img.data, {out,hole}=res; + for(let p=0;pExport window.__lc = { S, loadImage, buildExport, buildHostPayload, wandAt, zipStore, applyMode, collectExpressions, poseOf, EXPRS, draw, ensurePose, renderWarpPanel, renderExprPose, fieldDisp, doUndo, doRedo, migrateAllWarp, mlsRigid, mlsControls, facsPose, facsRole, applyFacs, buildCut, punchKids, splitHoles, skinFillRGB, makeLayer, - inpaintRGB, layerBackfill, paintCloneDab, autoHealHoles }; + inpaintRGB, patchMatchInpaint, pmComplete, layerBackfill, paintCloneDab, autoHealHoles }; From 20da64d4e58831f6874f26bb4a0501aeedc0e511 Mon Sep 17 00:00:00 2001 From: 7storm7 <23375221+7storm7@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:23:50 +0100 Subject: [PATCH 57/87] =?UTF-8?q?fix(cutter):=20add=20the=20missing=20?= =?UTF-8?q?=E2=9C=A8=20Auto-heal=20button=20to=20the=20=F0=9F=A9=B9=20Patc?= =?UTF-8?q?h=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autoHealHoles() existed + was exported but had no UI trigger — no button was ever wired to it, so PatchMatch auto-heal was unreachable. Added a prominent full-width ✨ Auto-heal button at the top of the Patch-tool panel (#cloneParams) → autoHealHoles(). Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/layer_cutter.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html index 105c5e9..855d633 100644 --- a/tools/layer_cutter.html +++ b/tools/layer_cutter.html @@ -140,6 +140,9 @@

Tool