End-to-end algorithm tracing every decision that determines which blocks get deleted. Six stages, one invariant chain: block IDs encoded in TypeScript must decode identically in Python.
Contents
flowchart TD
LIT[("litematic")]
RENDER[("render PNG")]
subgraph S1["Stage 1 · export_geometry.ts"]
s1a["for each non-air block\nb.pos, blockName, blockProps"]
s1b["getMesh + SpecialRenderers\nCull.none() → all 6 faces incl. interior"]
s1c["quad → 2 tri\n[0,1,2] and [0,2,3] fixed winding"]
s1d["pickId = x·sY·sZ + y·sZ + z + 1\nmat4.translate → world-space float32"]
s1e["positions.bin [N,3,3] f32\nblock_ids.bin [N] i32\nmeta.json {size:[sX,sY,sZ] nTriangles}"]
s1a-->s1b-->s1c-->s1d-->s1e
end
subgraph S2["Stage 2 · camera.py"]
s2a["viewProjectionMatrix[16]\nstored column-major (WebGL)"]
s2b["reshape(4,4, order='F')\n→ correct row-major VP [4,4]"]
s2c["placements[0]\ntile_x tile_y W H mirror"]
s2a-->s2b-->s2c
end
subgraph S3["Stage 3 · rasterize.py ×N views"]
s3a["VP @ [x,y,z,1]T → clip[4]\nw ≤ 0 → discard entire tri (behind camera)"]
s3b["ndc = clip / w\npx = (ndcX+1)·W/2\npy = (1−ndcY)·H/2 ← Y-flip"]
s3c["signed area = det of screen-space edge vecs\narea ≤ 0 → skip (backface or degenerate)"]
s3d["AABB per tri\nix ∈ [floor(min_px), ceil(max_px)] ∩ [0,W)\niy ∈ [floor(min_py), ceil(max_py)] ∩ [0,H)"]
s3e["per pixel in AABB:\nedge fns w0 w1 w2 — all ≥ 0 → inside"]
s3f["depth = (w0·z0 + w1·z1 + w2·z2) / area\ndepth below zbuf[y,x] → write block_id"]
s3g["mirror=true → id_buf[:,::-1].copy()"]
s3h["id_buf [H,W] int32\n0 = bg else = pick_id of frontmost block"]
s3a-->s3b-->s3c-->s3d-->s3e-->s3f-->s3g-->s3h
end
subgraph S4["Stage 4 · coverage.py"]
s4pre{"--render\nprovided?"}
s4s["simple path\nmask_mode: L / R / redness\n→ uint8 packed_mask"]
s4r1["redness = max(0, R_mask − max(G,B)_mask)"]
s4r2["diff = mean |mask_rgb − render_rgb| per pixel"]
s4r3["combined = (redness above t_r) AND (diff above t_d)"]
s4r4["erode: MinFilter (2p+1)×(2p+1)\nremoves boundary bleed"]
s4b["extract tile per view\npacked[ty:ty+H, tx:tx+W]"]
s4c["bool_sel = tile.ravel() above mask_threshold"]
s4d["per view accumulate\ntotal_visible += bincount(ids_flat)\ntotal_selected += bincount(ids_flat[bool_sel])"]
s4e["score = selected / visible\nbid→(x,y,z): idx=bid−1\nx=idx//(sY·sZ) y=(idx mod sY·sZ)//sZ z=idx mod sZ"]
s4f["scores.json {'x,y,z': score}"]
s4pre-->|"no"|s4s-->s4b
s4pre-->|"yes"|s4r1-->s4r2-->s4r3-->s4r4-->s4b
s4b-->s4c-->s4d-->s4e-->s4f
end
subgraph S5["Stage 5 · remove_blocks.py"]
s5a["assert: exactly 1 region\nassert: palette[0] = air → air_idx = 0"]
s5b["decode BlockStates stretches mode\nbits = max(2, ceil(log2(palette_size)))\nIDs straddle 64-bit long boundary\nsigned i64 → unsigned via bitmask"]
s5c["seeds = positions where score above threshold"]
s5d{"--propagate?"}
s5e["BFS 6-connected, match palette_id\nbbox = seed_bbox expanded ±1 block\nprevents flood into same-type walls"]
s5f["arr[y·sX·sZ + z·sX + x] = 0 (air)"]
s5g["encode back: stretches bit-pack\n→ signed i64 longs"]
s5h["output .litematic"]
s5a-->s5b-->s5c-->s5d
s5d-->|"yes"|s5e-->s5f
s5d-->|"no"|s5f
s5f-->s5g-->s5h
end
LIT --> s1a
LIT --> s5a
s1e --> s3a
s2c --> s3a
s2c -.->|"tile coords"| s4b
s3h --> s4d
RENDER --> s4pre
s4f --> s5c
scripts/export_geometry.tsIterates every non-air block, extracts mesh triangles, and writes three binary files. The block ID encoding here must be exactly reproduced in Python.
Matches src/Picking.ts → encodeBlockPosToPickId:
0 is background. Size \((s_X, s_Y, s_Z)\) comes from structure.getSize() = bounding box of all blocks in the litematic region.
Two mesh sources per block, each wrapped in independent try/catch:
| Source | Call | Covers |
|---|---|---|
| Main mesh | blockDef.getMesh(name, props, res, res, Cull.none()) | All standard blocks |
| Special mesh | SpecialRenderers.getBlockMesh(state, nbt, res, {}) | Water, lava, etc. |
Cull.none() exports all 6 faces including interior ones. The rasterizer's backface cull removes non-visible interior faces naturally.
getMesh() returns geometry in 0–1 block-unit space (scaled by 1/16 from 0–16 texel space). mat4.translate(t, identity, b.pos) shifts each block to its grid position. Final positions are in world-space block coordinates (integer grid, float-precise).
Each quad is split into 2 triangles with fixed winding:
Winding determines which face direction is "front" for backface culling in Stage 3.
| File | dtype | Shape | Content |
|---|---|---|---|
*_geometry_meta.json | JSON | — | size:[sX,sY,sZ], nTriangles |
*_positions.bin | <f4 (LE float32) | [N, 3, 3] | N triangles × 3 verts × (x,y,z) |
*_block_ids.bin | <i4 (LE int32) | [N] | pick_id per triangle |
mcsegment/camera.pyThe camera JSON stores viewProjectionMatrix as a 16-element flat array in column-major order (WebGL / gl-matrix convention). NumPy's default reshape is row-major (C order). Wrong order here silently transposes the matrix and projects all blocks to wrong pixels.
Verification: with correct reshape, objectCenter from the JSON projects to tile center ± 0.5 px.
Each view occupies a tile inside the packed image. Position comes from placements[0]:
For a 3×3 nineview at 418×418 px per tile, the packed image is 1254×1254. If tile coordinates are wrong, mask pixels don't align to rasterized block IDs → incorrect coverage.
Some views have mirror: true in the JSON. This is stored in ViewMetadata.mirror and applied in Stage 3 after rasterization — not during projection.
mcsegment/rasterize.pyProduces an int32 [H, W] image where each pixel holds the pick_id of the frontmost triangle. 0 = background.
All N×3 vertices projected in one vectorized batch:
\[ \begin{bmatrix}x_c\\y_c\\z_c\\w_c\end{bmatrix} = \text{VP} \cdot \begin{bmatrix}x_w\\y_w\\z_w\\1\end{bmatrix} \]Triangles where any vertex has \(w \le 0\) (behind camera) are discarded entirely.
Y-axis flip: NDC +Y is up; image +Y is down. The (1 − ndcY) term is mandatory. Wrong sign maps the top half of the structure to the bottom of the image.
Signed area of the projected triangle in screen space:
\[ \text{area} = (p_{x1}-p_{x0})(p_{y2}-p_{y0}) - (p_{x2}-p_{x0})(p_{y1}-p_{y0}) \]If area ≤ 0 the triangle is back-facing (or degenerate) → skipped. This is what makes Cull.none() in Stage 1 harmless: exported interior faces are culled here.
Per triangle, over its axis-aligned bounding box clamped to the image:
block_id and update \(z_{\text{buf}}\).Depth is NDC-z (not view-space z). Smaller value = closer to camera for a standard WebGL projection.
Must be applied after rasterization, not during projection. The mask tile for a mirrored view is stored un-flipped in the packed image; the flip makes the rasterized id_buf align to it.
One int32 [H, W] id_buf per view. Pixel value = pick_id of frontmost block, or 0 for background / empty space.
The rasterizer stores block_id per pixel, not triangle index. A single block exports up to 12 triangles (6 faces × 2 triangles each via Cull.none()). The Z-test resolves which triangle is frontmost at each pixel, then discards the triangle identity — only the block_id carried by that triangle is written to id_buf.
Consequence for Stage 4: bincount(ids_flat) accumulates pixel counts per block across all its visible triangles automatically. No triangle bookkeeping needed downstream.
mcsegment/coverage.pyWhen --render is provided, a 4-step pipeline replaces simple thresholding. Each step is applied to the full packed image before tile extraction.
| Step | Formula | Purpose |
|---|---|---|
| 1 · Redness | \(\text{redness}[i] = \max(0,\; R_\text{mask} - \max(G_\text{mask}, B_\text{mask}))\) | Isolates red-dominant pixels. Non-red house body → 0. Chimney → ≈R. |
| 2 · Pixel diff | \(\text{diff}[i] = \tfrac{1}{3}\sum_c |(\text{mask} - \text{render})_c|\) | Confirms the mask is highlighting something genuinely different from the render. A reddish block that looks red in both mask and render gets small diff → excluded. |
| 3 · Combined | \(\text{sel}[i] = (\text{redness}[i] > t_r) \;\mathbin{\text{AND}}\; (\text{diff}[i] > t_d)\) | \(t_r = 50\), \(t_d = 30\). Both conditions required. Eliminates bleed and coincidental redness. |
| 4 · Erosion | \(\text{eroded}[i] = \min_{k \in \text{kernel}} \text{sel}[k]\) — MinFilter \((2p+1){\times}(2p+1)\), \(p=3\) | Shrinks selection by 3 px on all sides. Removes boundary bleed pixels that passed thresholds. |
Concrete parameters: --mask-threshold 50 --diff-threshold 30 --erosion-px 3
Falls back to single-step threshold on mask_mode channel:
| --mask-mode | Formula |
|---|---|
L | PIL grayscale: 0.299R + 0.587G + 0.114B |
R | Red channel only |
redness | \(\max(0,\; R - \max(G,B))\) |
In enhanced mode: output of step 4 is binary (0 or 255); threshold 127 correctly selects 255s.
For each view, two integer histograms over the id_buf:
ids_flat[bool_sel] selects only the block IDs at mask-positive pixels. bincount counts how many pixels each block contributed. Both accumulators are int64.
Where \(b = \text{pick_id}(x,y,z)\) and the sum is over all views. Blocks with 0 visible pixels across all views are absent from the output (not score 0).
Exact inverse of Stage 1 encoding:
\[ \text{idx} = b - 1, \quad x = \left\lfloor\frac{\text{idx}}{s_Y s_Z}\right\rfloor, \quad y = \left\lfloor\frac{\text{idx} \bmod s_Y s_Z}{s_Z}\right\rfloor, \quad z = \text{idx} \bmod s_Z \]Size \((s_X, s_Y, s_Z)\) loaded from geometry_meta.json must match TS structure.getSize() exactly. A mismatch silently maps IDs to wrong coordinates.
scores.json — keys "x,y,z", values float in [0, 1].
mcsegment/remove_blocks.pyBlocks with score > threshold become seeds for removal. With enhanced mask preprocessing (redness + diff + erosion), all score > 0 blocks are already high-confidence — bleed is eliminated upstream. Use 0.5 with enhanced mode; use 0.3–0.5 as safety net with simple mode to exclude bleed noise (score 0.003–0.3).
Litematic regions have independent Position offsets. Must replicate the TS normalization from Schematics.ts:
Scores use world coords (from Stage 4). Litematic stores region-local coords. Mismatched coordinate system → remove wrong blocks or remove nothing.
Block index inside region's BlockStates long array:
This is YZX order (Y outermost, X innermost). Confirmed by src/Schematics.ts. Wrong order reads/writes wrong block positions.
Litematic uses "stretches" packing — block IDs are packed densely across 64-bit longs, with IDs allowed to straddle long boundaries:
\[ \text{bits} = \max\!\left(2,\; \lceil \log_2(\text{palette_size}) \rceil\right) \]Minimum 2 bits regardless of palette size. The signed→unsigned conversion (& 0xFFFF…) is required because Python's nbtlib yields signed int64 longs, but bit ops need unsigned semantics.
Two invariants are asserted at load time:
| Invariant | Check | Rationale |
|---|---|---|
| Exactly 1 region | len(regions_nbt) == 1 |
Multi-region coord normalization is undefined; structure coords would need cross-region offset logic not yet implemented |
palette[0] is air |
palette[0]['Name'] in {'minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'} |
Litematic convention: index 0 = air. Writing 0 to the block array replaces with air — no palette mutation needed, bit-width unchanged |
BFS from seed blocks to connected same-type neighbors, constrained to seed bounding box + 1 margin:
Bbox constraint prevents same-type flood into house walls. Useful when rasterizer only sees the outer shell of a hollow structure (e.g., chimney bricks).
Risk: if chimney block type (bricks) also appears in house walls within the bbox, those wall blocks are also removed. Use high score threshold (0.5) to keep seeds tightly in the chimney column.
Full chimney-removal run on a real Minecraft snow house. 9-view pack, 418×418 px per tile, 1254×1254 packed image.
View 0 of 9 from images/雪屋.json. Column-major flat array → reshape as order='F':
Python reshape:
id_buf per tile.
Tile extraction for view 0: mask_tile = packed_mask[0:418, 0:418]. After enhanced preprocessing (redness + diff + erosion), packed_mask is already binary (0/255). bool_sel selects the 255-pixels.
Before and after chimney removal. Command used:
Before — 雪屋 with chimney
After — chimney removed (99 blocks)
Every parameter in this table affects which blocks end up in scores.json or get deleted. A wrong value silently changes the output — no runtime error.
| Parameter | Stage | File | Effect of wrong value | Verified how |
|---|---|---|---|---|
encodeBlockPosToPickId formula |
1 + 4 | export_geometry.ts / picking.py | IDs map to wrong (x,y,z) → delete wrong blocks | Round-trip unit test: encode→decode matches original |
VP matrix reshape(4,4,order='F') |
2 | camera.py | Transposed VP → all blocks project to wrong pixels → all scores wrong | objectCenter projects to tile center ± 0.5 px |
NDC Y-flip: (1 − ndcY) × H/2 |
3 | rasterize.py | Top/bottom of structure flipped → wrong blocks selected | Synthetic single-triangle rasterization test |
Backface cull: area > 0 |
3 | rasterize.py | Wrong sign → front faces culled, back faces drawn → Z-buffer filled with back faces → wrong block attribution | Synthetic reversed-winding triangle test → all zeros |
| Mirror flip after rasterize | 3 | rasterize.py | Not applied → mirrored views have left/right swapped → wrong block attribution for those views | Two-block mirror test: left block in raster = right block in mask space |
--render (enhanced mode) |
4 | coverage.py | Not provided → simple threshold only; bleed and coincidental redness not filtered → non-target blocks get low scores | Enhanced mode: score>0 drops 1104→71, all 71 are brick |
mask_threshold (redness t_r) |
4 | coverage.py | Too low → dim background passes; too high → misses dim chimney pixels. Correct value sits in histogram gap. | redness=50: gap between house (0–39) and chimney (50+) |
diff_threshold (t_d) |
4 | coverage.py | Too low → blocks that are red in both mask and render not filtered; too high → misses true chimney | diff=30: chimney mask vs render diff ≈ 60; safe margin |
erosion_px |
4 | coverage.py | 0 → boundary bleed pixels survive combined threshold; too large → core selection eroded away | 3 px: removes 1–5 px bleed; chimney core is >10 px wide |
| Structure size \((s_X, s_Y, s_Z)\) | 1 + 4 | geometry_meta.json | Size mismatch between TS export and Python decode → wrong (x,y,z) for all blocks | Size printed on export; compared against litematic NBT |
| Litematic flat index: YZX order | 5 | remove_blocks.py | Wrong order (e.g., XYZ) → air written to wrong array position → wrong blocks removed in NBT | Matches src/Schematics.ts hand-checked |
Coordinate normalization: adj = pos − minPos |
5 | remove_blocks.py | Wrong offset → world coords don't align to region-local coords → seeds map to wrong positions or out-of-bounds | Matches TS normalization in Schematics.ts |
Bit-packing stretches mode: bits = max(2, ⌈log₂(palette)⌉) |
5 | remove_blocks.py | Wrong bits → decoded block IDs all wrong → modify wrong positions → corrupt litematic | Round-trip encode/decode on known structure |
Signed i64 → unsigned u64: & 0xFFFF… |
5 | remove_blocks.py | Python big-int sign extension corrupts bit ops → garbled palette IDs | nbtlib yields signed int64; mask forces unsigned interpretation |
| Exactly 1 region (enforced) | 5 | remove_blocks.py | Multi-region litematics raise ValueError — not silently mishandled |
Hard error; pipeline stops before corrupt output |
palette[0] is air (enforced) |
5 | remove_blocks.py | Non-air at index 0 raises ValueError; writing 0 to array must mean air or blocks are set to wrong type |
Hard error; Litematic convention always places air first |
--threshold (score cutoff) |
5 | cli.py / remove_blocks.py | Enhanced mode: 0.5 safe (all score>0 = brick). Simple mode: 0.3–0.5 needed to exclude bleed noise (non-brick walls at score 0.003–0.3). | Block-type audit: enhanced mode score>0 = 100% brick (71 blocks); simple mode score>0.5 = 100% brick (78), score 0.0–0.3 = majority non-brick |
--propagate + bbox_margin |
5 | remove_blocks.py | Without: only surface shell removed; interior same-type blocks survive. With: may expand into same-type wall blocks if bbox is too large | Render before/after; compare block count in bbox |
Mask bleed → wall noise
Diffusion segmentation bleeds 1–5 px past true target edge. Fix (enhanced mode): redness AND diff AND 3-px erosion eliminates bleed before scoring — score>0 becomes 100% brick. Simple-mode fallback: score threshold ≥ 0.3.
Propagation floods walls
If chimney and wall share block type (bricks), BFS crosses the boundary. Bbox constraint limits expansion but may still leak if chimney abuts wall. Fix: high threshold seeds keep seeds away from boundary.
Interior blocks invisible
Rasterizer only sees surface shell. Hollow chimney interior bricks have 0 visible pixels → score 0 → not deleted. Fix: --propagate BFS fills interior from surface seeds.
Export try/catch swallows
If getMesh() throws for a block, that block has no triangles → score 0 → never deleted even if it should be. Check export warnings for skipped blocks.
palette[0] not air → hard error
Pipeline rejects litematics where index 0 is not an air variant. This is the Minecraft/Litematica convention; any valid exported litematic satisfies it. If you see this error, the litematic was likely hand-crafted or exported by an unusual tool.
Multi-region → hard error
Pipeline rejects multi-region litematics. Structure coords from Stage 4 assume a single coordinate space. Split the litematic into one region per target structure before running the pipeline.
| Option | Recommended | Rationale |
|---|---|---|
--render | provide packed render PNG | Enables enhanced mode: redness AND diff AND erosion. Eliminates bleed before scoring. |
--mask-mode | redness | Isolates red-dominant pixels from semantic mask |
--mask-threshold | 50 | Redness cut in histogram gap (house 0–39, chimney 50+) |
--diff-threshold | 30 | Excludes blocks red in both mask and render (naturally reddish structure) |
--erosion-px | 3 | Removes 1–5 px boundary bleed; chimney core is >10 px wide so safe |
--threshold | 0.5 | Score cutoff; with enhanced mode, even 0.1 is safe (all score>0 = brick) |
--propagate | always | Fills interior blocks invisible to rasterizer |
Generated from mcsegment v0.1.0 · pipeline staged across export_geometry.ts, camera.py, rasterize.py, coverage.py, remove_blocks.py