# build-minecraft-model-from-image

Adopt this skill: https://proskills.md/skills/other/codepawl-viz3d-plugins-build-minecraft-model-from-image-skills-build-minecraft-m

Page: https://proskills.md/skills/other/codepawl-viz3d-plugins-build-minecraft-model-from-image-skills-build-minecraft-m
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/SKILL.md
size: 4778
sha: 3512ed49712beacf98bd53f8e715692fe0cf2668
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/SKILL.md

```text
---
name: build-minecraft-model-from-image
description: Turn a 2D picture of an object into a Minecraft-style 3D model, a few clean cuboids with pixel textures in the Blockbench look, never fine jagged voxels. Exports GLB, a viewer and renders. Use for "make this photo a Minecraft model", "block model from an image", "turn this object into blocks", "voxel from a picture", "từ ảnh làm khối 3D minecraft", "voxel từ ảnh", "biến ảnh đồ vật thành block", "làm model minecraft".
license: MIT
---

# Build a Minecraft-style model from a picture (Dựng model kiểu Minecraft từ ảnh đồ vật)

If `references/local.md` exists, read it first: it sets the folders and house rules for this machine.

The agent looks at the picture and writes the model by hand as code. There is no image-to-3D model, no heightmap and no extrusion.

## The look

A fine voxel grid looks jagged and ugly. The target is how Minecraft itself is modelled:

- A few big flat cuboids, about 20 to 110 boxes (a `round` tier takes 5 to 9). Each box or tier is a real part of the object.
- Every face carries a pixel texture at one texel per unit, so all pixels are the same size everywhere.
- Model height is 16 to 40 units. Detail goes in the paint, not in more boxes: eyes, labels, seams, screws, patterns.
- Round things are 2 to 5 stacked tiers of chamfered boxes (`round`, `disc`), never a staircase of tiny cubes.
- Angled parts are one rotated box (`beam`, `rotation=`).
- Palette from the picture, 3 to 5 close ramp steps per material. Low noise on smooth things, a little more on rough ones.

## Steps

`{{SKILL_DIR}}` is this skill's folder and `{{WORK_DIR}}` the folder where projects live. Scripts are in `{{SKILL_DIR}}/scripts`. Put each job in its own folder under `{{WORK_DIR}}`, with the picture as `source.jpg`. The scripts declare their Python dependencies inline, so `uv run` installs them. Blender 5.x does the renders. Set `BLENDER_PATH` if it is not on your PATH.

1. **Look at the whole picture at full size before any tool.** Name each real part, and what is only shadow, reflection or background. Blueprints blur this: on the test run a port's shadow was read as a "dark neck", and a collared shirt as a white bib.
2. **Read it:** `uv run --no-project {{SKILL_DIR}}/scripts/read_image.py source.jpg --blocks 24 --box x0,y0,x1,y1 --out read`. Add `--clear-background 18` on a plain background so holes open up (handles, slats). This writes `mask.png`, `blueprint.png` (block coordinates with a ruler), `palette.png` and `rows.json`. Pick `--blocks` so the thinnest part that must show gets 1 to 2 units.
3. **Write the part sheet** as the object script's docstring: what is visible, what is inferred for the unseen sides and why, what is left out. The camera view comes from measurement, see `references/method.md`, "Reading the view".
4. **Write the object script** with `SOURCE` and `build(kit)`. API and worked patterns: `references/method.md`. Worked examples of four shapes: `examples/`. Their source pictures are not included. Credits are in `README.md` at the repository root.
5. **Build:** `uv run --no-project {{SKILL_DIR}}/scripts/build_model.py object.py --out block --samples 48 --resolution 500`. This writes `model.glb`, `viewer.html`, `checks.json`, `overlay.png`, `compare.png` and `views/*.png` with transparent backgrounds. Add `--turntable 48` for `turntable.mp4`. `--device auto` is the default and goes to the CPU while the GPU is above 40% busy.
6. **Look at `compare.png` and fix what reads wrong**, at least one round. Go through `references/faults.md`. Check the back and side views as hard as the picture's view. That is where a lazy model shows.
7. **Blind read for anything going public:** ask a fresh subagent, or a person, who is not told the object to name it from the renders alone.

## Hard rules

- `build_model.py` exits 1 on floating parts or an extruded model. Fix the model. Do not loosen the check.
- Warnings are real: coplanar overlaps flicker in every viewer, and too many or too tiny boxes mean the model is sliding back into voxels.
- Silhouette overlap is a guide only. Thin frames and photos with perspective score low (a correct chair scores 0.45). Open `overlay.png` rather than chasing the number.
- Rendering uses the GPU. When another job holds it, keep to `--samples 24 --resolution 400`, or use `--no-render` and `viewer.html` until it frees up.
- Pictures need a clear licence if the result is published. Leave third-party logos and brand text out of the model and say so.
- If the picture is not enough for the unseen sides of a real object, search reference views before inventing them.
- After editing the scripts, run `uv run --no-project {{SKILL_DIR}}/scripts/test_block_model.py` (no GPU needed).
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/block_export.py
size: 11443
sha: 82c6651b9ddd6789d05d84915728e061b8474f37
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/block_export.py

```text
"""Packs a BlockModel's face textures into one atlas and writes a GLB with nearest-neighbour sampling, plus a three.js viewer."""

import base64
import io
import json
import struct

import numpy
from PIL import Image

from block_kit import FACES

VIEWER_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
  html, body { margin: 0; height: 100%; background: #e6e2d8; }
  canvas { display: block; width: 100%; height: 100%; }
</style>
<script type="importmap">
{ "imports": {
  "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
  "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
} }
</script>
</head>
<body>
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

const modelBase64 = "__MODEL__";

const scene = new THREE.Scene();
scene.background = new THREE.Color(0xe6e2d8);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);

scene.add(new THREE.HemisphereLight(0xffffff, 0xa89f90, 1.9));
const sun = new THREE.DirectionalLight(0xfff1dc, 2.2);
sun.position.set(-1.5, 3, 2.5);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.normalBias = 0.02;
scene.add(sun);

const camera = new THREE.PerspectiveCamera(24, 1, 0.01, 200);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 1.4;

const modelBytes = Uint8Array.from(atob(modelBase64), (character) => character.charCodeAt(0));
new GLTFLoader().parse(modelBytes.buffer, "", (gltf) => {
  gltf.scene.traverse((node) => {
    if (node.isMesh) {
      node.castShadow = true;
      node.receiveShadow = true;
    }
  });
  scene.add(gltf.scene);
  const bounds = new THREE.Box3().setFromObject(gltf.scene);
  const center = bounds.getCenter(new THREE.Vector3());
  const radius = bounds.getSize(new THREE.Vector3()).length() / 2;
  controls.target.copy(center);
  camera.position.set(center.x + radius * 2.2, center.y + radius * 1.6, center.z + radius * 4.2);
  const ground = new THREE.Mesh(new THREE.CircleGeometry(radius * 12, 64), new THREE.ShadowMaterial({ opacity: 0.22 }));
  ground.rotation.x = -Math.PI / 2;
  ground.position.y = bounds.min.y;
  ground.receiveShadow = true;
  scene.add(ground);
  sun.shadow.camera.left = -radius * 2;
  sun.shadow.camera.right = radius * 2;
  sun.shadow.camera.top = radius * 2;
  sun.shadow.camera.bottom = -radius * 2;
  sun.position.multiplyScalar(radius * 1.5);
  window.viewerReady = true;
});

function resize() {
  renderer.setSize(window.innerWidth, window.innerHeight);
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
}
window.addEventListener("resize", resize);
resize();
renderer.setAnimationLoop(() => {
  controls.update();
  renderer.render(scene, camera);
});
</script>
</body>
</html>
"""

# For each face: bottom-left corner and the directions of "right" and "up" as seen from outside, in terms of box size.
FACE_FRAMES = {
    "front": {"origin": (0, 0, 1), "right": (1, 0, 0), "up": (0, 1, 0), "normal": (0, 0, 1)},
    "back": {"origin": (1, 0, 0), "right": (-1, 0, 0), "up": (0, 1, 0), "normal": (0, 0, -1)},
    "right": {"origin": (1, 0, 1), "right": (0, 0, -1), "up": (0, 1, 0), "normal": (1, 0, 0)},
    "left": {"origin": (0, 0, 0), "right": (0, 0, 1), "up": (0, 1, 0), "normal": (-1, 0, 0)},
    "top": {"origin": (0, 1, 1), "right": (1, 0, 0), "up": (0, 0, -1), "normal": (0, 1, 0)},
    "bottom": {"origin": (0, 0, 0), "right": (1, 0, 0), "up": (0, 0, 1), "normal": (0, -1, 0)},
}

def pack_atlas(model):
    """Shelf packing with one texel of padding; edges are repeated into the padding so nearest sampling never bleeds."""
    textures = []
    for box in model.boxes:
        for face in FACES:
            textures.append((box, face, box.textures[face]))
    textures.sort(key=lambda item: -item[2].shape[0])
    total_area = sum((item[2].shape[0] + 2) * (item[2].shape[1] + 2) for item in textures)
    atlas_width = max(int(numpy.ceil(numpy.sqrt(total_area * 1.3))), max(item[2].shape[1] for item in textures) + 2)
    placements = {}
    cursor_x = 0
    cursor_y = 0
    shelf_height = 0
    laid_out = []
    for box, face, texture in textures:
        height, width = texture.shape[:2]
        if cursor_x + width + 2 > atlas_width:
            cursor_x = 0
            cursor_y += shelf_height
            shelf_height = 0
        laid_out.append((box, face, texture, cursor_x + 1, cursor_y + 1))
        cursor_x += width + 2
        shelf_height = max(shelf_height, height + 2)
    atlas_height = cursor_y + shelf_height
    atlas = numpy.zeros((atlas_height, atlas_width, 3), dtype=numpy.uint8)
    for box, face, texture, x, y in laid_out:
        height, width = texture.shape[:2]
        padded = numpy.pad(texture, ((1, 1), (1, 1), (0, 0)), mode="edge")
        atlas[y - 1:y + height + 1, x - 1:x + width + 1] = padded
        placements[(id(box), face)] = (x, y, width, height)
    return atlas, placements

def write_glb(model, path, unit_size=1 / 16):
    """One primitive per (roughness, metallic). Origin: bottom centre of the model's bounds."""
    atlas, placements = pack_atlas(model)
    atlas_height, atlas_width = atlas.shape[:2]
    all_corners = numpy.vstack([box.corners() for box in model.boxes])
    center = (all_corners.min(axis=0) + all_corners.max(axis=0)) / 2
    origin = numpy.array([center[0], all_corners[:, 1].min(), center[2]])
    buckets = {}
    for box in model.boxes:
        rotation = box.rotation_matrix()
        size = numpy.asarray(box.size, dtype=float)
        low = numpy.asarray(box.low, dtype=float)
        for face in FACES:
            material = box.materials_by_face[face]
            key = (material.roughness, material.metallic)
            bucket = buckets.setdefault(key, {"positions": [], "normals": [], "uvs": []})
            frame = FACE_FRAMES[face]
            corner_origin = low + numpy.asarray(frame["origin"], dtype=float) * size
            right = numpy.asarray(frame["right"], dtype=float) * size
            up = numpy.asarray(frame["up"], dtype=float) * size
            corners = numpy.array([corner_origin, corner_origin + right, corner_origin + right + up, corner_origin + up])
            corners = box.transform(corners)
            normal = rotation @ numpy.asarray(frame["normal"], dtype=float)
            x, y, width, height = placements[(id(box), face)]
            uvs = [(x / atlas_width, (y + height) / atlas_height), ((x + width) / atlas_width, (y + height) / atlas_height),
                   ((x + width) / atlas_width, y / atlas_height), (x / atlas_width, y / atlas_height)]
            for corner, uv in zip(corners, uvs):
                bucket["positions"].append((corner - origin) * unit_size)
                bucket["normals"].append(normal)
                bucket["uvs"].append(uv)

    binary = bytearray()
    buffer_views = []
    accessors = []
    primitives = []
    gltf_materials = []

    def add_view(data, target=None):
        while len(binary) % 4:
            binary.append(0)
        view = {"buffer": 0, "byteOffset": len(binary), "byteLength": len(data)}
        if target:
            view["target"] = target
        buffer_views.append(view)
        binary.extend(data)
        return len(buffer_views) - 1

    def add_accessor(data, target, component_type, count, accessor_type, bounds=None):
        accessor = {"bufferView": add_view(data, target), "componentType": component_type, "count": count, "type": accessor_type}
        if bounds:
            accessor["min"] = bounds[0]
            accessor["max"] = bounds[1]
        accessors.append(accessor)
        return len(accessors) - 1

    for (roughness, metallic), bucket in buckets.items():
        positions = numpy.asarray(bucket["positions"], dtype=numpy.float32)
        normals = numpy.asarray(bucket["normals"], dtype=numpy.float32)
        uvs = numpy.asarray(bucket["uvs"], dtype=numpy.float32)
        quad_count = len(positions) // 4
        indices = numpy.zeros(quad_count * 6, dtype=numpy.uint32)
        for quad_index in range(quad_count):
            base = quad_index * 4
            indices[quad_index * 6:quad_index * 6 + 6] = [base, base + 1, base + 2, base, base + 2, base + 3]
        bounds = (positions.min(axis=0).tolist(), positions.max(axis=0).tolist())
        position_accessor = add_accessor(positions.tobytes(), 34962, 5126, len(positions), "VEC3", bounds)
        normal_accessor = add_accessor(normals.tobytes(), 34962, 5126, len(normals), "VEC3")
        uv_accessor = add_accessor(uvs.tobytes(), 34962, 5126, len(uvs), "VEC2")
        index_accessor = add_accessor(indices.tobytes(), 34963, 5125, len(indices), "SCALAR")
        gltf_materials.append({
            "name": f"block_r{roughness}_m{metallic}",
            "pbrMetallicRoughness": {"baseColorTexture": {"index": 0}, "roughnessFactor": roughness, "metallicFactor": metallic},
        })
        primitives.append({"attributes": {"POSITION": position_accessor, "NORMAL": normal_accessor, "TEXCOORD_0": uv_accessor},
                           "indices": index_accessor, "material": len(gltf_materials) - 1})

    png_buffer = io.BytesIO()
    Image.fromarray(atlas).save(png_buffer, format="PNG")
    image_view = add_view(png_buffer.getvalue())
    while len(binary) % 4:
        binary.append(0)
    document = {
        "asset": {"version": "2.0", "generator": "build-minecraft-model-from-image"},
        "scene": 0,
        "scenes": [{"nodes": [0]}],
        "nodes": [{"mesh": 0, "name": "block_model"}],
        "meshes": [{"primitives": primitives, "name": "block_model"}],
        "materials": gltf_materials,
        "textures": [{"source": 0, "sampler": 0}],
        "samplers": [{"magFilter": 9728, "minFilter": 9728, "wrapS": 33071, "wrapT": 33071}],
        "images": [{"bufferView": image_view, "mimeType": "image/png"}],
        "buffers": [{"byteLength": len(binary)}],
        "bufferViews": buffer_views,
        "accessors": accessors,
    }
    json_bytes = json.dumps(document, separators=(",", ":")).encode("utf-8")
    json_bytes = json_bytes + b" " * ((4 - len(json_bytes) % 4) % 4)
    total_length = 12 + 8 + len(json_bytes) + 8 + len(binary)
    with open(path, "wb") as file:
        file.write(struct.pack("<III", 0x46546C67, 2, total_length))
        file.write(struct.pack("<II", len(json_bytes), 0x4E4F534A))
        file.write(json_bytes)
        file.write(struct.pack("<II", len(binary), 0x004E4942))
        file.write(bytes(binary))
    return {"atlas": [atlas_width, atlas_height], "triangles": 12 * len(model.boxes), "unit_size": unit_size}

def write_viewer(glb_path, viewer_path, title):
    with open(glb_path, "rb") as file:
        encoded_model = base64.b64encode(file.read()).decode("ascii")
    page = VIEWER_TEMPLATE.replace("__MODEL__", encoded_model).replace("__TITLE__", title)
    with open(viewer_path, "w", encoding="utf-8") as file:
        file.write(page)
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/block_kit.py
size: 12361
sha: ddf13e6f6cb3b91da99a6595c276173e58857b6f
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/block_kit.py

```text
"""Minecraft-style box models: a few cuboids, each face a pixel texture at one texel per unit.

Axes: x = width (+x is the viewer's right when facing the front), y = up, z = depth (+z is the front).
Sizes are whole units, so every pixel on the model is the same size. A box may be rotated for angled parts.
Faces are named front (+z), back (-z), right (+x), left (-x), top, bottom. A face's rows read as seen from outside it;
the top face is seen from above with the front edge at the bottom of the rows.
"""

from dataclasses import dataclass, field
from math import atan2, ceil, cos, degrees, hypot, radians, sin

import numpy

@dataclass
class Material:
    ramp: list  # hex colours, lightest first; 3 to 6 steps taken from the picture
    style: str = "plain"  # plain, wood, metal or fabric
    noise: float = 0.5  # how far a texel wanders from the base step, in ramp steps
    rim: bool = True  # lighter top row and darker bottom row on side faces, so each part reads as a part
    roughness: float = 0.85
    metallic: float = 0.0

def hex_to_rgb(colour):
    colour = colour.lstrip("#")
    return tuple(int(colour[index:index + 2], 16) for index in (0, 2, 4))

def _paint_steps(material, width, height, face, generator):
    """A height x width array of ramp positions for one face, before rims."""
    steps = len(material.ramp)
    base = (steps - 1) / 2
    positions = numpy.full((height, width), base, dtype=float)
    if material.style == "wood":
        across = width >= height
        lines = generator.normal(0.0, material.noise, height if across else width)
        grain = generator.normal(0.0, material.noise * 0.45, (height, width))
        positions += (lines[:, None] if across else lines[None, :]) + grain
    elif material.style == "metal":
        ramp_down = numpy.linspace(-0.8, 0.8, height)[:, None]
        positions += ramp_down + generator.normal(0.0, material.noise * 0.5, (height, width))
    elif material.style == "fabric":
        positions += generator.choice([-1.0, 0.0, 0.0, 1.0], (height, width)) * material.noise * 2
    else:
        positions += generator.normal(0.0, material.noise, (height, width))
    if face == "top":
        positions -= 0.8
    elif face == "bottom":
        positions += 1.2
    return positions

def _apply_rim(positions, face):
    height, width = positions.shape
    if min(height, width) < 2:
        return
    if face in ("front", "back", "left", "right"):
        positions[0, :] -= 0.9
        if height >= 3:
            positions[-1, :] += 0.9
    elif face == "top" and min(height, width) >= 3:
        positions[0, :] -= 0.5
        positions[-1, :] -= 0.5
        positions[:, 0] -= 0.5
        positions[:, -1] -= 0.5

class Box:
    def __init__(self, model, low, size, materials_by_face, rotation, pivot, name):
        self.low = tuple(float(value) for value in low)
        self.size = tuple(int(value) for value in size)
        self.rotation = rotation
        self.pivot = pivot
        self.name = name
        self.materials_by_face = materials_by_face
        self.generator = model.generator
        self.textures = {}
        for face in FACES:
            material = materials_by_face[face]
            width, height = self.face_size(face)
            positions = _paint_steps(material, width, height, face, model.generator)
            if material.rim:
                _apply_rim(positions, face)
            indices = numpy.clip(numpy.rint(positions), 0, len(material.ramp) - 1).astype(int)
            palette = numpy.array([hex_to_rgb(colour) for colour in material.ramp], dtype=numpy.uint8)
            self.textures[face] = palette[indices]

    def face_size(self, face):
        size_x, size_y, size_z = self.size
        if face in ("front", "back"):
            return size_x, size_y
        if face in ("left", "right"):
            return size_z, size_y
        return size_x, size_z

    def paint(self, face, rows, legend, at=(0, 0)):
        """Pixel art on a face. at = (column, row) of the top-left character, as seen from outside.
        legend maps a character to a hex colour; any other character leaves the texel alone."""
        texture = self.textures[face]
        for row_offset, row in enumerate(rows):
            for column_offset, character in enumerate(row):
                colour = legend.get(character)
                if colour is None:
                    continue
                target_row = at[1] + row_offset
                target_column = at[0] + column_offset
                if 0 <= target_row < texture.shape[0] and 0 <= target_column < texture.shape[1]:
                    texture[target_row, target_column] = hex_to_rgb(colour)
        return self

    def band(self, row, colour):
        """One texel line round the four sides, counted from the top: seams, stripes, belts."""
        for face in ("front", "back", "left", "right"):
            texture = self.textures[face]
            if 0 <= row < texture.shape[0]:
                texture[row, :] = hex_to_rgb(colour)
        return self

    def speckle(self, colours, share, faces=None):
        """Scatter texels of these hex colours over faces: rust, wear, printed fabric."""
        palette = [hex_to_rgb(colour) for colour in colours]
        for face in faces or FACES:
            texture = self.textures[face]
            chosen = self.generator.uniform(0.0, 1.0, texture.shape[:2]) < share
            for row, column in zip(*numpy.nonzero(chosen)):
                texture[row, column] = palette[self.generator.integers(len(palette))]
        return self

    def corners(self):
        """The eight world corners, x-low first."""
        x0, y0, z0 = self.low
        x1, y1, z1 = x0 + self.size[0], y0 + self.size[1], z0 + self.size[2]
        points = numpy.array([[x, y, z] for x in (x0, x1) for y in (y0, y1) for z in (z0, z1)], dtype=float)
        return self.transform(points)

    def rotation_matrix(self):
        if self.rotation is None:
            return numpy.eye(3)
        angle_x, angle_y, angle_z = (radians(value) for value in self.rotation)
        rotate_x = numpy.array([[1, 0, 0], [0, cos(angle_x), -sin(angle_x)], [0, sin(angle_x), cos(angle_x)]])
        rotate_y = numpy.array([[cos(angle_y), 0, sin(angle_y)], [0, 1, 0], [-sin(angle_y), 0, cos(angle_y)]])
        rotate_z = numpy.array([[cos(angle_z), -sin(angle_z), 0], [sin(angle_z), cos(angle_z), 0], [0, 0, 1]])
        return rotate_y @ rotate_x @ rotate_z

    def transform(self, points):
        if self.rotation is None:
            return points
        pivot = numpy.asarray(self.pivot, dtype=float)
        return (points - pivot) @ self.rotation_matrix().T + pivot

FACES = ("front", "back", "right", "left", "top", "bottom")

def _filler_size(corner):
    return (corner + 1) // 2

def _notch_fillers(first_low, second_low, diameter, corner):
    """Low corners of the boxes that fill the four notches of a core-and-slabs round, so it reads as an octagon."""
    if corner < 3:
        return []
    size = _filler_size(corner)
    near = corner - size
    far = diameter - corner
    return [(first_low + first, second_low + second) for first in (near, far) for second in (near, far)]

@dataclass
class BlockModel:
    materials: dict
    seed: int = 7
    boxes: list = field(default_factory=list)

    def __post_init__(self):
        self.generator = numpy.random.default_rng(self.seed)

    def box(self, low, size, material, top=None, bottom=None, sides=None, rotation=None, pivot=None, name=""):
        """low is the box's low corner, size is (width, height, depth) in whole units. top, bottom and sides override the
        material on those faces. rotation is degrees (x, y, z) around pivot, which defaults to the box's centre."""
        if pivot is None:
            pivot = tuple(low[index] + size[index] / 2 for index in range(3))
        side_material = self.materials[sides or material]
        materials_by_face = {face: side_material for face in ("front", "back", "left", "right")}
        materials_by_face["top"] = self.materials[top or material]
        materials_by_face["bottom"] = self.materials[bottom or material]
        created = Box(self, low, size, materials_by_face, rotation, pivot, name)
        self.boxes.append(created)
        return created

    def round(self, center_x, center_z, low_y, height, diameter, material, corner=None, top=None, bottom=None, name=""):
        """A round part the Minecraft way: a square core, four side slabs and, for corners of 3 or more, a filler in each
        notch. Returns the boxes: core, back slab, front slab, left slab, right slab, then any fillers.
        Odd and even diameters both stay centred when center_x fits them."""
        corner = corner if corner is not None else max(diameter // 5, 1)
        core = diameter - 2 * corner
        left = center_x - diameter / 2
        back = center_z - diameter / 2
        parts = [
            self.box((left + corner, low_y, back + corner), (core, height, core), material, top=top, bottom=bottom, name=name),
            self.box((left + corner, low_y, back), (core, height, corner), material, top=top, bottom=bottom, name=name),
            self.box((left + corner, low_y, back + diameter - corner), (core, height, corner), material, top=top, bottom=bottom, name=name),
            self.box((left, low_y, back + corner), (corner, height, core), material, top=top, bottom=bottom, name=name),
            self.box((left + diameter - corner, low_y, back + corner), (corner, height, core), material, top=top, bottom=bottom, name=name),
        ]
        for first, second in _notch_fillers(left, back, diameter, corner):
            parts.append(self.box((first, low_y, second), (_filler_size(corner), height, _filler_size(corner)), material, top=top,
                                  bottom=bottom, name=name))
        return parts

    def disc(self, center_x, center_y, low_z, depth, diameter, material, corner=None, recess=0, core=None, rotation=None,
             pivot=None, name=""):
        """A round part facing +z: ports, lenses, wheels, spout mouths. Rotate it to aim it anywhere. recess sets the
        square core back from the rim by that many units, with its own material, for a real dent. Returns (core, rim parts)."""
        corner = corner if corner is not None else max(diameter // 5, 1)
        core_size = diameter - 2 * corner
        left = center_x - diameter / 2
        bottom = center_y - diameter / 2
        if pivot is None:
            pivot = (center_x, center_y, low_z)
        shared = {"rotation": rotation, "pivot": pivot, "name": name}
        core_box = self.box((left + corner, bottom + corner, low_z), (core_size, core_size, depth - recess), core or material, **shared)
        rim = [
            self.box((left + corner, bottom, low_z), (core_size, corner, depth), material, **shared),
            self.box((left + corner, bottom + diameter - corner, low_z), (core_size, corner, depth), material, **shared),
            self.box((left, bottom + corner, low_z), (corner, core_size, depth), material, **shared),
            self.box((left + diameter - corner, bottom + corner, low_z), (corner, core_size, depth), material, **shared),
        ]
        for first, second in _notch_fillers(left, bottom, diameter, corner):
            rim.append(self.box((first, second, low_z), (_filler_size(corner), _filler_size(corner), depth), material, **shared))
        return core_box, rim

    def beam(self, start, end, thickness, material, name=""):
        """A straight bar between two points at any angle, for handles and braces. thickness = (across, deep);
        the bar runs in the plane of its rotation around z, then around y."""
        start = numpy.asarray(start, dtype=float)
        end = numpy.asarray(end, dtype=float)
        delta = end - start
        horizontal = hypot(delta[0], delta[2])
        length = max(int(ceil(numpy.linalg.norm(delta))), 1)
        center = (start + end) / 2
        angle_z = degrees(atan2(delta[1], horizontal))
        angle_y = degrees(atan2(-delta[2], delta[0]))
        low = (center[0] - length / 2, center[1] - thickness[0] / 2, center[2] - thickness[1] / 2)
        return self.box(low, (length, thickness[0], thickness[1]), material, rotation=(0, angle_y, angle_z),
                        pivot=tuple(center), name=name)
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/build_model.py
size: 16226
sha: 598ee8a8e1359a51b356fdb79e910bf54666f08c
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/build_model.py

```text
# /// script
# dependencies = ["numpy", "pillow"]
# ///
"""Runs an object script, then checks, exports and renders it.

  build_model.py object.py --out folder [--no-render] [--samples 64] [--turntable 48] [--resolution 800]

The object script defines:
  SOURCE = {"image": "source.jpg", "mask": "read/mask.png", "yaw": 30, "pitch": 10}  # the view the picture was taken from
  def build(kit): ... return model   # kit has BlockModel and Material; see block_kit.py

Writes model.glb, viewer.html, checks.json, overlay.png (picture mask vs model), compare.png (picture beside renders) and
views/*.png with transparent backgrounds. Exit code 1 when a hard check fails: floating parts or an extruded model.
"""

import argparse
import importlib.util
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path

import numpy
from PIL import Image, ImageDraw

SCRIPT_FOLDER = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_FOLDER))

import block_export  # noqa: E402
import block_kit  # noqa: E402

def find_blender():
    """BLENDER_PATH wins, then PATH, then the installer's default folder on Windows and macOS."""
    from_environment = os.environ.get("BLENDER_PATH") or shutil.which("blender")
    if from_environment:
        return from_environment
    program_files = Path(os.environ.get("ProgramFiles", "Program Files"))
    installed = sorted(program_files.glob("Blender Foundation/Blender */blender.exe"))
    if installed:
        return str(installed[-1])
    mac_application = Path("/Applications/Blender.app/Contents/MacOS/Blender")
    if mac_application.exists():
        return str(mac_application)
    return "blender"

