⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
build-minecraft-model-from-image
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".
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
Not yet listed on ClawHub or SkillsMP
// README
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.
Start in three steps
- Install the plugin.
- Type
/viz3dwith nothing after it (/visualize-in-3d:viz3din Claude Code with the plugin). The agent shows the menu of modes and looks. - 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 |
|---|---|
![]() | ![]() |
| Showcase | Live app |
![]() | ![]() |
| Lesson app | Book |
![]() | ![]() |
| Game | Quick prototype |
![]() | ![]() |
| Live embed | Stage cameras in a live app |
![]() | ![]() |
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 |
// HOW IT'S BUILT
// FILES
14 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/SKILL.md4.7 KB3512ed49712bupstream ↗
--- 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).plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/block_export.py11 KB82c6651b9dddupstream ↗
"""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/[email protected]/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/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)plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/block_kit.py12 KBddf13e6f6cb3upstream ↗
"""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)plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/build_model.py16 KB598ee8a8e135upstream ↗
# /// 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()plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/read_image.py9.6 KB3e5cec305e28upstream ↗
# /// 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()plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/render_blender.py4.0 KBd1822586711eupstream ↗
"""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)plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/scripts/test_block_model.py6.8 KB580bda82c4feupstream ↗
# /// 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()plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/references/faults.md3.6 KBbc2e641f4adaupstream ↗
# 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 |plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/references/method.md4.7 KB98042131cb9bupstream ↗
# 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.plugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/chair.py4.0 KB16bb3ca178e3upstream ↗
"""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 modelplugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/duck.py4.8 KB1081ca94d058upstream ↗
"""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 modelplugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/hydrant.py4.4 KB08f2d997e7bfupstream ↗
"""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 modelplugins/build-minecraft-model-from-image/skills/build-minecraft-model-from-image/examples/kettle.py3.0 KBa432f5f7cfd3upstream ↗
"""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 modelREADME.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/codepawl-viz3d-plugins-build-minecraft-model-from-image-skills-build-minecraft-m.md









