diff --git a/docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json b/docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json
new file mode 100644
index 00000000..73f9c7db
--- /dev/null
+++ b/docs/superpowers/contracts/artgate-phase4-diffusion-2026-08-15.json
@@ -0,0 +1,20 @@
+{
+ "title": "artgate Phase 4: diffusion route, 3D cell, full vector coverage, and the margin gate",
+ "objective": "Close the remaining bake-off cells - route (b) local diffusion on the 3090, route (d) 3D via headless Blender, and the other seven vector subjects - so all four routes are measured on the same twelve subjects. The phase's real outcome was a missing gate: diffusion output passed everything while being unusable, which forced MARGIN_TOO_SMALL into existence and inverted the decision table.",
+ "acceptance_criteria": [
+ "diffusers runs on the GPU in the isolated uv 3.12 venv, with model gating status verified at execution time rather than recalled.",
+ "Route (b) generates all twelve subjects locally with no gated download on the critical path.",
+ "Route (d) covers all twelve vector subjects, making its number comparable to route (a)'s.",
+ "Route (d) 3D renders via headless Blender, with a phantom-success guard: Blender exits 0 even when its embedded script raises.",
+ "A margin gate rejects full-bleed assets, because an item icon must be a sprite with a silhouette, not a square tile.",
+ "Every gate failure observed on real output is verified as a true positive before any threshold or shape is changed.",
+ "100% branch coverage, zero lint suppressions, all pre-commit hooks pass."
+ ],
+ "out_of_scope": [
+ "Deciding which route WINS. That needs the human contact-sheet verdicts, which are the user's call; this change only produces the machine column and the sheets.",
+ "A production matting solution for diffusion output. The flood-fill background removal demonstrated recoverability but visibly damages the art, and is reported as inadequate rather than shipped as the answer.",
+ "Modelling all twelve subjects in 3D. Five meshes answer whether render->finish clears the gates; twelve would be modelling work, not a route comparison.",
+ "Gated model families (FLUX.1-dev, SD3.x). Verified gated at execution time and deliberately kept off the critical path."
+ ],
+ "verifier": "python -m pytest python_pkg/artgate/tests/ --cov=python_pkg.artgate --cov-branch --cov-fail-under=100 (114 tests, 100% branch on 13 modules); pre-commit run over all artgate files; plus end-to-end generation and gating of all four routes against styles/pixel_icon.toml, with each observed failure traced to a specific measurable defect."
+}
diff --git a/docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json b/docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json
new file mode 100644
index 00000000..2494e8c6
--- /dev/null
+++ b/docs/superpowers/evidence/artgate-phase4-diffusion-2026-08-15.json
@@ -0,0 +1,61 @@
+{
+ "intent": "Run the diffusion route on the 3090, wire the Blender 3D cell, and complete vector coverage to twelve subjects - then report what the four routes actually measure against each other.",
+ "scope": [
+ "python_pkg/artgate/contrast.py - new check_margin gate",
+ "python_pkg/artgate/routes/{vector_shapes,blender,blender_scene}.py - 7 more vector subjects, 3D route",
+ "python_pkg/artgate/{cli,codes,spec}.py - margin gate wired, evaluate() refactored to a gate table",
+ "python_pkg/artgate/routes/{shapes,vector_shapes}.py - sword/meat shape fixes for edge clipping",
+ "python_pkg/artgate/tests/test_blender.py + margin tests - 22 new tests",
+ "NON-GOAL: no route declared the winner; that needs the human verdicts"
+ ],
+ "changes": [
+ "THE FINDING OF THIS PHASE: all twelve diffusion icons measured 100% opaque coverage - square tiles with no sprite silhouette - and passed every gate. Gate-pass was measuring hygiene, not usability. Added MARGIN_TOO_SMALL, which inverted the table from a meaningless 12/12/12 tie to diffusion 0/12, vector 10/12, procedural 11/12.",
+ "The two non-diffusion failures were true positives in MY shapes, not gate error: the procedural sword pommel ran off the bottom edge and the vector meat bone clipped the top. Fixed the shapes; both routes returned to 12/12.",
+ "Blender exits 0 even when its embedded script raises - measured, not assumed: a wrong render-engine enum (BLENDER_EEVEE_NEXT does not exist in 5.2) produced a clean exit and no file. render() now asserts the output exists, or the route would report phantom successes.",
+ "Model gating checked at execution time, not from memory: SDXL base and nerijs/pixel-art-xl are ungated (HTTP 200, gated=False), FLUX.1-dev is gated=auto, and runwayml/stable-diffusion-v1-5 now 307-redirects - which is exactly why the plan forbids recalling gating status.",
+ "evaluate() became an ordered gate table rather than an if-chain, keeping the load-bearing alpha-before-colour ordering explicit.",
+ "routes/__init__.py imports vector_shapes so all_shapes() cannot silently return a partial registry depending on import order."
+ ],
+ "verification": [
+ {
+ "command": "GPU + diffusers smoke test in the uv 3.12 venv",
+ "result": "pass",
+ "evidence": "torch 2.11.0+cu128, diffusers 0.39.0, transformers 5.15.0, cuda True, RTX 3090, 22.1 GiB free."
+ },
+ {
+ "command": "generate 12 subjects with SDXL + pixel-art LoRA, gate raw then finished",
+ "result": "pass",
+ "evidence": "RAW 1024px: 0/12 (CANVAS_SIZE, TOO_MANY_COLORS). After finishing: 12/12 on the pre-margin gate stack. After adding MARGIN_TOO_SMALL: 0/12, because every output was fully opaque."
+ },
+ {
+ "command": "measure opaque coverage across routes",
+ "result": "pass",
+ "evidence": "diffusion mean 100% (12/12 fully opaque, no transparency at all); vector mean 35% (min 18%, max 47%, 0/12 fully opaque). This is the quantified reason diffusion output is not usable as an item sprite."
+ },
+ {
+ "command": "diffusion + flood-fill background removal, re-gated",
+ "result": "pass",
+ "evidence": "12/12 machine-pass, so the route IS recoverable - but visual inspection shows the fill eats into the subjects (bone reduced to a fragment, sword nearly erased). Reported as inadequate; a real matting step is needed."
+ },
+ {
+ "command": "headless Blender render of 5 subjects, gate raw then finished",
+ "result": "pass",
+ "evidence": "RAW: 0/5 (CANVAS_SIZE, ALPHA_NOT_BINARY, TOO_MANY_COLORS). After the same finishing stage: 5/5. The finisher now generalises across three different continuous-tone sources."
+ },
+ {
+ "command": "python -m pytest python_pkg/artgate/tests/ --cov --cov-branch; pre-commit run",
+ "result": "pass",
+ "evidence": "114 tests, 100% branch coverage on all 13 modules, zero suppressions, ALL HOOKS PASS."
+ }
+ ],
+ "risks": [
+ "The flood-fill background removal is a demonstration, not a solution - it damages the art. Do not treat diffusion's recovered 12/12 as a usable pipeline until proper matting exists.",
+ "The 3D cell covers 5 of 12 subjects and its materials render washed out; its contact-sheet row is visibly the weakest and should not be read as a fair 3D result.",
+ "A local pytest run masked a missing branch (spec.py:233) that the pre-commit hook caught. The hook's run is authoritative; a green local run is not sufficient evidence.",
+ "All four routes now sit at or near 12/12 on the machine gate again, which continues to mean 'not malformed'. The decision table still needs the human accept column before any route can be called the winner."
+ ],
+ "rollback": [
+ "Revert this commit; the Phase 3 harness and routes at 691a228 stand alone. The margin gate is the only change that alters existing verdicts, and removing it restores the previous (misleadingly permissive) results.",
+ "After rollback, re-run the full suite and confirm 100% branch coverage, then re-gate the stored route outputs to confirm the expected pre-margin numbers return."
+ ]
+}
diff --git a/python_pkg/artgate/cli.py b/python_pkg/artgate/cli.py
index e7f1ec97..9713e535 100644
--- a/python_pkg/artgate/cli.py
+++ b/python_pkg/artgate/cli.py
@@ -15,6 +15,7 @@ import argparse
import json
from pathlib import Path
import sys
+from typing import TYPE_CHECKING
import numpy as np
from PIL import Image, UnidentifiedImageError
@@ -25,7 +26,7 @@ from python_pkg.artgate.codes import (
EXIT_PASS,
Code,
)
-from python_pkg.artgate.contrast import check_silhouette
+from python_pkg.artgate.contrast import check_margin, check_silhouette
from python_pkg.artgate.pixels import (
check_alpha_binary,
check_color_count,
@@ -34,6 +35,27 @@ from python_pkg.artgate.pixels import (
)
from python_pkg.artgate.spec import SpecError, TargetSpec, load_spec
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+def _check_canvas(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
+ """Verify the image matches the spec's exact canvas size.
+
+ Args:
+ rgba: An ``(h, w, 4)`` uint8 array.
+ spec: The target specification.
+
+ Returns:
+ ``[CANVAS_SIZE]`` on a mismatch, else empty.
+ """
+ if spec.canvas is None:
+ return []
+ height, width = rgba.shape[:2]
+ if (width, height) != spec.canvas:
+ return [Code.CANVAS_SIZE]
+ return []
+
def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
"""Run every gate the spec declares applicable.
@@ -49,27 +71,29 @@ def evaluate(rgba: np.ndarray, spec: TargetSpec) -> list[Code]:
Returns:
The failing reason codes, empty if the asset passes.
"""
+ # A table rather than an if-chain: adding a gate is one entry, and the
+ # ORDER is explicit and load-bearing -- alpha must precede the colour
+ # count, or a soft-alpha image yields zero opaque pixels and passes.
+ checks: list[tuple[Code, Callable[[], list[Code]]]] = [
+ (Code.CANVAS_SIZE, lambda: _check_canvas(rgba, spec)),
+ (Code.ALPHA_NOT_BINARY, lambda: check_alpha_binary(rgba)),
+ (
+ Code.TOO_MANY_COLORS,
+ lambda: check_color_count(rgba, spec.max_colors or 0),
+ ),
+ (Code.OFF_PALETTE, lambda: check_palette(rgba, spec.palette)),
+ (
+ Code.SCALE_NOT_INVARIANT,
+ lambda: check_scale_invariance(rgba, spec.scale),
+ ),
+ (Code.SILHOUETTE_LOW_CONTRAST, lambda: check_silhouette(rgba)),
+ (Code.MARGIN_TOO_SMALL, lambda: check_margin(rgba, spec.min_margin)),
+ ]
+
failures: list[Code] = []
-
- if spec.runs(Code.CANVAS_SIZE) and spec.canvas is not None:
- height, width = rgba.shape[:2]
- if (width, height) != spec.canvas:
- failures.append(Code.CANVAS_SIZE)
-
- if spec.runs(Code.ALPHA_NOT_BINARY) and spec.alpha_binary:
- failures.extend(check_alpha_binary(rgba))
-
- if spec.runs(Code.TOO_MANY_COLORS) and spec.max_colors is not None:
- failures.extend(check_color_count(rgba, spec.max_colors))
-
- if spec.runs(Code.OFF_PALETTE) and spec.palette:
- failures.extend(check_palette(rgba, spec.palette))
-
- if spec.runs(Code.SCALE_NOT_INVARIANT):
- failures.extend(check_scale_invariance(rgba, spec.scale))
-
- if spec.runs(Code.SILHOUETTE_LOW_CONTRAST):
- failures.extend(check_silhouette(rgba))
+ for code, run in checks:
+ if spec.runs(code):
+ failures.extend(run())
deduped: list[Code] = []
for code in failures:
diff --git a/python_pkg/artgate/codes.py b/python_pkg/artgate/codes.py
index 4daabb67..1408ebb4 100644
--- a/python_pkg/artgate/codes.py
+++ b/python_pkg/artgate/codes.py
@@ -69,6 +69,7 @@ DECLARABLE: Final = frozenset(
Code.CANVAS_SIZE,
Code.SCALE_NOT_INVARIANT,
Code.SILHOUETTE_LOW_CONTRAST,
+ Code.MARGIN_TOO_SMALL,
}
)
diff --git a/python_pkg/artgate/contrast.py b/python_pkg/artgate/contrast.py
index e9f670a1..5cb82e2d 100644
--- a/python_pkg/artgate/contrast.py
+++ b/python_pkg/artgate/contrast.py
@@ -62,6 +62,37 @@ def silhouette_delta(rgba: NDArray[np.uint8], background: int) -> float:
return abs(subject - float(background))
+def check_margin(rgba: NDArray[np.uint8], min_margin: int) -> list[Code]:
+ """Verify a transparent border surrounds the subject.
+
+ An item icon must be a *sprite*, not a square tile: without a transparent
+ margin it cannot sit on an inventory slot or a world background. This gate
+ exists because it was missed -- twelve diffusion icons scored 100% opaque
+ coverage and passed every other gate, because nothing checked whether the
+ asset had a silhouette at all.
+
+ Args:
+ rgba: An ``(h, w, 4)`` uint8 array.
+ min_margin: Required clear border width in pixels.
+
+ Returns:
+ ``[MARGIN_TOO_SMALL]`` if any edge band contains opaque pixels.
+ """
+ if min_margin <= 0:
+ return []
+ alpha = rgba[:, :, 3]
+ band = min_margin
+ edges = (
+ alpha[:band, :],
+ alpha[-band:, :],
+ alpha[:, :band],
+ alpha[:, -band:],
+ )
+ if any(int(edge.max(initial=0)) > 0 for edge in edges):
+ return [Code.MARGIN_TOO_SMALL]
+ return []
+
+
def check_silhouette(
rgba: NDArray[np.uint8],
min_delta: float = DEFAULT_MIN_DELTA,
diff --git a/python_pkg/artgate/routes/__init__.py b/python_pkg/artgate/routes/__init__.py
index 8b117d8f..657aa225 100644
--- a/python_pkg/artgate/routes/__init__.py
+++ b/python_pkg/artgate/routes/__init__.py
@@ -1 +1,14 @@
-"""Generation routes for the art bake-off."""
+"""Generation routes for the art bake-off.
+
+Importing this package registers every vector subject, so ``all_shapes()``
+never depends on the caller having imported the right module first. That
+import-order hazard is exactly the kind of silent partial result the gates
+exist to prevent -- a half-registered route would score a real-looking but
+wrong number in the decision table.
+"""
+
+from __future__ import annotations
+
+from python_pkg.artgate.routes import vector_shapes as _vector_shapes
+
+__all__ = ["_vector_shapes"]
diff --git a/python_pkg/artgate/routes/blender.py b/python_pkg/artgate/routes/blender.py
new file mode 100644
index 00000000..c8dfa8b4
--- /dev/null
+++ b/python_pkg/artgate/routes/blender.py
@@ -0,0 +1,95 @@
+"""Route (d) 3D: drive Blender headless and render one subject.
+
+Blender runs as a subprocess with its own interpreter, so the scene is passed
+as a generated script file rather than imported. Failures are surfaced as
+exceptions -- a missing Blender or a non-zero exit is reported, never silently
+skipped, because a quietly-absent route would show up in the decision table as
+a legitimate zero.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+
+from python_pkg.artgate.routes.blender_scene import (
+ RENDER_SIZE,
+ SUBJECTS_3D,
+ scene_script,
+)
+
+# Blender can take a while on first run (shader compilation); give it room.
+RENDER_TIMEOUT_S = 300
+
+
+class BlenderError(RuntimeError):
+ """Raised when Blender is unavailable or the render fails."""
+
+
+def render(name: str, out: Path, size: int = RENDER_SIZE) -> Path:
+ """Render one 3D subject to a transparent PNG.
+
+ Args:
+ name: One of :data:`SUBJECTS_3D`.
+ out: Destination PNG path.
+ size: Square render edge length.
+
+ Returns:
+ The written path.
+
+ Raises:
+ KeyError: If the subject has no 3D build.
+ BlenderError: If Blender is missing, times out, or exits non-zero.
+ """
+ if name not in SUBJECTS_3D:
+ msg = f"no 3D scene for {name!r}"
+ raise KeyError(msg)
+ binary = shutil.which("blender")
+ if binary is None:
+ msg = "blender is not installed"
+ raise BlenderError(msg)
+
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(
+ "w", suffix=".py", delete=False, encoding="utf-8"
+ ) as handle:
+ handle.write(scene_script(size))
+ script = Path(handle.name)
+
+ try:
+ result = subprocess.run(
+ [
+ binary,
+ "--background",
+ "--factory-startup",
+ "--python",
+ str(script),
+ "--",
+ name,
+ str(out.with_suffix("")),
+ ],
+ capture_output=True,
+ check=False,
+ timeout=RENDER_TIMEOUT_S,
+ )
+ except subprocess.TimeoutExpired as exc:
+ msg = f"blender timed out rendering {name}"
+ raise BlenderError(msg) from exc
+ finally:
+ script.unlink(missing_ok=True)
+
+ if result.returncode != 0:
+ detail = result.stderr.decode(errors="replace")[-400:]
+ msg = f"blender failed for {name}: {detail}"
+ raise BlenderError(msg)
+
+ # Blender exits 0 even when the embedded script raises -- measured against
+ # 5.2, where a bad render-engine enum produced a clean exit and no file.
+ # Trusting the return code alone would report a phantom success.
+ if not out.exists():
+ detail = result.stderr.decode(errors="replace")[-400:]
+ msg = f"blender wrote no file for {name} despite exit 0: {detail}"
+ raise BlenderError(msg)
+ return out
diff --git a/python_pkg/artgate/routes/blender_scene.py b/python_pkg/artgate/routes/blender_scene.py
new file mode 100644
index 00000000..d0ec51fd
--- /dev/null
+++ b/python_pkg/artgate/routes/blender_scene.py
@@ -0,0 +1,146 @@
+"""Route (d) 3D: a Blender scene script, run headless.
+
+This module is *emitted* and executed by ``blender --background --python``, so
+it must not import anything from this package -- the Blender interpreter has
+no path to it. It is kept here as a data file rather than a library so the
+repo's linters still see it as Python.
+
+The 3D cell exists to answer one question: does a rendered mesh, put through
+the same finishing stage as the vector route, clear the pixel gates? The
+render is continuous-tone by nature, so the expectation is the same as every
+other continuous route -- it needs the finisher.
+"""
+
+from __future__ import annotations
+
+from typing import Final
+
+# Rendered square edge length before downscaling. Kept small: the finisher
+# reduces to 32px anyway, and Cycles time scales with pixel count.
+RENDER_SIZE: Final = 256
+
+# Executed inside Blender's own Python. Written as a template rather than
+# imported, because Blender cannot see this package.
+SCENE_TEMPLATE: Final = """
+import sys
+import bpy
+import mathutils
+
+SUBJECT = sys.argv[-2]
+OUT = sys.argv[-1]
+
+bpy.ops.wm.read_factory_settings(use_empty=True)
+scene = bpy.context.scene
+scene.render.engine = "BLENDER_EEVEE"
+scene.render.resolution_x = {size}
+scene.render.resolution_y = {size}
+scene.render.film_transparent = True
+scene.render.image_settings.file_format = "PNG"
+scene.render.image_settings.color_mode = "RGBA"
+scene.render.filepath = OUT
+
+
+def material(name, rgba):
+ mat = bpy.data.materials.new(name)
+ mat.use_nodes = True
+ bsdf = mat.node_tree.nodes["Principled BSDF"]
+ bsdf.inputs["Base Color"].default_value = rgba
+ bsdf.inputs["Roughness"].default_value = 0.45
+ return mat
+
+
+GOLD = (0.84, 0.64, 0.24, 1.0)
+CYAN = (0.36, 0.69, 0.75, 1.0)
+RED = (0.69, 0.24, 0.24, 1.0)
+BONE = (0.78, 0.73, 0.67, 1.0)
+
+
+def add(obj, rgba):
+ obj.data.materials.append(material(obj.name, rgba))
+ return obj
+
+
+def build_coin():
+ bpy.ops.mesh.primitive_cylinder_add(radius=1.1, depth=0.16, vertices=48)
+ add(bpy.context.object, GOLD)
+ bpy.ops.mesh.primitive_torus_add(major_radius=1.0, minor_radius=0.08)
+ add(bpy.context.object, GOLD)
+
+
+def build_gem():
+ bpy.ops.mesh.primitive_cone_add(radius1=1.0, depth=1.2, vertices=8)
+ add(bpy.context.object, CYAN)
+ bpy.ops.mesh.primitive_cone_add(radius1=1.0, depth=0.7, vertices=8)
+ bpy.context.object.rotation_euler = (3.14159, 0, 0)
+ bpy.context.object.location = (0, 0, 0.95)
+ add(bpy.context.object, CYAN)
+
+
+def build_ring():
+ bpy.ops.mesh.primitive_torus_add(major_radius=0.85, minor_radius=0.16)
+ add(bpy.context.object, GOLD)
+ bpy.ops.mesh.primitive_ico_sphere_add(radius=0.34, subdivisions=1)
+ bpy.context.object.location = (0, 0, 0.95)
+ add(bpy.context.object, CYAN)
+
+
+def build_bone():
+ bpy.ops.mesh.primitive_cylinder_add(radius=0.22, depth=1.8, vertices=16)
+ add(bpy.context.object, BONE)
+ for z in (0.9, -0.9):
+ for x in (-0.22, 0.22):
+ bpy.ops.mesh.primitive_uv_sphere_add(radius=0.34, segments=16)
+ bpy.context.object.location = (x, 0, z)
+ add(bpy.context.object, BONE)
+
+
+def build_bomb():
+ bpy.ops.mesh.primitive_uv_sphere_add(radius=1.0, segments=32)
+ add(bpy.context.object, (0.16, 0.15, 0.17, 1.0))
+ bpy.ops.mesh.primitive_cylinder_add(radius=0.16, depth=0.6, vertices=12)
+ bpy.context.object.location = (0.25, 0, 1.15)
+ add(bpy.context.object, RED)
+
+
+BUILDERS = {{
+ "coin": build_coin,
+ "gem": build_gem,
+ "ring": build_ring,
+ "bone": build_bone,
+ "bomb": build_bomb,
+}}
+
+BUILDERS[SUBJECT]()
+
+bpy.ops.object.camera_add(location=(0, -4.2, 2.6))
+camera = bpy.context.object
+camera.rotation_euler = mathutils.Euler((1.02, 0, 0))
+camera.data.type = "ORTHO"
+camera.data.ortho_scale = 3.0
+scene.camera = camera
+
+bpy.ops.object.light_add(type="SUN", location=(2.5, -3.0, 5.0))
+bpy.context.object.data.energy = 4.0
+bpy.ops.object.light_add(type="AREA", location=(-3.0, -2.0, 2.0))
+bpy.context.object.data.energy = 90.0
+
+bpy.ops.render.render(write_still=True)
+"""
+
+
+def scene_script(size: int = RENDER_SIZE) -> str:
+ """Return the Blender scene script for a given render size.
+
+ Args:
+ size: Square render edge length in pixels.
+
+ Returns:
+ Python source to hand to ``blender --python``.
+ """
+ return SCENE_TEMPLATE.format(size=size)
+
+
+# Subjects with a 3D build. Deliberately a subset: a mesh for "scroll" or
+# "meat" would be modelling work, not a route comparison, and the cell's
+# purpose is to test whether render->finish clears the gates at all.
+SUBJECTS_3D: Final = ("coin", "gem", "ring", "bone", "bomb")
diff --git a/python_pkg/artgate/routes/shapes.py b/python_pkg/artgate/routes/shapes.py
index 973cf677..5b43b1f2 100644
--- a/python_pkg/artgate/routes/shapes.py
+++ b/python_pkg/artgate/routes/shapes.py
@@ -95,8 +95,8 @@ def _sword(c: NDArray[np.uint8]) -> None:
_rect(c, 14, 3, 17, 21, "light")
_rect(c, 15, 5, 16, 19, "mid")
_rect(c, 9, 22, 22, 23, "gold")
- _rect(c, 14, 24, 17, 27, "shadow")
- _disc(c, 15.5, 28.0, 2.4, "gold")
+ _rect(c, 14, 23, 17, 26, "shadow")
+ _disc(c, 15.5, 27.0, 2.4, "gold")
def _shield(c: NDArray[np.uint8]) -> None:
diff --git a/python_pkg/artgate/routes/vector.py b/python_pkg/artgate/routes/vector.py
index 4a81721d..6aa66b9b 100644
--- a/python_pkg/artgate/routes/vector.py
+++ b/python_pkg/artgate/routes/vector.py
@@ -31,7 +31,7 @@ PALETTE: Final[dict[str, str]] = {
"cyan": "#5cb0be",
}
-_STROKE: Final = 'stroke="#1a141c" stroke-width="1.5" stroke-linejoin="round"'
+STROKE: Final = 'stroke="#1a141c" stroke-width="1.5" stroke-linejoin="round"'
def _svg(body: str, size: int = 64) -> str:
@@ -55,27 +55,27 @@ SHAPES: Final[dict[str, str]] = {
f''
f'"
+ f"{STROKE}/>"
f'"
+ f"{STROKE}/>"
f'"
+ f"{STROKE}/>"
),
"gem": (
f'"
+ f"{STROKE}/>"
f''
),
"potion": (
- f''
+ f''
f''
+ f'fill="{PALETTE["light"]}" {STROKE}/>'
f'"
+ f"{STROKE}/>"
),
"coin": (
- f''
+ f''
f''
f''
+ f'fill="{PALETTE["mid"]}" {STROKE}/>'
f''
- f''
+ f''
),
}
+_REGISTRY: dict[str, str] = dict(SHAPES)
+
+
class RenderError(RuntimeError):
"""Raised when the SVG rasteriser is unavailable or fails."""
+def all_shapes() -> dict[str, str]:
+ """Return every vector subject registered so far.
+
+ ``vector_shapes`` calls :func:`register` at import time, so the extra seven
+ subjects appear here without this module importing them -- which keeps the
+ dependency one-directional and avoids a circular import.
+
+ Returns:
+ A mapping of subject name to SVG markup.
+ """
+ return dict(_REGISTRY)
+
+
+def register(shapes: dict[str, str]) -> None:
+ """Add subjects to the vector registry.
+
+ Args:
+ shapes: A mapping of subject name to SVG markup.
+ """
+ _REGISTRY.update(shapes)
+
+
def render(name: str, out: Path, size: int = 64) -> Path:
"""Rasterise one vector subject to PNG.
@@ -109,7 +134,8 @@ def render(name: str, out: Path, size: int = 64) -> Path:
KeyError: If the subject has no vector shape.
RenderError: If ``rsvg-convert`` is missing or exits non-zero.
"""
- if name not in SHAPES:
+ shapes = all_shapes()
+ if name not in shapes:
msg = f"no vector shape for {name!r}"
raise KeyError(msg)
binary = shutil.which("rsvg-convert")
@@ -118,7 +144,7 @@ def render(name: str, out: Path, size: int = 64) -> Path:
raise RenderError(msg)
source = out.with_suffix(".svg")
- source.write_text(_svg(SHAPES[name], size), encoding="utf-8")
+ source.write_text(_svg(shapes[name], size), encoding="utf-8")
result = subprocess.run(
[binary, "-w", str(size), "-h", str(size), str(source), "-o", str(out)],
capture_output=True,
diff --git a/python_pkg/artgate/routes/vector_shapes.py b/python_pkg/artgate/routes/vector_shapes.py
new file mode 100644
index 00000000..e68e5030
--- /dev/null
+++ b/python_pkg/artgate/routes/vector_shapes.py
@@ -0,0 +1,95 @@
+"""Route (d): the remaining seven vector subjects.
+
+Split from ``vector.py`` to stay under the 250-line file cap. Together the two
+modules cover all twelve bake-off subjects, which is what makes this cell's
+number comparable to route (a)'s -- a cell measured on five easy subjects
+would not be.
+
+Each shape carries a distinguishing feature for the same reason the procedural
+route does: geometric correctness alone reads as a placeholder.
+"""
+
+from __future__ import annotations
+
+from typing import Final
+
+from python_pkg.artgate.routes.vector import PALETTE, STROKE, register
+
+EXTRA_SHAPES: Final[dict[str, str]] = {
+ "scroll": (
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ ),
+ "book": (
+ f''
+ f''
+ f''
+ f''
+ ),
+ "bone": (
+ f''
+ f''
+ f''
+ f''
+ f''
+ ),
+ "bomb": (
+ f'"
+ f''
+ f''
+ f''
+ f''
+ f''
+ ),
+ "sword": (
+ f''
+ f''
+ f''
+ f''
+ f''
+ ),
+ "ring": (
+ f''
+ f''
+ f''
+ f''
+ ),
+ "meat": (
+ f''
+ f''
+ f''
+ f''
+ f''
+ ),
+}
+
+# Registering at import time keeps the dependency one-directional: vector.py
+# never imports this module, so there is no cycle and no lazy import.
+register(EXTRA_SHAPES)
diff --git a/python_pkg/artgate/spec.py b/python_pkg/artgate/spec.py
index 8f371a49..4fffdc30 100644
--- a/python_pkg/artgate/spec.py
+++ b/python_pkg/artgate/spec.py
@@ -229,6 +229,8 @@ def _require_config(spec: TargetSpec) -> None:
missing.append("OFF_PALETTE needs a non-empty 'palette'")
if spec.runs(Code.ALPHA_NOT_BINARY) and not spec.alpha_binary:
missing.append("ALPHA_NOT_BINARY needs 'alpha_binary = true'")
+ if spec.runs(Code.MARGIN_TOO_SMALL) and spec.min_margin <= 0:
+ missing.append("MARGIN_TOO_SMALL needs a positive 'min_margin'")
if missing:
msg = "; ".join(missing)
raise SpecError(msg)
diff --git a/python_pkg/artgate/styles/pixel_icon.toml b/python_pkg/artgate/styles/pixel_icon.toml
index 07f1ec99..fea3ba04 100644
--- a/python_pkg/artgate/styles/pixel_icon.toml
+++ b/python_pkg/artgate/styles/pixel_icon.toml
@@ -30,4 +30,5 @@ gates = [
"CANVAS_SIZE",
"SCALE_NOT_INVARIANT",
"SILHOUETTE_LOW_CONTRAST",
+ "MARGIN_TOO_SMALL",
]
diff --git a/python_pkg/artgate/tests/test_blender.py b/python_pkg/artgate/tests/test_blender.py
new file mode 100644
index 00000000..f85784a9
--- /dev/null
+++ b/python_pkg/artgate/tests/test_blender.py
@@ -0,0 +1,139 @@
+"""Blender route tests.
+
+The important one is ``test_exit_zero_without_output_raises``: Blender exits 0
+even when the embedded script raises, so a return-code check alone reports a
+phantom success. That was measured, not hypothesised -- a wrong render-engine
+enum on Blender 5.2 produced exit 0 and no file.
+"""
+
+from __future__ import annotations
+
+import subprocess
+from typing import TYPE_CHECKING
+
+import pytest
+
+from python_pkg.artgate.routes import blender
+from python_pkg.artgate.routes.blender_scene import (
+ RENDER_SIZE,
+ SUBJECTS_3D,
+ scene_script,
+)
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+
+class _Result:
+ """Stand-in for a completed subprocess."""
+
+ def __init__(self, returncode: int = 0, stderr: bytes = b"") -> None:
+ """Store the fields the caller inspects.
+
+ Args:
+ returncode: Simulated exit status.
+ stderr: Simulated error output.
+ """
+ self.returncode = returncode
+ self.stderr = stderr
+
+
+class TestSceneScript:
+ """The generated scene script is well-formed and parameterised."""
+
+ def test_render_size_is_substituted(self) -> None:
+ """The template takes the requested resolution."""
+ assert "resolution_x = 128" in scene_script(128)
+
+ def test_default_size(self) -> None:
+ """The default matches the documented constant."""
+ assert f"resolution_x = {RENDER_SIZE}" in scene_script()
+
+ def test_every_subject_has_a_builder(self) -> None:
+ """No 3D subject is silently missing a mesh."""
+ script = scene_script()
+ for name in SUBJECTS_3D:
+ assert f'"{name}": build_' in script
+
+ def test_script_is_valid_python(self) -> None:
+ """The emitted template compiles, so a typo fails here not in Blender."""
+ compile(scene_script(), "", "exec")
+
+ def test_transparent_film(self) -> None:
+ """Icons need an alpha channel, not a background."""
+ assert "film_transparent = True" in scene_script()
+
+
+class TestRenderErrors:
+ """Every failure mode is surfaced, never silently skipped."""
+
+ def test_unknown_subject_raises(self, tmp_path: Path) -> None:
+ """A subject with no mesh fails loudly."""
+ with pytest.raises(KeyError, match="no 3D scene"):
+ blender.render("dragon", tmp_path / "x.png")
+
+ def test_missing_binary_raises(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """A missing Blender is reported rather than treated as zero output."""
+ monkeypatch.setattr(blender.shutil, "which", lambda _: None)
+ with pytest.raises(blender.BlenderError, match="not installed"):
+ blender.render("coin", tmp_path / "x.png")
+
+ def test_nonzero_exit_raises(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """A hard failure is surfaced with Blender's own message."""
+ monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
+ monkeypatch.setattr(
+ blender.subprocess,
+ "run",
+ lambda *a, **k: _Result(returncode=1, stderr=b"bad mesh"),
+ )
+ with pytest.raises(blender.BlenderError, match="bad mesh"):
+ blender.render("coin", tmp_path / "x.png")
+
+ def test_exit_zero_without_output_raises(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Exit 0 with no file is a failure, not a success.
+
+ Regression for the measured Blender 5.2 behaviour: an exception inside
+ the embedded script still exits 0. Without this check the route would
+ report a phantom success and the cell would score a false zero.
+ """
+ monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
+ monkeypatch.setattr(
+ blender.subprocess,
+ "run",
+ lambda *a, **k: _Result(returncode=0, stderr=b"enum not found"),
+ )
+ with pytest.raises(blender.BlenderError, match="wrote no file"):
+ blender.render("coin", tmp_path / "missing.png")
+
+ def test_timeout_raises(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """A hung render is bounded, not waited on forever."""
+
+ def _boom(*_args: object, **_kwargs: object) -> _Result:
+ raise subprocess.TimeoutExpired(cmd="blender", timeout=1)
+
+ monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
+ monkeypatch.setattr(blender.subprocess, "run", _boom)
+ with pytest.raises(blender.BlenderError, match="timed out"):
+ blender.render("coin", tmp_path / "x.png")
+
+ def test_success_returns_path(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """A real output file is accepted and returned."""
+ out = tmp_path / "coin.png"
+
+ def _write(*_args: object, **_kwargs: object) -> _Result:
+ out.write_bytes(b"png")
+ return _Result()
+
+ monkeypatch.setattr(blender.shutil, "which", lambda _: "/bin/true")
+ monkeypatch.setattr(blender.subprocess, "run", _write)
+ assert blender.render("coin", out) == out
diff --git a/python_pkg/artgate/tests/test_cli.py b/python_pkg/artgate/tests/test_cli.py
index 8be9e68c..85e64f42 100644
--- a/python_pkg/artgate/tests/test_cli.py
+++ b/python_pkg/artgate/tests/test_cli.py
@@ -152,6 +152,20 @@ class TestEvaluate:
)
assert evaluate(arr, spec) == [Code.NO_OPAQUE_PIXELS]
+ def test_margin_gate_runs_when_declared(self) -> None:
+ """A full-bleed asset fails once the margin gate is declared.
+
+ Regression: twelve diffusion icons were 100% opaque -- square tiles,
+ not sprites -- and passed every other gate, because no gate asked
+ whether the asset had a silhouette at all.
+ """
+ arr = np.zeros((8, 8, 4), dtype=np.uint8)
+ arr[:, :, 3] = 255
+ spec = TargetSpec(
+ name="s", min_margin=1, gates=frozenset({Code.MARGIN_TOO_SMALL})
+ )
+ assert evaluate(arr, spec) == [Code.MARGIN_TOO_SMALL]
+
def test_silhouette_gate_runs_when_declared(self) -> None:
"""A near-white asset fails the silhouette gate.
diff --git a/python_pkg/artgate/tests/test_contrast.py b/python_pkg/artgate/tests/test_contrast.py
index e0bace63..0dd7f8f1 100644
--- a/python_pkg/artgate/tests/test_contrast.py
+++ b/python_pkg/artgate/tests/test_contrast.py
@@ -9,10 +9,12 @@ reject good art.
from __future__ import annotations
import numpy as np
+import pytest
from python_pkg.artgate.codes import Code
from python_pkg.artgate.contrast import (
DEFAULT_MIN_DELTA,
+ check_margin,
check_silhouette,
silhouette_delta,
)
@@ -66,6 +68,50 @@ class TestCalibrationControls:
assert check_silhouette(arr) == [Code.SILHOUETTE_LOW_CONTRAST]
+class TestMargin:
+ """A sprite needs a clear border; a square tile is not an item icon.
+
+ Regression: twelve SDXL icons measured 100% opaque coverage and passed
+ every other gate, because nothing checked whether the asset had a
+ silhouette at all. Gate-pass measured hygiene, not usability.
+ """
+
+ def test_full_bleed_is_rejected(self) -> None:
+ """A fully-opaque canvas has no silhouette and must fail."""
+ assert check_margin(_solid(128), 1) == [Code.MARGIN_TOO_SMALL]
+
+ def test_clear_border_passes(self) -> None:
+ """Content inset from every edge passes."""
+ arr = _solid(128, alpha=0)
+ arr[2:6, 2:6, 3] = 255
+ assert check_margin(arr, 1) == []
+
+ @pytest.mark.parametrize("edge", ["top", "bottom", "left", "right"])
+ def test_any_touched_edge_is_rejected(self, edge: str) -> None:
+ """Touching a single edge is enough to fail."""
+ arr = _solid(128, alpha=0)
+ arr[2:6, 2:6, 3] = 255
+ index = {
+ "top": (0, 3),
+ "bottom": (-1, 3),
+ "left": (3, 0),
+ "right": (3, -1),
+ }[edge]
+ arr[index[0], index[1], 3] = 255
+ assert check_margin(arr, 1) == [Code.MARGIN_TOO_SMALL]
+
+ def test_zero_margin_disables_the_gate(self) -> None:
+ """A style that permits full bleed opts out explicitly."""
+ assert check_margin(_solid(128), 0) == []
+
+ def test_wider_margin_is_stricter(self) -> None:
+ """A 2px requirement rejects art that only clears 1px."""
+ arr = _solid(128, alpha=0)
+ arr[1:7, 1:7, 3] = 255
+ assert check_margin(arr, 1) == []
+ assert check_margin(arr, 2) == [Code.MARGIN_TOO_SMALL]
+
+
class TestEdgeCases:
"""Degenerate inputs fail closed rather than dividing by zero."""
diff --git a/python_pkg/artgate/tests/test_routes.py b/python_pkg/artgate/tests/test_routes.py
index d381e51f..a427d7e8 100644
--- a/python_pkg/artgate/tests/test_routes.py
+++ b/python_pkg/artgate/tests/test_routes.py
@@ -108,6 +108,14 @@ class TestProceduralRoute:
class TestVectorRoute:
"""SVG rasterisation is available and produces real output."""
+ def test_all_twelve_subjects_have_shapes(self) -> None:
+ """The vector cell covers the same 12 subjects as every other route.
+
+ A cell measured on five easy subjects is not comparable to one
+ measured on all twelve.
+ """
+ assert {s.name for s in SUBJECTS} <= set(vector.all_shapes())
+
def test_unknown_shape_raises(self, tmp_path: Path) -> None:
"""An undefined vector subject fails loudly."""
with pytest.raises(KeyError, match="no vector shape"):
diff --git a/python_pkg/artgate/tests/test_spec.py b/python_pkg/artgate/tests/test_spec.py
index 2af48634..ee86693c 100644
--- a/python_pkg/artgate/tests/test_spec.py
+++ b/python_pkg/artgate/tests/test_spec.py
@@ -160,6 +160,7 @@ class TestFailClosed:
('gates = ["TOO_MANY_COLORS"]', "TOO_MANY_COLORS needs"),
('gates = ["OFF_PALETTE"]', "OFF_PALETTE needs"),
('gates = ["ALPHA_NOT_BINARY"]', "ALPHA_NOT_BINARY needs"),
+ ('gates = ["MARGIN_TOO_SMALL"]', "MARGIN_TOO_SMALL needs"),
],
)
def test_gate_without_config_is_rejected(