BLENDER_PATH = find_blender()
GPU_BUSY_PERCENT = 40
BELOW_NORMAL_PRIORITY = 0x00004000
BACKDROP = (230, 226, 216)
TOUCH_TOLERANCE = 0.02
MOST_PARTS = 120
TINY_SHARE = 0.35

def find_groups(bounds):
    """Boxes whose bounds touch are one group. Rotated boxes use their bounds, so this errs toward 'connected'."""
    parents = list(range(len(bounds)))

    def find(index):
        while parents[index] != index:
            parents[index] = parents[parents[index]]
            index = parents[index]
        return index

    for first in range(len(bounds)):
        for second in range(first + 1, len(bounds)):
            low_a, high_a = bounds[first]
            low_b, high_b = bounds[second]
            if numpy.all(low_a <= high_b + TOUCH_TOLERANCE) and numpy.all(low_b <= high_a + TOUCH_TOLERANCE):
                parents[find(first)] = find(second)
    groups = {}
    for index in range(len(bounds)):
        groups.setdefault(find(index), []).append(index)
    return sorted(groups.values(), key=len, reverse=True)

def find_coplanar_overlaps(boxes):
    """Same-facing faces in one plane that overlap flicker in any viewer. Only unrotated boxes are compared."""
    clashes = []
    plain = [box for box in boxes if box.rotation is None]
    for first_index, first in enumerate(plain):
        for second in plain[first_index + 1:]:
            for axis in range(3):
                others = [index for index in range(3) if index != axis]
                overlap = all(
                    min(first.low[other] + first.size[other], second.low[other] + second.size[other])
                    - max(first.low[other], second.low[other]) > 0.01
                    for other in others)
                if not overlap:
                    continue
                same_low = abs(first.low[axis] - second.low[axis]) < 0.01
                same_high = abs(first.low[axis] + first.size[axis] - second.low[axis] - second.size[axis]) < 0.01
                if same_low or same_high:
                    clashes.append(f'{first.name or first.low} / {second.name or second.low} on axis {"xyz"[axis]}')
    return clashes

