From a5610219a6102224a3a02918088329b3e2cec170 Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 25 Sep 2026 23:28:22 -0400 Subject: [PATCH 1/2] feat(examples): skull sprite matching the reference New examples/skull/: a 40x40 3/4-view skull drawn in pixelsrc after reference-skull.jpg (moved here from avatar-prototype). It has the domed cranium swelling back-left, warm bone ramp, angular orbits with lighter inner walls, notched nasal cavity, cheekbone plane, shadowed temple, and a separate mandible with individual teeth. The jaw is its own sprite, so skull_talking.gif animates closed/ajar/open poses hinged at the ear. Includes 1x/8x renders, the GIF, a reference-vs-render comparison (compare.py), a README with render commands, and GAPS.md with minimal repros for five compiler gaps: random region order without z, ignored auto-outline/repeat/transform, fill seeding outside concave shapes, compositions unable to offset a layer, and derived sprites needing palette. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PXBCFZMofE9EGKzo8J2YKJ --- examples/skull/GAPS.md | 90 ++++ examples/skull/README.md | 55 +++ examples/skull/compare.py | 31 ++ .../reference-skull.jpg | Bin examples/skull/skull.png | Bin 0 -> 1789 bytes examples/skull/skull.pxl | 432 ++++++++++++++++++ examples/skull/skull_8x.png | Bin 0 -> 4599 bytes examples/skull/skull_comparison.png | Bin 0 -> 59813 bytes examples/skull/skull_talking.gif | Bin 0 -> 44542 bytes 9 files changed, 608 insertions(+) create mode 100644 examples/skull/GAPS.md create mode 100644 examples/skull/README.md create mode 100644 examples/skull/compare.py rename examples/{avatar-prototype => skull}/reference-skull.jpg (100%) create mode 100644 examples/skull/skull.png create mode 100644 examples/skull/skull.pxl create mode 100644 examples/skull/skull_8x.png create mode 100644 examples/skull/skull_comparison.png create mode 100644 examples/skull/skull_talking.gif diff --git a/examples/skull/GAPS.md b/examples/skull/GAPS.md new file mode 100644 index 00000000..a74e537d --- /dev/null +++ b/examples/skull/GAPS.md @@ -0,0 +1,90 @@ +# Gaps found while drawing the skull + +These are places where the compiler couldn't express what the art needed, or +where it didn't behave the way the docs describe. Each one has a minimal repro +and a note on how `skull.pxl` works around it. Checked against `pxl` built from +this branch. + +## 1. Region paint order is random unless every region has `z` + +```json5 +{ type: "palette", name: "p", colors: { _: "transparent", a: "#ff0000", b: "#0000ff", c: "#00ff00", d: "#ffff00" } } +{ type: "sprite", name: "order", size: [4, 4], palette: "p", regions: { + a: { rect: [0, 0, 4, 4] }, + b: { rect: [1, 1, 3, 3] }, + c: { rect: [2, 2, 2, 2] }, + d: { points: [[3, 3]] }, +} } +``` + +The docs (format/regions.md, "Z-Order") say the default is definition order. +`Sprite.regions` is a `HashMap`, though, and `render_structured` stable-sorts +by z, so equal-z regions paint in hash order. That order changes from process +to process: rendering the repro 6 times gave 3 different PNGs. **Workaround:** +every region in `skull.pxl` has an explicit, unique `z`. The fix is probably an +`IndexMap` for `regions`, which would also make `fill`/`except` dependency +order match the source. + +## 2. `auto-outline` (and several other region modifiers) does nothing + +```json5 +{ type: "palette", name: "p", colors: { _: "transparent", body: "#e6dccd", outline: "#000000" } } +{ type: "sprite", name: "blob", size: [8, 8], palette: "p", regions: { + body: { rect: [2, 2, 4, 4], z: 0 }, + outline: { "auto-outline": "body", thickness: 1, z: 1 }, +} } +``` + +`pxl mask --count` reports 16 `body` pixels and 0 `outline` pixels. +`RegionDef` parses `auto-outline`, `repeat`/`spacing`/`offset-alternate`, +`transform`, `jitter`, `round` and the `x`/`y` range limits, but +`structured.rs::rasterize_region` never reads any of them, so they are dropped +without a warning. **Workaround:** the outline is drawn explicitly as a closed +polyline. The teeth are listed one by one instead of using `repeat`. + +## 3. `fill: "inside(X)"` can flood the outside of a concave shape + +```json5 +{ type: "palette", name: "p", colors: { _: "transparent", bone: "#e6dccd", outline: "#000000" } } +{ type: "sprite", name: "cup", size: [10, 10], palette: "p", regions: { + outline: { z: 1, line: [[0, 0], [3, 0], [3, 6], [6, 6], [6, 0], [9, 0], [9, 9], [0, 9], [0, 0]] }, + bone: { z: 0, fill: "inside(outline)" }, +} } +``` + +The seed is the centre of the boundary's bounding box, or the nearest free +pixel to it. For the "U" that pixel is in the notch, so `bone` fills the notch +and leaks out of the top of the canvas, and the inside of the U stays empty. +The docs offer `seed: [x, y]`, but `RegionDef.seed` is the `u32` jitter seed +and `flood_fill` is always called with `None`. A related problem: a `fill` +nested inside `union`/`intersect` isn't deferred the way a top-level `fill` is, +so it can run before its boundary exists and fill the whole canvas. +**Workaround:** the cranium keeps its `fill` (its centre is inside). The +mandible is concave, so its interior is a `path` polygon with the same vertices +as its outline. Every `fill` is top-level. + +## 4. Compositions can't offset a layer + +```json5 +{ type: "palette", name: "p", colors: { _: "transparent", x: "#ff0000" } } +{ type: "sprite", name: "dot", size: [4, 4], palette: "p", regions: { x: { points: [[0, 0]] } } } +{ type: "sprite", name: "dot_moved", palette: "p", source: "dot", transform: ["shift:2,2"] } +{ type: "composition", name: "scene", size: [4, 4], cell_size: [4, 4], + sprites: { D: "dot_moved" }, layers: [{ map: ["D"] }] } +``` + +`pxl render -s dot_moved` puts the dot at (2,2), but `pxl render -c scene` puts +it at (0,0). The composition path (`render_composition_to_image`) renders the +resolved regions and skips the derived sprite's image `transform`. The +`{ sprite, x, y }` layer form in format/composition.md doesn't help either: it +renders an empty canvas. The only offset left is `cell_size` with a +mismatched map, and that warns on every render. **Workaround:** the talking +poses `skull_jaw_ajar` and `skull_jaw_open` are written out in full as their +own sprites, not as `shift`-ed copies of `skull_jaw`. + +## 5. Derived sprites still need `palette` + +The docs example `{ type: "sprite", name: "hero_outlined", source: "hero", +transform: [...] }` fails with `missing field 'palette'`. In `skull.pxl`, +the composition after that line then wasn't found either. **Workaround:** not needed after gap 4, but any +`source:` sprite has to repeat `palette`. diff --git a/examples/skull/README.md b/examples/skull/README.md new file mode 100644 index 00000000..de16c05c --- /dev/null +++ b/examples/skull/README.md @@ -0,0 +1,55 @@ +# Skull + +A 3/4-view skull facing right, drawn in pixelsrc after `reference-skull.jpg`. +It's a 40x40 sprite on a transparent background, and the jaw is a separate +sprite so the skull can talk. + +![reference vs render](skull_comparison.png) + +![talking](skull_talking.gif) + +## Files + +| File | What it is | +|------|------------| +| `skull.pxl` | Source: the `bone` palette, the `skull_cranium` and `skull_jaw` sprites, two open-jaw poses (`skull_jaw_ajar`, `skull_jaw_open`), the `skull` / `skull_talk_*` compositions and the `skull_talking` animation | +| `skull.png` | The still at 1x (40x40) | +| `skull_8x.png` | The still at 8x (320x320) | +| `skull_talking.gif` | The talking loop at 8x | +| `reference-skull.jpg` | The target art | +| `skull_comparison.png` | Reference and render side by side, both skulls scaled to the same height | +| `compare.py` | Builds `skull_comparison.png` (needs Pillow) | +| `GAPS.md` | Things the compiler couldn't express, with minimal repros | + +## Render + +From the repo root, after `cargo build --release`: + +```sh +pxl=target/release/pxl +$pxl render examples/skull/skull.pxl -c skull -o examples/skull/skull.png +$pxl render examples/skull/skull.pxl -c skull --scale 8 -o examples/skull/skull_8x.png +$pxl render examples/skull/skull.pxl --gif --animation skull_talking --scale 8 -o examples/skull/skull_talking.gif +python3 examples/skull/compare.py +``` + +## How it's built + +- **Silhouette.** `outline` is a closed polyline (`line` with many points), + and the cranium's `bone` is `fill: "inside(outline)"`. +- **Shading.** The back-of-skull shadow is `mid: { fill: "inside(outline)", + except: ["lit"] }`, where `lit` is a transparent mask polygon covering the + lit side. Other planes are small `path` polygons: the shadowed temple, the + cheekbone underside and the far side of the face. The dome highlight is a + `path` too. +- **Detail.** The angular orbits are `path` polygons with a lighter inner `rim` + polygon. The nasal notch, tooth roots, ramus edge and chin ridge are `points` + and `line`s. The teeth are two pixels each: a lit column (`tooth`) over the + beige base. A transparent `gap` region at z 200 punches the see-through hole + at the corner of the mouth. +- **Jaw.** `skull_jaw` is drawn over the cranium. Where it meets the skull, its + top edge is a soft `seam`/`gum` line instead of a hard outline. The cranium + has a dark mouth interior hidden behind the jaw, and that interior shows when + the jaw opens. The open poses swing the mandible around the hinge: the chin + drops 1px (ajar) or 2px (open) and the hinge stays put. +- **Order.** Every region has an explicit `z` (see GAPS.md). diff --git a/examples/skull/compare.py b/examples/skull/compare.py new file mode 100644 index 00000000..e92e527e --- /dev/null +++ b/examples/skull/compare.py @@ -0,0 +1,31 @@ +"""Build skull_comparison.png: the reference beside pixelsrc's render. + + python3 examples/skull/compare.py (needs Pillow; run from the repo root) + +Expects examples/skull/skull.png to be rendered first (see README.md). +""" +from pathlib import Path + +from PIL import Image, ImageDraw + +HERE = Path(__file__).parent +ref = Image.open(HERE / "reference-skull.jpg").convert("RGB") +mine = Image.open(HERE / "skull.png").convert("RGBA") + +# Crop both to the skull, then scale the render (nearest neighbour) so the +# two skulls are the same height on screen. +ref = ref.crop((50, 20, 285, 300)) +mine = mine.crop(mine.getbbox()) +k = 8 +mine = mine.resize((mine.width * k, mine.height * k), Image.NEAREST) +ref = ref.resize((round(ref.width * mine.height / ref.height), mine.height), Image.NEAREST) + +pad, label_h = 16, 24 +out = Image.new("RGB", (ref.width + mine.width + pad * 3, mine.height + pad * 2 + label_h), "white") +out.paste(ref, (pad, pad + label_h)) +out.paste(mine, (ref.width + pad * 2, pad + label_h), mine) +draw = ImageDraw.Draw(out) +draw.text((pad, pad), "reference-skull.jpg", fill=(80, 76, 72)) +draw.text((ref.width + pad * 2, pad), "skull.pxl -> pxl render (40x40, shown 8x)", fill=(80, 76, 72)) +out.save(HERE / "skull_comparison.png") +print("Saved:", HERE / "skull_comparison.png") diff --git a/examples/avatar-prototype/reference-skull.jpg b/examples/skull/reference-skull.jpg similarity index 100% rename from examples/avatar-prototype/reference-skull.jpg rename to examples/skull/reference-skull.jpg diff --git a/examples/skull/skull.png b/examples/skull/skull.png new file mode 100644 index 0000000000000000000000000000000000000000..3cb036ddeb134e504c5cbbbf4ada8a0eabbce120 GIT binary patch literal 1789 zcmZuydrVVT7{9y?1g_6?6VnR1I9X!Z9D&W!s{%4asZJbO+*%Wp8HcVQQ3{ma4q^&H zf|DQ&wP=`@KPHWP2uS4?#}JuN9NU-!X({x9jF!Ia?c?6Np4&mT#H2mF>HW_4{T|o@Ld%dSTB;@XCNqYASZKAz9?i-9d={u$5#ezRH>zY z=!xQN?upJbllT7poVqB#vR^5GXJ?|tbH3~I^4qdqg}*E9EL}U-NsKsbDO!9{uaymB zzh+mOeOFEP9F{LZ1sQw9BdL;J(bEKeCd+lxDwLLr7gBW+xXk=h*^LFJ{W!m+LN0kr zU~kfft0%;>U3KW|`mCkHEH5c})FlaHniJs4GA@1E1Av5A)KPl>*%4 zmebeMMspG4ngd59FWdaj1d=#S-9yJKle10*;PAa~Hym%Ze^1S6NPCGWFP|R{Wa!bR z^RT9<`+y;`rU~uOV0w#!ssw3k_4j3*7KU`V#Ir({+BlIu*UuRPTLBvR;EJHRW@EO6DeFA1B3fxLSHkLD4_v*)So{}#wWey_ z1rgA(PJeyc`&=a9zBs{+l@^pem@v)r&`2KY-6`^pxAlW^rR58yvsqHn4&bbnHWrvg zT^9QPuo_A*^Ug9wmn4u&I~`8t&>IPWpdR!H?EJG?RBUmBl@C=hbbtW+$#~n#^;~xyw~=AI%eLt` zT|f_dYh6u%jCWlA(BW_@(l4M5kBH}5M`9ulF99i3$pXhwftsMzVR`0Rx?g;nbMwMAhQdDEq^lhv3RRnV=wC-5{FZ>qu#|c401_eiw;JDi`6M9VX;pedJ1d5& zdw`ojK?rZx;^!oD3RFs0@5X!;{8dR*l>%AZ4sqsg!U0_aG=_knbi{7NK8~nk99s|~ zY|46qt8Wx>J5)7t@(0CIE=J14=BU1H5X<`|Z0e!q1dzJ5EMidUNK(4Js?De~9vj7^ z!*%9n2?2Y?U@?#10_yp5X9Kt@I`IE#_t!N#Tv*O_lyK|ucoBe zG~Z=cW*W3Qko6o~mTdKs)+jZI3|(?6kZKucZV%*tD)he(J3%M-2&2#+$xck7_JLZ! zu(Am^icAyL+{2L2WmFahTYfp=a#T;C)Y<15%qPGtdMH3d8!^&|GY)x5yO&?P(O;Td zsZIIh2HLhuGVZmg`v7LAm68OJK)|IqHRxUl1VI6$=@C+3ld7h}A4T^~;EnSnv;=m6 zR$Gw{kbD)U4R{I}5l%&eF~!;Y41)P!b7e5?o0;}8S&3#`XaIlFZ4723R4pV2g+3f`AQ>_;G<`oxoAA-N8>zqN3@i&SZ^Y1OLTWoVf&8 zkG$4|Rx3S&LWafwZU*8c>e$Mkxy5`;(DgAeoOCX55Yce5b{mi(aPr5{cS-CL*rJ=j zziA*=TS^0fBUXH6hYq0M(JjXg-W7~E8xr3{J;SrxzW(jV{(C