def convex_hull(points):
    ordered = sorted(set(map(tuple, points)))
    if len(ordered) < 3:
        return ordered

    def cross(origin, first, second):
        return (first[0] - origin[0]) * (second[1] - origin[1]) - (first[1] - origin[1]) * (second[0] - origin[0])

    lower = []
    for point in ordered:
        while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0:
            lower.pop()
        lower.append(point)
    upper = []
    for point in reversed(ordered):
        while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0:
            upper.pop()
        upper.append(point)
    return lower[:-1] + upper[:-1]

def project(points, yaw_degrees, pitch_degrees):
    """Orthographic screen position for a camera at this yaw and pitch; screen y grows downward."""
    yaw = numpy.radians(yaw_degrees)
    pitch = numpy.radians(pitch_degrees)
    screen_x = points[:, 0] * numpy.cos(yaw) - points[:, 2] * numpy.sin(yaw)
    forward = points[:, 0] * numpy.sin(yaw) + points[:, 2] * numpy.cos(yaw)
    screen_y = points[:, 1] * numpy.cos(pitch) - forward * numpy.sin(pitch)
    return numpy.stack([screen_x, -screen_y], axis=1)

def compare_silhouette(boxes, mask, yaw, pitch, overlay_path):
    """Fit the model's silhouette into the mask's box. Overlay: grey = both, red = picture only, blue = model only."""
    rows = numpy.where(mask.any(axis=1))[0]
    columns = numpy.where(mask.any(axis=0))[0]
    crop = mask[rows[0]:rows[-1] + 1, columns[0]:columns[-1] + 1]
    scale = 400 / max(crop.shape)
    width = max(int(crop.shape[1] * scale), 1)
    height = max(int(crop.shape[0] * scale), 1)
    target = numpy.asarray(Image.fromarray((crop * 255).astype(numpy.uint8)).resize((width, height))) > 127
    projected = [project(box.corners(), yaw, pitch) for box in boxes]
    together = numpy.vstack(projected)
    low = together.min(axis=0)
    extent = together.max(axis=0) - low
    fit = min(width / max(extent[0], 1e-6), height / max(extent[1], 1e-6))
    offset = numpy.array([(width - extent[0] * fit) / 2, (height - extent[1] * fit) / 2])
    canvas = Image.new("1", (width, height), 0)
    draw = ImageDraw.Draw(canvas)
    for points in projected:
        hull = convex_hull(((points - low) * fit + offset).tolist())
        draw.polygon([tuple(point) for point in hull], fill=1)
    model = numpy.asarray(canvas, dtype=bool)
    overlap = float((model & target).sum() / max((model | target).sum(), 1))
    overlay = numpy.full((height, width, 3), 255, dtype=numpy.uint8)
    overlay[model & target] = (190, 190, 190)
    overlay[target & ~model] = (230, 40, 40)
    overlay[model & ~target] = (40, 90, 230)
    Image.fromarray(overlay).save(overlay_path)
    return overlap

def run_checks(model, source, output_folder):
    boxes = model.boxes
    bounds = [(box.corners().min(axis=0), box.corners().max(axis=0)) for box in boxes]
    groups = find_groups(bounds)
    everything = numpy.vstack([box.corners() for box in boxes])
    extents = everything.max(axis=0) - everything.min(axis=0)
    front_areas = {}
    for box in boxes:
        key = (round(box.low[2], 2), box.size[2])
        front_areas[key] = front_areas.get(key, 0) + box.size[0] * box.size[1]
    prism = max(front_areas.values()) / sum(front_areas.values())
    tiny = sum(1 for box in boxes if box.size[0] * box.size[1] * box.size[2] <= 2)
    checks = {
        "extent_units_xyz": [round(float(value), 1) for value in extents],
        "boxes": len(boxes),
        "groups": len(groups),
        "prism_fraction": round(float(prism), 3),
        "tiny_box_share": round(tiny / len(boxes), 3),
        "depth_to_size": round(float(extents[2] / max(extents[0], extents[1])), 3),
    }
    if source and source.get("mask"):
        mask = numpy.asarray(Image.open(source["mask"]).convert("L")) > 127
        checks["silhouette_overlap"] = round(compare_silhouette(
            boxes, mask, source.get("yaw", 0), source.get("pitch", 0), output_folder / "overlay.png"), 3)
    failures = []
    if len(groups) > 1:
        names = [[boxes[index].name or str(boxes[index].low) for index in group][:4] for group in groups[1:]]
        failures.append(f"{len(groups) - 1} floating part group(s): {names}")
    if prism > 0.92:
        failures.append(f"prism_fraction {prism:.2f}: every part shares one depth, the model is an extrusion")
    warnings = []
    if len(boxes) > MOST_PARTS:
        warnings.append(f"{len(boxes)} boxes: heading back toward voxels, merge parts and paint the detail instead")
    if tiny / len(boxes) > TINY_SHARE:
        warnings.append("over a third of the boxes are tiny: paint that detail on a face instead")
    clashes = find_coplanar_overlaps(boxes)
    if clashes:
        warnings.append(f"{len(clashes)} coplanar overlap(s) will flicker: {clashes[:6]}")
    if checks["depth_to_size"] < 0.2:
        warnings.append("depth is under a fifth of the size: check it against the object's real depth")
    if checks.get("silhouette_overlap", 1.0) < 0.65:
        warnings.append("silhouette_overlap under 0.65: open overlay.png, proportions or yaw/pitch are off (thin frames score low anyway)")
    checks["failures"] = failures
    checks["warnings"] = warnings
    return checks

def choose_device(requested):
    """auto stays off the GPU while someone else is using it, so a render never fights another job."""
    if requested != "auto":
        return requested
    nvidia_smi = shutil.which("nvidia-smi")
    if nvidia_smi is None:
        return "cpu"
    result = subprocess.run([nvidia_smi, "--query-gpu=utilization.gpu", "--format=csv,noheader,nounits"], capture_output=True, text=True)
    try:
        return "cpu" if int(result.stdout.split()[0]) >= GPU_BUSY_PERCENT else "gpu"
    except (IndexError, ValueError):
        return "cpu"

def render_views(glb_path, render_folder, views, samples, resolution, device="auto"):
    if not Path(BLENDER_PATH).exists():
        raise SystemExit(f"Blender not found at {BLENDER_PATH}; set BLENDER_PATH")
    render_folder.mkdir(parents=True, exist_ok=True)
    views_path = render_folder / "views.json"
    views_path.write_text(json.dumps(views), encoding="utf-8")
    command = [BLENDER_PATH, "-b", "--factory-startup", "-t", "6", "-P", str(SCRIPT_FOLDER / "render_blender.py"), "--",
               str(glb_path), str(render_folder), str(views_path), str(samples), str(resolution), choose_device(device)]
    flags = BELOW_NORMAL_PRIORITY if os.name == "nt" else 0
    result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", creationflags=flags)
    missing = [view["name"] for view in views if not (render_folder / f'{view["name"]}.png').exists()]
    if result.returncode != 0 or missing:
        print(result.stdout[-2500:])
        print(result.stderr[-1500:])
        raise SystemExit(f"Blender render failed, missing: {missing}")

def flatten_on_backdrop(path):
    render = Image.open(path).convert("RGBA")
    backdrop = Image.new("RGBA", render.size, BACKDROP + (255,))
    return Image.alpha_composite(backdrop, render).convert("RGB")

def crop_source(source, panel_size):
    picture = Image.open(source["image"]).convert("RGB")
    if source.get("mask"):
        mask = numpy.asarray(Image.open(source["mask"]).convert("L")) > 127
        rows = numpy.where(mask.any(axis=1))[0]
        columns = numpy.where(mask.any(axis=0))[0]
        margin = int(max(rows[-1] - rows[0], columns[-1] - columns[0]) * 0.12)
        picture = picture.crop((max(columns[0] - margin, 0), max(rows[0] - margin, 0),
                                min(columns[-1] + margin, picture.width), min(rows[-1] + margin, picture.height)))
    picture.thumbnail((panel_size, panel_size))
    panel = Image.new("RGB", (panel_size, panel_size), BACKDROP)
    panel.paste(picture, ((panel_size - picture.width) // 2, (panel_size - picture.height) // 2))
    return panel

def compose_comparison(source, render_folder, views, path, panel_size=420):
    panels = []
    if source and source.get("image"):
        panels.append(("picture", crop_source(source, panel_size)))
    for view in views:
        panels.append((view["name"], flatten_on_backdrop(render_folder / f'{view["name"]}.png').resize((panel_size, panel_size))))
    columns = 3
    rows = (len(panels) + columns - 1) // columns
    sheet = Image.new("RGB", (columns * panel_size, rows * panel_size), "white")
    draw = ImageDraw.Draw(sheet)
    for index, (label, panel) in enumerate(panels):
        position = ((index % columns) * panel_size, (index // columns) * panel_size)
        sheet.paste(panel, position)
        draw.text((position[0] + 8, position[1] + 6), label, fill=(30, 30, 30))
    sheet.save(path)

def render_turntable(glb_path, output_folder, frame_count, samples, resolution, pitch, device="auto"):
    frames_folder = output_folder / "turntable_frames"
    if frames_folder.exists():
        shutil.rmtree(frames_folder)
    views = [{"name": f"frame_{index:04d}", "yaw": 360 * index / frame_count, "pitch": pitch} for index in range(frame_count)]
    render_views(glb_path, frames_folder, views, samples, resolution, device)
    for view in views:
        frame_path = frames_folder / f'{view["name"]}.png'
        flatten_on_backdrop(frame_path).save(frame_path)
    ffmpeg = shutil.which("ffmpeg")
    if ffmpeg is None:
        print("ffmpeg not found: frames stay in", frames_folder)
        return
    subprocess.run([ffmpeg, "-y", "-loglevel", "error", "-framerate", "24", "-i", str(frames_folder / "frame_%04d.png"),
                    "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", str(output_folder / "turntable.mp4")], check=True)
    shutil.rmtree(frames_folder)

def main():
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("object_script")
    parser.add_argument("--out", required=True)
    parser.add_argument("--no-render", action="store_true")
    parser.add_argument("--samples", type=int, default=64)
    parser.add_argument("--resolution", type=int, default=800)
    parser.add_argument("--device", choices=["auto", "gpu", "cpu"], default="auto", help="auto uses the CPU while the GPU is busy")
    parser.add_argument("--turntable", type=int, default=0, help="frames of a turntable.mp4 at 24 fps")
    arguments = parser.parse_args()

    object_path = Path(arguments.object_script).resolve()
    output_folder = Path(arguments.out)
    output_folder.mkdir(parents=True, exist_ok=True)
    specification = importlib.util.spec_from_file_location("block_object_script", object_path)
    object_module = importlib.util.module_from_spec(specification)
    specification.loader.exec_module(object_module)
    source = dict(getattr(object_module, "SOURCE", {}))
    for key in ("image", "mask"):
        if key in source and not Path(source[key]).is_absolute():
            source[key] = str(object_path.parent / source[key])
    model = object_module.build(block_kit)

    glb_path = output_folder / "model.glb"
    export_stats = block_export.write_glb(model, glb_path)
    block_export.write_viewer(glb_path, output_folder / "viewer.html", object_path.stem)
    checks = run_checks(model, source, output_folder)
    checks.update(export_stats)
    (output_folder / "checks.json").write_text(json.dumps(checks, indent=1), encoding="utf-8")
    print(json.dumps(checks))

    if not arguments.no_render:
        yaw = source.get("yaw", 30)
        pitch = source.get("pitch", 12)
        views = [
            {"name": "same_view", "yaw": yaw, "pitch": pitch},
            {"name": "front", "yaw": 0, "pitch": 6},
            {"name": "right", "yaw": 90, "pitch": 6},
            {"name": "back", "yaw": 180, "pitch": 12},
            {"name": "left_above", "yaw": -55, "pitch": 38},
        ]
        render_folder = output_folder / "views"
        render_views(glb_path, render_folder, views, arguments.samples, arguments.resolution, arguments.device)
        compose_comparison(source, render_folder, views, output_folder / "compare.png")
        if arguments.turntable:
            render_turntable(glb_path, output_folder, arguments.turntable, arguments.samples, arguments.resolution, 16, arguments.device)
    if checks["failures"]:
        print("FAILED:", "; ".join(checks["failures"]))
        raise SystemExit(1)

if __name__ == "__main__":
    main()
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/read_image.py
size: 9842
sha: 3e5cec305e282b148cd73b681ed95ada6c3c591d
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/read_image.py

```text
# /// script
# dependencies = ["numpy", "pillow", "opencv-python-headless"]
# ///
"""Turn a picture into what a builder reads: object mask, palette, and a blueprint in block coordinates.

  read_image.py picture.jpg --blocks 24 --out read/ [--box x0,y0,x1,y1] [--polygon "x,y x,y ..."] [--colours 14]
                [--clear-background 18]

--blocks is the object's height in model units; 16 to 32 is the Minecraft range. --box is the object's pixel rectangle (GrabCut starts from it);
without it the mask comes from the whole picture, which fails on busy backgrounds. --polygon overrides the mask by hand.
Writes mask.png, blueprint.png (pixelated at block resolution with a ruler), palette.png and rows.json.
"""

import argparse
import json
import sys
from pathlib import Path

import cv2
import numpy
from PIL import Image, ImageDraw

def make_mask(picture, box, polygon):
    height, width = picture.shape[:2]
    if polygon:
        mask = Image.new("L", (width, height), 0)
        ImageDraw.Draw(mask).polygon(polygon, fill=255)
        return numpy.asarray(mask) > 0
    if box is None:
        margin = max(min(width, height) // 40, 2)
        box = (margin, margin, width - margin, height - margin)
    labels = numpy.zeros((height, width), dtype=numpy.uint8)
    background_model = numpy.zeros((1, 65), dtype=numpy.float64)
    foreground_model = numpy.zeros((1, 65), dtype=numpy.float64)
    rectangle = (box[0], box[1], box[2] - box[0], box[3] - box[1])
    bgr = cv2.cvtColor(picture, cv2.COLOR_RGB2BGR)
    cv2.grabCut(bgr, labels, rectangle, background_model, foreground_model, 6, cv2.GC_INIT_WITH_RECT)
    mask = (labels == cv2.GC_FGD) | (labels == cv2.GC_PR_FGD)
    count, components, statistics, _ = cv2.connectedComponentsWithStats(mask.astype(numpy.uint8), connectivity=8)
    if count > 1:
        largest = 1 + int(numpy.argmax(statistics[1:, cv2.CC_STAT_AREA]))
        mask = components == largest
    return mask

def clear_background(picture, mask, box, tolerance):
    """Drop mask pixels close to the colours on the box's own border. Opens holes GrabCut keeps: inside a handle, between slats."""
    height, width = picture.shape[:2]
    left, top, right, bottom = box if box else (0, 0, width, height)
    ring = numpy.concatenate([picture[top:top + 6, left:right].reshape(-1, 3), picture[bottom - 6:bottom, left:right].reshape(-1, 3),
                              picture[top:bottom, left:left + 6].reshape(-1, 3), picture[top:bottom, right - 6:right].reshape(-1, 3)])
    ring_lab = cv2.cvtColor(ring.reshape(-1, 1, 3), cv2.COLOR_RGB2LAB).reshape(-1, 3).astype(numpy.float32)
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.5)
    _, labels, centers = cv2.kmeans(ring_lab, 4, None, criteria, 3, cv2.KMEANS_PP_CENTERS)
    shares = numpy.bincount(labels.ravel(), minlength=4) / len(labels)
    centers = centers[shares >= 0.12]  # a thin object crossing the ring must not count as background
    picture_lab = cv2.cvtColor(picture, cv2.COLOR_RGB2LAB).astype(numpy.float32)
    distances = numpy.min(numpy.linalg.norm(picture_lab[:, :, None, :] - centers[None, None, :, :], axis=3), axis=2)
    cleared = mask & (distances > tolerance)
    count, components, statistics, _ = cv2.connectedComponentsWithStats(cleared.astype(numpy.uint8), connectivity=8)
    if count > 1:
        largest = 1 + int(numpy.argmax(statistics[1:, cv2.CC_STAT_AREA]))
        cleared = components == largest
    return cleared

def extract_palette(picture, mask, colour_count):
    pixels = picture[mask]
    if len(pixels) > 60000:
        step = len(pixels) // 60000
        pixels = pixels[::step]
    lab = cv2.cvtColor(pixels.reshape(-1, 1, 3), cv2.COLOR_RGB2LAB).reshape(-1, 3).astype(numpy.float32)
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 40, 0.5)
    _, labels, centers = cv2.kmeans(lab, colour_count, None, criteria, 4, cv2.KMEANS_PP_CENTERS)
    coverage = numpy.bincount(labels.ravel(), minlength=colour_count) / len(labels)
    rgb = cv2.cvtColor(centers.astype(numpy.uint8).reshape(-1, 1, 3), cv2.COLOR_LAB2RGB).reshape(-1, 3)
    swatches = []
    for colour, share, center in zip(rgb, coverage, centers):
        swatches.append({"hex": "#%02x%02x%02x" % tuple(int(channel) for channel in colour),
                         "share": round(float(share), 3), "lightness": int(center[0])})
    swatches.sort(key=lambda swatch: -swatch["lightness"])
    return swatches

def draw_palette(swatches, path):
    sheet = Image.new("RGB", (120 * len(swatches), 90), "white")
    draw = ImageDraw.Draw(sheet)
    for index, swatch in enumerate(swatches):
        draw.rectangle((index * 120, 0, index * 120 + 119, 62), fill=swatch["hex"])
        draw.text((index * 120 + 4, 66), f'{swatch["hex"]} {swatch["share"]:.0%}', fill="black")
    sheet.save(path)

def draw_blueprint(picture, mask, blocks_high, path):
    rows_with_object = numpy.where(mask.any(axis=1))[0]
    columns_with_object = numpy.where(mask.any(axis=0))[0]
    top = int(rows_with_object[0])
    bottom = int(rows_with_object[-1]) + 1
    left = int(columns_with_object[0])
    right = int(columns_with_object[-1]) + 1
    pixels_per_block = (bottom - top) / blocks_high
    blocks_wide = max(int(round((right - left) / pixels_per_block)), 1)
    crop = picture[top:bottom, left:right]
    crop_mask = mask[top:bottom, left:right].astype(numpy.float32)
    small = cv2.resize(crop, (blocks_wide, blocks_high), interpolation=cv2.INTER_AREA)
    small_mask = cv2.resize(crop_mask, (blocks_wide, blocks_high), interpolation=cv2.INTER_AREA)
    cell = max(min(1100 // max(blocks_wide, blocks_high), 26), 8)
    margin = 34
    sheet = Image.new("RGB", (blocks_wide * cell + margin * 2, blocks_high * cell + margin * 2), (250, 250, 250))
    draw = ImageDraw.Draw(sheet)
    for column in range(blocks_wide):
        for row in range(blocks_high):
            x0 = margin + column * cell
            y0 = margin + row * cell
            if small_mask[row, column] >= 0.5:
                draw.rectangle((x0, y0, x0 + cell - 1, y0 + cell - 1), fill=tuple(int(c) for c in small[row, column]))
            else:
                faded = tuple(int(c * 0.35 + 236 * 0.65) for c in small[row, column])
                draw.rectangle((x0, y0, x0 + cell - 1, y0 + cell - 1), fill=faded)
    for column in range(0, blocks_wide + 1, 5):
        x_position = margin + column * cell
        draw.line((x_position, margin, x_position, margin + blocks_high * cell), fill=(255, 0, 90), width=1)
        draw.text((x_position - 6, 16), str(column), fill="black")
        draw.text((x_position - 6, margin + blocks_high * cell + 6), str(column), fill="black")
    for height in range(0, blocks_high + 1, 5):
        y_position = margin + (blocks_high - height) * cell
        draw.line((margin, y_position, margin + blocks_wide * cell, y_position), fill=(255, 0, 90), width=1)
        draw.text((8, y_position - 6), str(height), fill="black")
        draw.text((margin + blocks_wide * cell + 6, y_position - 6), str(height), fill="black")
    sheet.save(path)
    rows = {}
    for height in range(blocks_high):
        row = blocks_high - 1 - height
        filled = numpy.where(small_mask[row] >= 0.5)[0]
        if len(filled) == 0:
            continue
        runs = []
        run_start = int(filled[0])
        previous = int(filled[0])
        for column in filled[1:]:
            column = int(column)
            if column != previous + 1:
                runs.append([run_start, previous])
                run_start = column
            previous = column
        runs.append([run_start, previous])
        rows[height] = runs
    return {"blocks_wide": blocks_wide, "blocks_high": blocks_high, "object_box": [left, top, right, bottom],
            "pixels_per_block": round(pixels_per_block, 2), "rows_from_ground": rows}

def main():
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("image")
    parser.add_argument("--blocks", type=int, default=24)
    parser.add_argument("--out", required=True)
    parser.add_argument("--box")
    parser.add_argument("--polygon")
    parser.add_argument("--colours", type=int, default=14)
    parser.add_argument("--clear-background", type=float, help="Lab distance, about 12 to 25; for plain backgrounds only")
    arguments = parser.parse_args()

    output_folder = Path(arguments.out)
    output_folder.mkdir(parents=True, exist_ok=True)
    picture = numpy.asarray(Image.open(arguments.image).convert("RGB"))
    box = [int(value) for value in arguments.box.split(",")] if arguments.box else None
    polygon = None
    if arguments.polygon:
        polygon = [tuple(int(value) for value in point.split(",")) for point in arguments.polygon.split()]
    mask = make_mask(picture, box, polygon)
    if arguments.clear_background:
        mask = clear_background(picture, mask, box, arguments.clear_background)
    if mask.sum() < 100:
        raise SystemExit("mask is empty: pass a tighter --box or a --polygon")
    Image.fromarray((mask * 255).astype(numpy.uint8)).save(output_folder / "mask.png")
    swatches = extract_palette(picture, mask, arguments.colours)
    draw_palette(swatches, output_folder / "palette.png")
    blueprint = draw_blueprint(picture, mask, arguments.blocks, output_folder / "blueprint.png")
    blueprint["palette"] = swatches
    (output_folder / "rows.json").write_text(json.dumps(blueprint, indent=1), encoding="utf-8")
    print(f'blocks {blueprint["blocks_wide"]} wide x {blueprint["blocks_high"]} high, '
          f'mask covers {mask.mean():.0%} of the picture')
    print("palette", " ".join(f'{swatch["hex"]}:{swatch["share"]:.0%}' for swatch in swatches))
    print("wrote", output_folder)

if __name__ == "__main__":
    main()
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/render_blender.py
size: 4104
sha: d1822586711ea733d2cde59fefb0f80e514f39ad
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/render_blender.py

```text
"""Runs inside Blender: blender -b -P render_blender.py -- model.glb out_folder views.json samples resolution [gpu|cpu]
views.json = [{"name": "front", "yaw": 0, "pitch": 8}, ...]. yaw 0 looks at the object's front (+z in the model);
positive yaw moves the camera to the object's right. Writes out_folder/<name>.png with a transparent background and the
shadow kept.
"""

import json
import math
import sys
from pathlib import Path

import bpy
from mathutils import Vector

arguments = sys.argv[sys.argv.index("--") + 1:]
model_path = arguments[0]
output_folder = Path(arguments[1])
views = json.loads(Path(arguments[2]).read_text(encoding="utf-8"))
samples = int(arguments[3])
resolution = int(arguments[4])
device_choice = arguments[5] if len(arguments) > 5 else "gpu"

bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=model_path)
meshes = [item for item in bpy.context.scene.objects if item.type == "MESH"]

# Pixels stay sharp: force Closest on every image node, whatever the importer chose.
for material in bpy.data.materials:
    if material.use_nodes:
        for node in material.node_tree.nodes:
            if node.type == "TEX_IMAGE":
                node.interpolation = "Closest"
            if node.type == "BSDF_PRINCIPLED":
                node.inputs["Specular IOR Level"].default_value = 0.3

corners = []
for mesh in meshes:
    for corner in mesh.bound_box:
        corners.append(mesh.matrix_world @ Vector(corner))
low = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners)))
high = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners)))
center = (low + high) / 2
radius = (high - low).length / 2

scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.samples = samples
scene.cycles.use_denoising = True
scene.cycles.device = "GPU"
preferences = bpy.context.preferences.addons["cycles"].preferences
for backend in ("OPTIX", "CUDA", "NONE"):
    try:
        preferences.compute_device_type = backend
        break
    except TypeError:
        continue
preferences.get_devices()
for device in preferences.devices:
    device.use = device.type != "CPU"
if device_choice == "cpu" or not any(device.use for device in preferences.devices):
    scene.cycles.device = "CPU"
scene.render.resolution_x = resolution
scene.render.resolution_y = resolution
scene.render.film_transparent = True
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.view_settings.view_transform = "Standard"
scene.view_settings.look = "None"

world = bpy.data.worlds.new("studio")
world.use_nodes = True
background = world.node_tree.nodes["Background"]
background.inputs["Color"].default_value = (1.0, 0.98, 0.95, 1.0)
background.inputs["Strength"].default_value = 0.72
scene.world = world

bpy.ops.mesh.primitive_plane_add(size=radius * 60, location=(center.x, center.y, low.z))
bpy.context.object.is_shadow_catcher = True

sun_data = bpy.data.lights.new("sun", "SUN")
sun_data.energy = 2.2
sun_data.angle = math.radians(18)
sun = bpy.data.objects.new("sun", sun_data)
scene.collection.objects.link(sun)
sun.rotation_euler = (math.radians(48), 0, math.radians(-35))

camera_data = bpy.data.cameras.new("camera")
camera_data.lens = 70
camera_data.sensor_width = 36
camera_data.clip_end = radius * 200
camera = bpy.data.objects.new("camera", camera_data)
scene.collection.objects.link(camera)
scene.camera = camera
half_view = math.atan(camera_data.sensor_width / 2 / camera_data.lens)
distance = radius * 1.08 / math.sin(half_view)

for view in views:
    yaw = math.radians(view["yaw"])
    pitch = math.radians(view["pitch"])
    # The glTF importer maps model +z (front) to Blender -y.
    direction = Vector((math.sin(yaw) * math.cos(pitch), -math.cos(yaw) * math.cos(pitch), math.sin(pitch)))
    camera.location = center + direction * distance
    camera.rotation_euler = (-direction).to_track_quat("-Z", "Y").to_euler()
    scene.render.filepath = str(output_folder / f'{view["name"]}.png')
    bpy.ops.render.render(write_still=True)
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/test_block_model.py
size: 6983
sha: 580bda82c4fe2912858ff12c6d7fb6a853a4f44a
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/test_block_model.py

```text
# /// script
# dependencies = ["numpy", "pillow"]
# ///
"""Behaviour tests for the claims SKILL.md makes. Run: uv run --no-project test_block_model.py. No GPU or Blender needed."""

import json
import struct
import subprocess
import sys
import tempfile
from pathlib import Path

import numpy

SCRIPT_FOLDER = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_FOLDER))

import block_export  # noqa: E402
import block_kit  # noqa: E402

OBJECT_HEADER = """
def build(kit):
    materials = {"stone": kit.Material(["#aaaaaa", "#888888", "#666666"])}
    model = kit.BlockModel(materials)
"""

def run_object_script(folder, body):
    script = folder / "object.py"
    script.write_text(OBJECT_HEADER + body + "\n    return model\n", encoding="utf-8")
    output = folder / "output ảnh"  # non-ASCII and a space, on purpose
    result = subprocess.run([sys.executable, str(SCRIPT_FOLDER / "build_model.py"), str(script), "--out", str(output), "--no-render"],
                            capture_output=True, text=True, encoding="utf-8", errors="replace")
    checks_path = output / "checks.json"
    checks = json.loads(checks_path.read_text(encoding="utf-8")) if checks_path.exists() else {}
    return result.returncode, checks, output

def read_glb(path):
    data = Path(path).read_bytes()
    magic, version, total = struct.unpack("<III", data[:12])
    assert magic == 0x46546C67 and version == 2 and total == len(data)
    json_length, json_type = struct.unpack("<II", data[12:20])
    assert json_type == 0x4E4F534A
    document = json.loads(data[20:20 + json_length])
    binary_start = 20 + json_length + 8
    return document, data[binary_start:]

def read_accessor(document, binary, index, component_count, dtype):
    accessor = document["accessors"][index]
    view = document["bufferViews"][accessor["bufferView"]]
    values = numpy.frombuffer(binary, dtype=dtype, count=accessor["count"] * component_count, offset=view["byteOffset"])
    return values.reshape(-1, component_count) if component_count > 1 else values

def test_floating_part_fails():
    with tempfile.TemporaryDirectory() as folder:
        code, checks, _ = run_object_script(Path(folder), '    model.box((0, 0, 0), (4, 4, 4), "stone")\n    model.box((9, 9, 9), (2, 2, 2), "stone")')
        assert code == 1 and checks["groups"] == 2, checks

def test_extrusion_fails():
    with tempfile.TemporaryDirectory() as folder:
        code, checks, _ = run_object_script(Path(folder), '    model.box((0, 0, 0), (8, 3, 2), "stone")\n    model.box((3, 3, 0), (2, 6, 2), "stone")')
        assert code == 1 and checks["prism_fraction"] > 0.92, checks

def test_real_model_passes_and_writes_files():
    with tempfile.TemporaryDirectory() as folder:
        body = '    model.round(8, 8, 0, 4, 12, "stone", corner=3)\n    model.box((6, 4, 6), (4, 6, 4), "stone")\n    model.beam((8, 10, 8), (12, 14, 8), (1, 2), "stone")'
        code, checks, output = run_object_script(Path(folder), body)
        assert code == 0, checks
        assert not checks["warnings"], checks["warnings"]
        assert (output / "model.glb").exists() and (output / "viewer.html").exists()

def test_coplanar_overlap_warns():
    with tempfile.TemporaryDirectory() as folder:
        body = '    model.box((0, 0, 0), (6, 6, 6), "stone")\n    model.box((3, 3, 0), (6, 6, 3), "stone")'
        _, checks, _ = run_object_script(Path(folder), body)
        assert any("coplanar" in warning for warning in checks["warnings"]), checks

def test_tiny_boxes_warn():
    with tempfile.TemporaryDirectory() as folder:
        body = '    model.box((0, 0, 0), (8, 8, 8), "stone")\n    for index in range(4):\n        model.box((index * 2, 8, 0), (1, 1, 1), "stone")'
        _, checks, _ = run_object_script(Path(folder), body)
        assert any("tiny" in warning for warning in checks["warnings"]), checks

def test_winding_matches_normals_and_uvs_are_in_range():
    materials = {"stone": block_kit.Material(["#aaaaaa", "#888888"])}
    model = block_kit.BlockModel(materials)
    model.box((0, 0, 0), (3, 4, 5), "stone")
    model.box((0, 4, 0), (3, 2, 1), "stone", rotation=(10, 40, 20))
    with tempfile.TemporaryDirectory() as folder:
        path = Path(folder) / "model.glb"
        block_export.write_glb(model, path)
        document, binary = read_glb(path)
    primitive = document["meshes"][0]["primitives"][0]
    positions = read_accessor(document, binary, primitive["attributes"]["POSITION"], 3, numpy.float32)
    normals = read_accessor(document, binary, primitive["attributes"]["NORMAL"], 3, numpy.float32)
    uvs = read_accessor(document, binary, primitive["attributes"]["TEXCOORD_0"], 2, numpy.float32)
    indices = read_accessor(document, binary, primitive["indices"], 1, numpy.uint32)
    assert document["samplers"][0]["magFilter"] == 9728
    assert uvs.min() >= 0 and uvs.max() <= 1
    for triangle in indices.reshape(-1, 3):
        first, second, third = positions[triangle]
        geometric = numpy.cross(second - first, third - first)
        assert numpy.dot(geometric, normals[triangle[0]]) > 0

def test_paint_lands_where_seen_from_outside():
    """Top-left of the front face as seen is low x, high y; on the right face, top-left as seen is high z."""
    materials = {"stone": block_kit.Material(["#808080"], noise=0.0, rim=False)}
    model = block_kit.BlockModel(materials)
    box = model.box((0, 0, 0), (4, 4, 4), "stone")
    box.paint("front", ["#"], {"#": "#ff0000"})
    box.paint("right", ["#"], {"#": "#00ff00"})
    frames = block_export.FACE_FRAMES
    assert tuple(box.textures["front"][0, 0]) == (255, 0, 0)
    assert frames["front"]["right"] == (1, 0, 0) and frames["front"]["origin"] == (0, 0, 1)
    assert tuple(box.textures["right"][0, 0]) == (0, 255, 0)
    assert frames["right"]["right"] == (0, 0, -1) and frames["right"]["origin"] == (1, 0, 1)

def test_round_fillers_do_not_overlap_the_core():
    materials = {"stone": block_kit.Material(["#808080"])}
    model = block_kit.BlockModel(materials)
    parts = model.round(10, 10, 0, 3, 20, "stone", corner=4)
    assert len(parts) == 9
    for first_index, first in enumerate(parts):
        for second in parts[first_index + 1:]:
            overlaps = all(first.low[axis] < second.low[axis] + second.size[axis] and second.low[axis] < first.low[axis] + first.size[axis]
                           for axis in range(3))
            assert not overlaps, (first.low, second.low)

def main():
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    tests = [value for name, value in sorted(globals().items()) if name.startswith("test_")]
    failed = 0
    for test in tests:
        try:
            test()
            print("ok  ", test.__name__)
        except Exception as error:  # report every test, not just the first failure
            failed += 1
            print("FAIL", test.__name__, repr(error)[:400])
    raise SystemExit(1 if failed else 0)

if __name__ == "__main__":
    main()
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/references/faults.md
size: 3731
sha: bc2e641f4ada23a2b86344ad90104367215116f6
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/references/faults.md

```text
# Faults found, and what fixed them

Measured on the test run of 2026-10-31: iron kettle, armchair, rubber duck and fire hydrant. Go through this list when looking at `compare.png`.

## The look

| Fault | Fix |
| --- | --- |
| Fine voxel grid at 40+ blocks tall looks jagged and ugly. Rounded forms become staircases and the block grid fights the object | Box model: few cuboids, detail in paint. The old voxel engine is not coming back |
| Texture looks like ore or camouflage: dark iron with bright orange specks, cushion in big two-tone patches | Close ramp steps, speckle colours near the base, share under 0.1 |
| Smooth things look like sponge: rubber and paint with a noisy texture | `noise` around 0.15 on paint and glossy plastic |
| A `round` part from above shows a plus-shaped notch at each corner | `round` and `disc` now fill the notches once `corner` is 3 or more |
| Legs too chunky at the Minecraft-default size | Raise total height (32 to 40 units) so the same 2 x 2 leg is slenderer |
| A one-cube head reads as a box with a beak | Chamfered `round` tiers, with a small flat front for the painted face |

## Reading the picture

| Fault | Fix |
| --- | --- |
| Parts named from the blueprint alone were wrong: a white "bib" was a collared shirt, a die was a playing card, a "dark neck" was a port's shadow | Look at the full picture first and name parts from that. The blueprint is only for coordinates |
| `read_image.py` mask keeps the background inside handles and between slats | `--clear-background 18` on plain backgrounds (24 for pale ones) |
| A thin object crossing the box border was counted as background and ate the mask | Only colours with a 12% share of the box border count. Keep the box loose around the object |
| Busy backgrounds: colour clearing is not safe | Tight `--box` only. The blueprint fades pixels outside the mask, rather than hiding them, so a bad mask still leaves the object readable |

## Building

| Fault | Fix |
| --- | --- |
| A nut placed at the rim's depth vanishes inside the recess | The core's front is at `low_z + depth - recess`. Put the nut from there outward |
| Side ports built long and at a wide angle looked like wings | Aim them nearer the front and keep them short. Check overall width against the picture |
| Arm boards and cushions share a plane with posts and legs, and flicker | Butt the part against the post instead of passing through it. The coplanar warning catches these |
| The picture's view looks right and the back is lazy | `compare.png` has back and side panels on purpose. Look at them as hard |
| A blind reader took the pink hydrant for a pig: raised side ports were ears, the front port a snout, the tiered skirt a costume. The model matched the photo and still read wrong | Add the cues that name the object, even past what the picture stresses: level ports with flanged collars, a bolted flange, an operating nut. Then read it blind again. Result: hydrant became the top guess, though only at 40%, so a recoloured or unusual object stays the hard case |
| Very fine pixel art is noise: card and die pips at 3 units wide read as checkers | Give that detail 4 to 5 units or drop it |
| Brand text from the picture | Leave it out and say so in the docstring |

## Rendering

| Fault | Fix |
| --- | --- |
| Horizon line across the background | Shadow catcher and transparent film. `compare.png` flattens over one backdrop colour |
| Grainy shadow on the floor | Denoising on. The pixel texture stays sharp with sampler Closest |
| A render pass took over 500 s, at about 30 s per view instead of 5 | Not a bug: the GPU was at 100% with another Blender and a game. Check `nvidia-smi` first, and lower samples and resolution when it is busy |
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/references/method.md
size: 4828
sha: 98042131cb9b52d278336ed6ce7be936976b42bc
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/references/method.md

```text
# Method and API

Reading map: Reading the view, Choosing boxes, API, Materials, Paint, Inferring the unseen sides. Read the whole section you need.

## Reading the view

Convention: x is width (+x is the viewer's right when facing the object's front), y is up, z is depth (+z is the front).
`yaw` 0 looks at the front. Positive yaw moves the camera to the object's right, so the object seems to point left in the picture.
`pitch` is the camera's height above the horizon. Both go in `SOURCE`.

A side-on or front-on picture is yaw 0 and a small pitch. For a three-quarter picture of something with feet, solve from the feet on the blueprint.
With near-front foot as the origin: moving to the near-back foot shifts the picture by (a, b), and moving to the far-front foot shifts it by (-c, d). Then:

- `tan(yaw) * sin(pitch) = d / c`
- `sin(pitch) / tan(yaw) = b / a`
- `depth = a / sin(yaw)`, `width = c / cos(yaw)`
- real heights = picture heights / `cos(pitch)`

The chair's four feet gave yaw 57, pitch 25-ish and width about equal to depth this way. Without feet, estimate from how far the centre line (beak, front port) sits off the middle of the silhouette.

## Choosing boxes

One box per real part, then ask whether the part can be merged and painted. Height in units comes from the thinnest thing that must show:
chair legs of 2 x 2 looked clumsy at 32 tall and right at 40. A kettle needed only 28.

| Shape in the picture | Build it with |
| --- | --- |
| Turned body (kettle, barrel) | `round` tiers following the silhouette rows: narrow, wide, wide, narrow |
| Ball-like head | `round` tiers with a large `corner`, plus a flat front for the painted face |
| Port, lens, wheel | `disc` with `recess=1` for a real dent, then a small box for the nut |
| Handle, brace, wire | `beam`s between points. 4 to 7 segments make a clean arch |
| Thin frame | plain boxes at 1 to 2 units, set between the posts so faces never share a plane |
| Lettering, eyes, screws, patterns | `paint` on a face, never boxes |

## API

`build(kit)` returns a `kit.BlockModel(materials, seed=...)`. All sizes are whole units. `low` may be a half unit, to centre odd sizes.

- `model.box(low, size, material, top=, bottom=, sides=, rotation=(x, y, z), pivot=, name=)` returns a `Box`. Rotation is in degrees, applied z, then x, then y, around `pivot` (default: the box's centre).
- `model.round(center_x, center_z, low_y, height, diameter, material, corner=, top=, bottom=, name=)` returns 5 boxes: core, back slab, front slab, left slab, right slab. So index `[2]` is the front.
- `model.disc(center_x, center_y, low_z, depth, diameter, material, corner=, recess=, core=, rotation=, pivot=, name=)` is a round part facing +z. Rotate it to aim it: a rotation of (-12, 50, 0) aims it right, forward and slightly up. Returns `(core_box, rim_boxes)`.
- `model.beam(start, end, (across, deep), material, name=)` is a bar between two points.

Odd diameters need a centre ending in .5 and even ones a whole centre, or the texels land on half units.

## Materials

`kit.Material(ramp, style="plain", noise=0.5, rim=True, roughness=0.85, metallic=0.0)`

- `ramp`: hex colours lightest first, from `palette.png` and the picture. Keep the steps close. Far-apart steps look like camouflage or ore.
- `style`: `plain` scatters texels, `wood` draws grain lines along the long side, `metal` shades top to bottom, `fabric` flecks.
- `noise`: 0.15 for paint and glossy plastic, 0.3 to 0.5 for wood and fabric, 0.5 to 0.7 for rough iron.
- `rim=True` lightens the top row and darkens the bottom row of side faces, which is what makes a part read as a part. Turn it off for tiny pieces and recesses.

## Paint

- `box.paint(face, rows, legend, at=(column, row))` draws pixel art. Faces are `front back left right top bottom`, and rows read as seen from outside that face. On `top`, the front edge is the bottom row.
- `box.band(row, colour)` draws a line round the four sides, counted from the top: seams and stripes.
- `box.speckle(colours, share, faces=)` scatters texels: rust, wear. Keep the colours close to the base, and the share under 0.1 unless the object is truly that worn.
- On a `round`, paint the core or a slab, as they are separate boxes. A face that needs to span wider than the core goes on its own box.

## Inferring the unseen sides

Say the reasoning in the docstring, so it can be checked against later references.

- A turned object repeats itself all the way round. Don't turn its ports with it: decide how many there are.
- Furniture mirrors left to right, and the back rail or stretcher is what shows through the frame.
- Animals and toys: the body is usually longer than wide, and the tail and back continue the visible curve. Clothes wrap round.
- A part that needs support must have it: a handle has lugs, an arm board has a post.
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/chair.py
size: 4089
sha: 16bb3ca178e3a7ea9440494867ace62db42343d6
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/chair.py

```text
"""Arts and Crafts armchair, three-quarter photo. 40 units tall so 2 x 2 legs stay as slender as the photo's.
The four foot positions on the blueprint solve to yaw 57, pitch 25 and width about equal to depth.
Visible: near side, front, thin arm boards, gold printed cushion, four slats, top rail, stretchers.
Inferred: the far side mirrors the near side; the rear stretcher and the back of the slats match what shows through the frame."""

SOURCE = {"image": "source.jpg", "mask": "read/mask.png", "yaw": 57, "pitch": 25}
PRINT_TILE = ["..o...", ".o.o..", "..o..o", "o....o", ".oo...", "...oo."]

def tiled(rows, columns, shift=0):
    return ["".join(PRINT_TILE[(row + shift) % 6][(column + shift) % 6] for column in range(columns)) for row in range(rows)]

def build(kit):
    materials = {
        "wood": kit.Material(["#7d4c3a", "#673c2f", "#532f28", "#432523"], style="wood", noise=0.4, roughness=0.5),
        "arm": kit.Material(["#8e5a3c", "#774630", "#623828"], style="wood", noise=0.4, roughness=0.45),
        "cushion": kit.Material(["#e2cc92", "#cfb274", "#b4955a"], style="fabric", noise=0.25, roughness=0.95),
    }
    model = kit.BlockModel(materials, seed=5)
    width = 15
    depth = 15
    left = 0
    right = width - 2
    back = 0
    front = depth - 2
    inner_width = right - left - 2
    inner_depth = front - back - 2

    # Legs. The back legs run up as the posts; the front legs run up to carry the arms.
    for x in (left, right):
        model.box((x, 0, back), (2, 40, 2), "wood", name="back post")
        model.box((x, 0, front), (2, 28, 2), "wood", name="front leg")

    # Seat rails sit between the legs, set in by half a leg, so they read as separate boards.
    model.box((left + 2, 16, front), (inner_width, 3, 1), "wood", name="front rail")
    model.box((left + 2, 16, back + 1), (inner_width, 3, 1), "wood", name="rear rail")
    model.box((left, 16, back + 2), (1, 3, inner_depth), "wood", name="left rail")
    model.box((right + 1, 16, back + 2), (1, 3, inner_depth), "wood", name="right rail")
    model.box((left + 1, 18, back + 2), (inner_width + 2, 1, inner_depth), "wood", name="seat board")

    # Cushion: sits between the posts and the front legs, its front spilling over the rail, domed on top.
    cushion = model.box((2, 19, 2), (inner_width, 2, inner_depth + 2), "cushion", name="cushion")
    pillow = model.box((3, 21, 3), (inner_width - 2, 1, inner_depth), "cushion", name="cushion dome")
    print_legend = {"o": "#7a5a30"}
    cushion.paint("front", tiled(2, inner_width), print_legend)
    cushion.paint("top", tiled(inner_depth + 2, inner_width), print_legend)
    pillow.paint("top", tiled(inner_depth, inner_width - 2, 3), print_legend)
    pillow.paint("front", tiled(1, inner_width - 2, 2), print_legend)
    pillow.paint("right", tiled(1, inner_depth, 1), print_legend)
    pillow.paint("left", tiled(1, inner_depth, 4), print_legend)

    # Stretchers cross the legs at different heights, as in the photo.
    model.box((left, 9, back + 2), (1, 1, inner_depth), "wood", name="left stretcher")
    model.box((right + 1, 9, back + 2), (1, 1, inner_depth), "wood", name="right stretcher")
    model.box((left + 2, 6, front + 1), (inner_width, 1, 1), "wood", name="front stretcher")
    model.box((left + 2, 12, back), (inner_width, 1, 1), "wood", name="rear stretcher")

    # Back: lower rail, top rail with a raised middle, four thin slats sitting close.
    model.box((left + 2, 24, back), (inner_width, 1, 1), "wood", name="lower back rail")
    model.box((left + 2, 35, back), (inner_width, 2, 1), "wood", name="top rail")
    model.box((left + 5, 37, back), (inner_width - 6, 1, 1), "wood", name="top rail crown")
    for slat_x in (4, 6, 8, 10):
        model.box((left + slat_x, 25, back), (1, 10, 1), "wood", name="slat")

    # Arms: thin boards butting into the post and running past the front leg, a touch wider than the leg.
    model.box((left - 1, 28, back + 2), (4, 1, depth), "arm", name="left arm")
    model.box((right - 1, 28, back + 2), (4, 1, depth), "arm", name="right arm")
    return model
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/duck.py
size: 4960
sha: 1081ca94d058b716e0e448645ee22a23e9b60c13
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/duck.py

```text
"""Rubber duck in a bronze shirt, two dice in one wing, playing cards in the other, three-quarter photo. 30 units tall.
Visible: big glossy head, wide orange-red beak, white eyes with blue iris, bronze shirt with V collar, yellow belly bowl,
yellow wing tips, dice, cards, tail tip at the right.
Inferred: a rubber duck is longer than wide, so the body runs back behind the head; the raised tail sits on the centre line;
the shirt wraps round the back. The printed brand on the belly is left out on purpose."""

SOURCE = {"image": "source.jpg", "mask": "read/mask.png", "yaw": 20, "pitch": 14}

COLLAR = "#f6dfa4"
EYE = {"W": "#ffffff", "B": "#2f83b8", "P": "#14100e"}
INK = "#1a1412"
RED = "#d8362a"

def build(kit):
    materials = {
        "yellow": kit.Material(["#fce85e", "#f6dc40", "#efcd30"], noise=0.15, roughness=0.3),
        "shirt": kit.Material(["#f0cf84", "#e0b25c", "#c9913e", "#a97030"], style="metal", noise=0.25, roughness=0.35, metallic=0.25),
        "beak_top": kit.Material(["#f78a4a", "#ec6c32", "#d85024"], noise=0.15, roughness=0.3),
        "beak_low": kit.Material(["#d84c20", "#b83616", "#8c2410"], noise=0.15, rim=False, roughness=0.3),
        "die": kit.Material(["#fbfbf6", "#f2f2ea"], noise=0.05, rim=False, roughness=0.25),
    }
    model = kit.BlockModel(materials, seed=3)
    center_x = 11.0

    # Body: a bowl narrowing to the floor, then the shirt, stepped in at the shoulders. The belly is longer than wide.
    model.round(center_x, 14, 0, 1, 14, "yellow", corner=3, name="base")
    model.round(center_x, 14, 1, 2, 18, "yellow", corner=4, name="lower belly")
    model.round(center_x, 14, 3, 5, 22, "yellow", corner=4, name="belly")
    shirt_parts = model.round(center_x, 14, 8, 5, 22, "shirt", corner=4, name="shirt")
    shoulder_parts = model.round(center_x, 15, 13, 2, 18, "shirt", corner=3, name="shoulders")
    collar_rows = ["cc..........cc", ".cc........cc.", "..cc......cc..", "...cc....cc...", "....cccccc....", ".....cccc....."]
    shirt_parts[2].paint("front", collar_rows, {"c": COLLAR})
    shoulder_parts[2].paint("front", ["cc........cc", ".cc......cc."], {"c": COLLAR})

    # Head: chamfered tiers, not one cube, with a small tuft.
    model.round(center_x, 17, 15, 1, 12, "yellow", corner=2, name="neck")
    model.round(center_x, 17, 16, 2, 14, "yellow", corner=3, name="head low")
    head_parts = model.round(center_x, 17, 18, 8, 16, "yellow", corner=3, name="head")
    model.round(center_x, 17, 26, 2, 14, "yellow", corner=3, name="head high")
    model.round(center_x, 17, 28, 1, 8, "yellow", corner=2, name="crown")
    model.box((center_x - 1, 29, 16), (2, 1, 3), "yellow", name="tuft")

    # Eyes are painted on the flat front of the head, as on any Minecraft mob.
    eye_rows = [".WW.", "WBBW", "WPBW", ".WW."]
    head_parts[2].paint("front", eye_rows, EYE, at=(0, 1))
    head_parts[2].paint("front", eye_rows, EYE, at=(6, 1))

    # Beak: the upper sticks out further than the lower and has a rounded front.
    model.box((center_x - 6, 20, 25), (12, 2, 5), "beak_top", name="upper beak")
    model.box((center_x - 5, 20, 30), (10, 2, 1), "beak_top", name="upper beak tip")
    model.box((center_x - 5, 18, 25), (10, 2, 4), "beak_low", name="lower beak")

    # Tail: a rising wedge on the centre line at the back.
    model.box((center_x - 4, 6, 1), (8, 5, 3), "yellow", rotation=(-28, 0, 0), name="tail")
    model.box((center_x - 2, 10, -1), (4, 3, 2), "yellow", rotation=(-28, 0, 0), pivot=(center_x, 8, 2), name="tail tip")

    # Sleeves in shirt colour, wing tips in yellow, each wing reaching forward.
    model.box((-2, 9, 15), (3, 5, 9), "shirt", name="left sleeve")
    model.box((-2, 10, 24), (3, 3, 3), "yellow", name="left wing tip")
    model.box((21, 9, 15), (3, 5, 9), "shirt", name="right sleeve")
    model.box((21, 10, 24), (3, 3, 3), "yellow", name="right wing tip")

    # Two dice on the left wing tip: big enough for clean pips.
    pips = {"o": INK}
    first_die = model.box((-5, 13, 23), (4, 4, 4), "die", rotation=(0, -12, 0), name="die")
    first_die.paint("front", ["o...", "....", "....", "...o"], pips)
    first_die.paint("top", ["....", ".oo.", ".oo.", "...."], pips)
    second_die = model.box((-1, 13, 24), (4, 4, 4), "die", rotation=(0, 16, 0), name="die")
    second_die.paint("front", ["o..o", "....", "....", "o..o"], pips)
    second_die.paint("top", ["....", ".o..", "..o.", "...."], pips)

    # Two playing cards in the right wing tip, fanned: a corner letter and one suit mark each, nothing finer.
    king = model.box((20, 13, 25), (5, 7, 1), "die", rotation=(0, 0, 12), pivot=(22, 13, 25), name="card")
    king.paint("front", ["o.o..", "oo...", "o.o..", ".....", "..o..", ".ooo.", "..o.."], {"o": INK})
    ace = model.box((22, 13, 26), (5, 7, 1), "die", rotation=(0, 0, -12), pivot=(23, 13, 26), name="card")
    ace.paint("front", [".#...", "#.#..", "###..", ".....", ".#.#.", ".###.", "..#.."], {"#": RED})
    return model
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/hydrant.py
size: 4529
sha: 08f2d997e7bf92f4e257ac3ca029d55a45a3d07d
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/hydrant.py

```text
"""French fire hydrant painted pink, nearly front-on, in a garden. 34 units tall, about 16 wide at the ports (the photo is about
half as wide as tall).
Visible: grey base with a raised front rib, pink column, a front port, two side ports aimed out and forward, a flat grey cap
with an operating nut on top, a steel square nut in the middle of every port cap.
Inferred: the column is turned, so the back repeats it with the rib; the side ports sit opposite each other; the back has no port.
A dark band under the front port is that port's shadow in the photo, not a part.
A blind reader took the first version for a pig: side ports read as ears and the front port as a snout. What made it a hydrant:
level ports with a flanged collar, a bolted steel flange between base and column, and a real operating nut on top.
Reel fix (third pass): the column is taller so pink shows between the flange and the ports; every port is a round nozzle:
a short stem, a grey flange ring, then a pink cap with a shallow, pink-red recess (not dark red) that a bright steel nut fills;
the side ports are level (no pitch), set well out from the column on a visible stem, so they read as nozzles, not ears."""

SOURCE = {"image": "source.jpg", "mask": "read/mask.png", "yaw": 0, "pitch": 4}
BOLT = "#5d6265"
PORT_YAW = 40

def build(kit):
    materials = {
        "pink": kit.Material(["#f08a92", "#e97d86", "#dc6d77"], noise=0.15, roughness=0.5),
        "recess": kit.Material(["#d4606c", "#c85763", "#b94c58"], noise=0.15, rim=False, roughness=0.55),
        "grey": kit.Material(["#aeb2b4", "#9ea3a5", "#8d9295"], noise=0.15, roughness=0.5, metallic=0.1),
        "cap": kit.Material(["#b9bdbf", "#a0a5a7", "#868b8e"], style="metal", noise=0.2, roughness=0.4, metallic=0.3),
        "steel": kit.Material(["#e6e9ea", "#cfd3d5", "#b4b9bb"], style="metal", noise=0.12, roughness=0.3, metallic=0.5),
        "rust": kit.Material(["#7a4a3a", "#5e3a30"], noise=0.3, rim=False),
    }
    model = kit.BlockModel(materials, seed=9)
    center_x = 8.5
    center_z = 8.5

    # Base: rust foot, tall grey standpipe with a rib down the front and the back.
    model.round(center_x, center_z, 0, 1, 11, "rust", corner=3, name="foot")
    model.round(center_x, center_z, 1, 10, 9, "grey", corner=2, name="base")
    model.box((7, 2, 13), (3, 7, 1), "grey", name="front rib")
    model.box((7, 2, 3), (3, 7, 1), "grey", name="back rib")

    # Bolted flange between base and column: a flat ring with bolt heads painted on its sides.
    flange = model.round(center_x, center_z, 11, 1, 11, "cap", corner=3, name="flange")
    for part in flange[1:5]:
        for face in ("front", "back", "left", "right"):
            part.paint(face, ["o.o.o.o.o"[:part.face_size(face)[0]]], {"o": BOLT}, at=(0, 0))

    # Pink column, tall and straight: 17 units, so it shows between the flange and the ports.
    model.round(center_x, center_z, 12, 17, 9, "pink", corner=2, name="column")

    # Cap, and the operating nut every hydrant has on top.
    model.round(center_x, center_z, 29, 1, 9, "cap", corner=2, name="cap")
    model.round(center_x, center_z, 30, 1, 7, "cap", corner=2, name="cap top")
    model.box((7, 31, 7), (3, 3, 3), "steel", name="operating nut")

    # Front port: stem, grey flange ring, pink round cap with a shallow recess and a steel nut that fills it.
    port_y = 19.5
    model.disc(center_x, port_y, 10, 4, 7, "pink", corner=2, name="front port stem")
    model.disc(center_x, port_y, 14, 1, 9, "grey", corner=2, name="front port ring")
    model.disc(center_x, port_y, 15, 2, 7, "pink", corner=2, recess=1, core="recess", name="front port cap")
    model.box((7, port_y - 1.5, 16), (3, 3, 1), "steel", name="front nut")

    # Side ports: level (no pitch), aimed out and forward, on a visible stem well out from the column.
    side_y = 25.5
    for side in (-1, 1):
        rotation = (0, side * PORT_YAW, 0)
        pivot = (center_x, side_y, center_z)
        model.disc(center_x, side_y, center_z + 2, 4, 5, "pink", corner=1, rotation=rotation, pivot=pivot, name="side port stem")
        model.disc(center_x, side_y, center_z + 6, 1, 9, "grey", corner=2, rotation=rotation, pivot=pivot, name="side port ring")
        model.disc(center_x, side_y, center_z + 7, 2, 7, "pink", corner=2, recess=1, core="recess", rotation=rotation, pivot=pivot,
                   name="side port cap")
        model.box((center_x - 1.5, side_y - 1.5, center_z + 8), (3, 3, 1), "steel", rotation=rotation, pivot=pivot, name="side nut")
    return model
```

## File: plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/kettle.py
size: 3070
sha: a432f5f7cfd3b1861ad2c7e621d46d6e6c027524
upstream: https://github.com/codepawl/viz3d/blob/main/plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/kettle.py

```text
"""Japanese cast-iron kettle (tetsubin), side-on photo. 28 units tall.
Visible: pillow-shaped body with a flat shoulder, raised lid, bud knob, short beak spout on the left, bail handle, casting seam, rust.
Inferred: the body is turned, so the far side repeats the near side; the bail is one piece in the picture plane;
nothing else sits on the back."""

SOURCE = {"image": "source.jpg", "mask": "read/mask.png", "yaw": 0, "pitch": 0}
SEAM = "#2a2228"
RUST = ["#6e5245", "#5e4640", "#74563f"]

def build(kit):
    materials = {
        "iron": kit.Material(["#5e504c", "#4f4345", "#41373b", "#352c31", "#2a2329"], noise=0.55, roughness=0.8, metallic=0.1),
        "lid": kit.Material(["#6a5a55", "#584b4c", "#483d40", "#3a3035"], noise=0.5, roughness=0.8, metallic=0.1),
        "wire": kit.Material(["#57483f", "#40343a", "#2f2830"], noise=0.3, rim=False, roughness=0.6, metallic=0.3),
        "bronze": kit.Material(["#a08e68", "#84724f", "#665640", "#4e4034"], style="metal", noise=0.3, rim=False, roughness=0.4, metallic=0.5),
        "lip": kit.Material(["#a4482c", "#7c3220"], noise=0.3, rim=False),
    }
    model = kit.BlockModel(materials, seed=11)
    center_x = 13.5
    center_z = 13.5

    # Body tiers follow the photo's rounded profile: small foot, wide belly, shoulder stepping in to the lid.
    model.round(center_x, center_z, 0, 1, 15, "iron", corner=3, name="foot")
    model.round(center_x, center_z, 1, 1, 19, "iron", corner=4, name="lower belly")
    belly = model.round(center_x, center_z, 2, 8, 21, "iron", corner=4, name="belly")
    for part in belly:
        part.band(5, SEAM)
        part.speckle(RUST, 0.08, faces=("front", "back", "left", "right"))
    model.round(center_x, center_z, 10, 1, 19, "lid", corner=4, name="upper belly")
    shoulder = model.round(center_x, center_z, 11, 1, 17, "lid", corner=3, name="shoulder")
    for part in shoulder:
        part.speckle(RUST, 0.1, faces=("top",))

    model.round(center_x, center_z, 12, 1, 11, "lid", corner=2, name="lid")
    model.box((13, 13, 13), (1, 1, 1), "bronze", name="knob neck")
    model.box((12, 14, 12), (3, 3, 3), "bronze", name="knob bud")
    model.box((13, 17, 13), (1, 1, 1), "bronze", name="knob tip")

    # Spout: a stubby beak leaving the upper belly, tipped up, with the red inside showing at the mouth.
    model.beam((5.0, 6.8, 13.5), (0.8, 10.2, 13.5), (4, 3), "iron", name="spout")
    mouth = model.box((-2, 10, 12), (4, 2, 3), "iron", name="spout mouth")
    mouth.paint("top", ["....", "##..", "...."], {"#": "#6a2a1c"})

    # Lugs on the shoulder and the bail: straight posts, then two angled pieces each side, then the top bar.
    model.box((4, 12, 12), (2, 3, 3), "iron", name="lug left")
    model.box((21, 12, 12), (2, 3, 3), "iron", name="lug right")
    z = 13.5
    bail_points = [(5, 14), (5, 22), (6.5, 25.5), (10, 27.5), (17, 27.5), (20.5, 25.5), (22, 22), (22, 14)]
    for start, end in zip(bail_points[:-1], bail_points[1:]):
        model.beam((start[0], start[1], z), (end[0], end[1], z), (1, 2), "wire", name="bail")
    return model
```

## File: README.md
upstream: https://github.com/codepawl/viz3d/blob/main/README.md

```text
# viz3d

Visualize anything in 3D. Give your agent a subject: a place, a system, an object, a product, a creature, a story or an
idea. The agent researches it, builds models to real dimensions, and makes a film, an app you click through, a lesson,
a book or a game. It checks its own frames before it shows you anything.

<table>
  <tr>
    <td width="200">
      <a href="https://67js.codepawl.com">
        <picture>
          <source media="(prefers-color-scheme: dark)" srcset="https://67js.codepawl.com/brand/wordmark-dark.svg?v=0.10.0" />
          <img src="https://67js.codepawl.com/brand/wordmark-light.svg?v=0.10.0" alt="67js" width="160" />
        </picture>
      </a>
    </td>
    <td>
      Films and stills render in <a href="https://github.com/codepawl/67.js"><b>67js</b></a>, our WebGL2 engine that
      agents drive through an MCP server: JSON scene recipes, PNG review grids, and GPU or CPU export to images and
      video with bounded memory. viz3d works without it, and looks better with it. See
      <a href="#films-and-stills-with-67js">Films and stills with 67js</a>.
    </td>
  </tr>
</table>

## Start in three steps

1. [Install](#install) the plugin.
2. Type `/viz3d` with nothing after it (`/visualize-in-3d:viz3d` in Claude Code with the plugin). The agent shows the menu of modes and looks.
3. Type a mode, a look if you want one, and the subject: `/viz3d film white model the Chernobyl accident`.

Put the subject in quotes when it starts with a mode or look word: `/viz3d "film noir city"`.

You can also skip the command and ask in your own words. The agent picks the mode and the look from the subject.

## What do you want to make?

| You want to | Type |
|---|---|
| Tell a story or an event as a film | `/viz3d film the 1906 San Francisco earthquake` |
| Show creatures, machines or characters in action | `/viz3d action two mechs fighting in a flooded city` |
| Show off one object, product or model | `/viz3d showcase glow a cockroach` |
| Show how a place or a system runs | `/viz3d app a container port` |
| Teach how a thing works, step by step | `/viz3d lesson an Arduino that fades an LED` |
| Open up one big object or building, room by room | `/viz3d book the Titanic` |
| Let people play it | `/viz3d game pixel a worm that eats its way through a city` |
| Show your leads something this week | `/viz3d prototype our warehouse, from last week's CSV export` |
| Put a live 3D view in your own dashboard | `/viz3d embed our fleet, the feed is at /api/fleet/events` |

## Modes

The nine modes fall into three groups, by what people do with the result.

**Watch it**

- **Documentary film** (`film`): a 2 to 4 minute film of an event, told in order. It is built from a sourced fact
  file, a map, block-built scenes and flat glowing figures. Captions are timed for reading, and the score changes with
  the story.
- **Action short** (`action`): creatures, machines or characters doing something, such as a fight, a chase or a
  launch. Every move is planned beat by beat, and every hit lands on a visible contact. The agent checks the frames for
  hidden or mistimed actions before it shows you the film.
- **Showcase** (`showcase`): a film or a set of stills that shows off one object, product or model. The model stays
  clean while the world around it turns to ASCII or glow.

**Explore it**

- **Live app** (`app`): a three.js app with a dashboard around a 3D world of the place. A seeded simulation moves the
  units and the numbers. The agent records a 30 s film by driving the app with real clicks on a virtual clock.
- **Lesson app** (`lesson`): a step-by-step lesson on a real object at real size, such as an Arduino and a
  breadboard. Parts and wires plug in as they do on a desk. The highlighted code line is what the board does right
  now. A free bench lets the learner build their own circuit.
- **Book** (`book`): a 3D illustrated cutaway plate of one big object or building, such as a liner or a castle.
  You orbit it, read the margin labels and fly into any room, each full of small people at work. Several subjects
  make a page-turning book with a short quiz on each page.
- **Game** (`game`): a free browser game people play. The player steers one creature or unit through an open world
  that pushes back. It grows by playing and starts again from a death card. Bots play it before you do, to balance it
  and to find stuck states.

**Put it in your product**

- **Quick prototype** (`prototype`): a live app in one pass from three.js primitives, with no Blender. Use it when you
  need something to show your leads this week.
- **Live embed** (`embed`): the 3D view as a web component (`<viz3d-world>`) or an iframe page. It goes into the
  dashboard or product you already have. It reads your real data through a field mapping file, from REST, Server-Sent
  Events, WebSocket or a CSV export. A new feed needs no code change. Tokens stay on your backend.

| Documentary film | Action short |
|---|---|
| ![A documentary map: a red route from a farm to a hardware store across a county](docs/images/documentary.jpg) | ![A steam-driven mechanical spider walking over pipes on a prototype grid](docs/images/action.jpg) |
| **Showcase** | **Live app** |
| ![A cockroach drawn in glowing ASCII glyphs on a dark dithered floor](docs/images/glow.jpg) | ![A canal lock app: a container ship selected, its card open on the right, and the stage timeline at the bottom](docs/images/live-app.jpg) |
| **Lesson app** | **Book** |
| ![An Arduino lesson: an LED fading on a breadboard, the current code line highlighted and the PWM signal drawn on the right](docs/images/lesson-app.jpg) | ![An illustrated cutaway plate of RMS Titanic with labelled rooms and the people aboard](docs/images/book.jpg) |
| **Game** | **Quick prototype** |
| ![A browser game: a swarm of red worms spreading through a low-poly forest, with the worm count in the corner](docs/images/game.jpg) | ![An electric bus depot built from three.js primitives, with pantograph chargers over the buses](docs/images/quick-prototype.jpg) |
| **Live embed** | **Stage cameras in a live app** |
| ![A transit company dashboard with the 3D depot view inside it, a bus card open and a live status mark](docs/images/live-embed.jpg) | ![A metro app with the stage bar at the top and the speed controls at the bottom](docs/images/stages.jpg) |

Every live app has stage cameras on number keys, a guided tour and a follow camera, as in the metro app above.
Playback runs from 0.25x to 10x.

## Looks

The thirteen looks fall into four families. Put the look word in your command, or leave it out and the agent uses the
default look for the mode.

| Family | Look | Word | Fits |
|---|---|---|---|
| Clean product | Clean ops world | `ops` | The default for a system that runs: a white-grey world with one accent colour, framed like a real operations product |
| | Toy isometric | `toy` | A friendly diorama of a place or system |
| | Lived-in isometric | `lived-in` | Ordinary buildings where people live or work and a crisis plays out inside |
| Real and cinematic | Photoreal terrain | `terrain` | Something spreading over real land, such as a wildfire or a flood |
| | Dark documentary | `documentary` | A case or an event told as a film |
| | White model documentary | `white model` | A case told as an architect's white model |
| | Layer build film | `layer build` | One locked aerial camera while a place is built up layer by layer |
| Technical | Point-cloud survey | `point cloud` | A scanned, technical view of land, sea or a site |
| | Illustrated cutaway plate | `plate` | One big object or building cut open, for the book mode |
| | Space-time cube | `space-time` | Movers under spacing rules, such as air traffic: the map is the floor, time goes up, and you drag a tube to fix a breach |
| Stylized art | Pixel diorama | `pixel` | Pixel-art sprites on a 3D stage, for battles, games and small worlds |
| | Dither + ASCII showcase | `ASCII` | A film that shows off a mo
```
