mcsegment — Pipeline Algorithmic Report


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

Overview

6pipeline stages
TS→PyID contract
YZXlitematic order
F-orderVP reshape
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
        
drag to pan · scroll to zoom
Fig 1. Full pipeline with key algorithmic steps. Dashed edge = tile coordinates reused from Stage 2 in Stage 4. Dotted diamond = conditional BFS propagation.

Stage 1 · Geometry Export scripts/export_geometry.ts

Iterates every non-air block, extracts mesh triangles, and writes three binary files. The block ID encoding here must be exactly reproduced in Python.

Block ID Encoding

Matches src/Picking.ts → encodeBlockPosToPickId:

\[ \text{pickId}(x,y,z) = x \cdot s_Y \cdot s_Z + y \cdot s_Z + z + 1 \]

0 is background. Size \((s_X, s_Y, s_Z)\) comes from structure.getSize() = bounding box of all blocks in the litematic region.

Mesh Extraction

Two mesh sources per block, each wrapped in independent try/catch:

SourceCallCovers
Main meshblockDef.getMesh(name, props, res, res, Cull.none())All standard blocks
Special meshSpecialRenderers.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.

Coordinate Space

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).

Quad → Triangle Convention

Each quad is split into 2 triangles with fixed winding:

for [i, j, k] of [[0,1,2], [0,2,3]]: push verts[i].pos, verts[j].pos, verts[k].pos

Winding determines which face direction is "front" for backface culling in Stage 3.

Output Files

FiledtypeShapeContent
*_geometry_meta.jsonJSONsize:[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

Stage 2 · Camera Loading mcsegment/camera.py

VP Matrix Convention

The 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.

vp_flat = np.array(v['viewProjectionMatrix'], dtype=np.float64) # length 16 vp = vp_flat.reshape(4, 4, order='F') # F = Fortran/column-major → correct row-major 4×4

Verification: with correct reshape, objectCenter from the JSON projects to tile center ± 0.5 px.

Tile Extraction

Each view occupies a tile inside the packed image. Position comes from placements[0]:

tile = packed_img[tile_y : tile_y + height, tile_x : tile_x + width]

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.

Mirror Flag

Some views have mirror: true in the JSON. This is stored in ViewMetadata.mirror and applied in Stage 3 after rasterization — not during projection.


Stage 3 · Rasterization mcsegment/rasterize.py

Produces an int32 [H, W] image where each pixel holds the pick_id of the frontmost triangle. 0 = background.

Vertex Projection

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} \]
vh = np.concatenate([verts, np.ones((N*3,1))], axis=1) # [N*3, 4] clip = (vp @ vh.T).T # [N*3, 4] w = clip[:, 3]

Triangles where any vertex has \(w \le 0\) (behind camera) are discarded entirely.

NDC → Pixel Coordinate

\[ \text{ndcX} = \frac{c_x}{w}, \quad \text{ndcY} = \frac{c_y}{w}, \quad \text{ndcZ} = \frac{c_z}{w} \] \[ p_x = (\text{ndcX}+1)\cdot\tfrac{W}{2}, \qquad p_y = (1-\text{ndcY})\cdot\tfrac{H}{2} \]

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.

Backface Culling

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.

Z-Buffer Rasterization

Per triangle, over its axis-aligned bounding box clamped to the image:

  1. Compute edge functions \(w_0, w_1, w_2\) for each pixel center.
  2. If all \(\ge 0\) → pixel is inside triangle.
  3. Interpolate depth: \(\displaystyle d = \frac{w_0\,z_0 + w_1\,z_1 + w_2\,z_2}{\text{area}}\)
  4. If \(d < z_{\text{buf}}[y,x]\): write 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.

Mirror Application

if view.mirror: id_buf = id_buf[:, ::-1].copy() # horizontal flip AFTER full rasterization

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.

Output

One int32 [H, W] id_buf per view. Pixel value = pick_id of frontmost block, or 0 for background / empty space.

Block–Pixel Attribution

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.


Stage 4 · Coverage Computation mcsegment/coverage.py

Mask Preprocessing (Enhanced Mode)

When --render is provided, a 4-step pipeline replaces simple thresholding. Each step is applied to the full packed image before tile extraction.

StepFormulaPurpose
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.

Stage Images — 雪屋 chimney run

Concrete parameters: --mask-threshold 50 --diff-threshold 30 --erosion-px 3

packed render
0 · Render. Reference image. Normal block colors.
segmentation mask
0 · Mask. Diffusion model output. Chimney = bright red, house = black.
redness map
1 · Redness \(= \max(0, R-\max(G,B))\). Chimney pixels bright, house near zero.
pixel diff
2 · Pixel diff vs render. High everywhere mask differs from rendered colors.
combined binary
3 · Combined: redness > 50 AND diff > 30. Binary. Still has 1-5 px bleed at edges.
eroded final
4 · Eroded (MinFilter 7×7, p=3). Bleed removed. Score > 0 drops 1104 → 71 blocks.

Simple Mode (no --render)

Falls back to single-step threshold on mask_mode channel:

--mask-modeFormula
LPIL grayscale: 0.299R + 0.587G + 0.114B
RRed channel only
redness\(\max(0,\; R - \max(G,B))\)

Binarization

\[ \text{bool_sel}[i] = (\text{packed_mask}[i] > \text{mask_threshold}) \]

In enhanced mode: output of step 4 is binary (0 or 255); threshold 127 correctly selects 255s.

Bincount Accumulation

For each view, two integer histograms over the id_buf:

ids_flat = id_buf.ravel() # [H*W] int32 bool_sel = mask_tile.ravel() > mask_threshold # [H*W] bool total_visible += np.bincount(ids_flat, minlength=bc_len) total_selected += np.bincount(ids_flat[bool_sel], minlength=bc_len)

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.

Score Formula

\[ \text{score}(x,y,z) = \frac{\sum_v \text{selected_px}_{v}(b)}{\sum_v \text{visible_px}_{v}(b)} \]

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).

ID → Coordinate Decode

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.

Output

scores.json — keys "x,y,z", values float in [0, 1].


Stage 5 · Block Removal mcsegment/remove_blocks.py

Score Threshold

Blocks 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).

Coordinate Normalization

Litematic regions have independent Position offsets. Must replicate the TS normalization from Schematics.ts:

min_pos = min(pos[i] for all regions) # find global origin adj = pos[region] - min_pos # offset for this region local_x = world_x - adj[0] # world → region-local coords

Scores use world coords (from Stage 4). Litematic stores region-local coords. Mismatched coordinate system → remove wrong blocks or remove nothing.

Litematic Index Order (YZX)

Block index inside region's BlockStates long array:

\[ \text{flat_idx}(x,y,z) = y \cdot s_X \cdot s_Z + z \cdot s_X + x \]

This is YZX order (Y outermost, X innermost). Confirmed by src/Schematics.ts. Wrong order reads/writes wrong block positions.

Bit-Packing: Stretches Mode

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) \]
# Decode: read bits continuously across longs bits, bit_mask = _bits_for_palette(palette_size) longs = [v & 0xFFFFFFFFFFFFFFFF for v in raw_longs] # signed i64 → unsigned u64 data, data_len = longs[0], 64 for each block: if data_len < bits: # straddle: pull next long data |= (longs[next] << data_len) data_len += 64 pid = data & bit_mask data >>= bits; data_len -= bits

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.

Preconditions (enforced with ValueError)

Two invariants are asserted at load time:

InvariantCheckRationale
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
if len(regions_nbt) != 1: raise ValueError(f'expected 1 region, got {len(regions_nbt)}') if str(palette[0]['Name']) not in ('minecraft:air', 'minecraft:cave_air', 'minecraft:void_air'): raise ValueError(f'palette[0] must be air, got: {palette[0]["Name"]}') air_idx = 0 # no palette mutation; no bit-width change

Optional Propagation (--propagate)

BFS from seed blocks to connected same-type neighbors, constrained to seed bounding box + 1 margin:

from collections import deque queue = deque([(x, y, z, arr[flat_idx(x,y,z,sX,sZ)]) for x,y,z in seeds]) while queue: x, y, z, pid = queue.popleft() for dx,dy,dz in 6-neighbors: if in_bbox and not visited and arr[flat(nx,ny,nz)] == pid: visited.add((nx,ny,nz)); queue.append((nx,ny,nz,pid))

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.


Concrete Example · 雪屋 (Snow House)

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':

view 0 — azimuth 0°, elevation 20°, no mirror
{ "viewIndex": 0, "azimuthDegrees": 0, // camera angle around Y axis "elevationDegrees": 20, // camera tilt above horizontal "mirror": false, // no horizontal flip for this view "viewProjectionMatrix": [ // 16 floats, COLUMN-MAJOR (WebGL gl-matrix) 3.732050895690918, 0, 0, 0, 0, 3.5069806575775146, -1.1543179750442505, -0.3420201241970062, 0, -1.2764365673065186, -3.1714625358581543, -0.9396926164627075, -44.784610748291016, -19.430234909057617, 83.09712982177734, 106.40138244628906 ], "placements": [{ "x": 0, // tile left offset in packed image (px) "y": 0, // tile top offset in packed image (px) "width": 418, // tile width "height": 418 // tile height }] }

Python reshape:

vp = np.array(view['viewProjectionMatrix']).reshape(4, 4, order='F') # vp[0] = [3.732, 0, 0, -44.78] ← column 0 of WebGL matrix # vp[1] = [0, 3.507, -1.276, -19.43] # vp[2] = [0, -1.154, -3.171, 83.10] # vp[3] = [0, -0.342, -0.940, 106.40]
9-view packed render
Packed 9-view render (1254×1254). Each tile is 418×418 px, 3×3 grid. Rasterizer produces one id_buf per tile.
semantic segmentation mask
Semantic segmentation mask. Chimney highlighted red by diffusion model. Enhanced mode: redness + diff vs render + erosion 3 px → all 71 selected blocks are brick.

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:

# Step 1: export geometry (once per litematic) yarn vite-node scripts/export_geometry.ts \ --input test/fixtures/litematics/雪屋.litematic \ --output /tmp/雪屋_geo # Step 2: compute per-block coverage scores (enhanced mode) mcsegment coverage \ --geometry /tmp/雪屋_geo/雪屋_geometry_meta.json \ --camera images/雪屋.json \ --mask imgs/雪屋_mask.png \ --render imgs/雪屋_render.png \ --mask-mode redness \ --mask-threshold 50 \ --diff-threshold 30 \ --erosion-px 3 \ --output /tmp/雪屋_scores.json # → 1557 block scores written # → score>0: 71 blocks (was 1104 without enhanced mode) # Step 3: remove blocks (score > 0.5, BFS-fill same-type interior) mcsegment remove-blocks \ --litematic test/fixtures/litematics/雪屋.litematic \ --scores /tmp/雪屋_scores.json \ --output /tmp/雪屋_no_chimney.litematic \ --threshold 0.5 \ --propagate # → 52 seed blocks (score > 0.5, all brick types, 0 non-brick) # → 99 total removed (52 seeds + 47 interior bricks via BFS propagation)

Before — 雪屋 with chimney

After — chimney removed (99 blocks)


Correctness Parameters

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

Known Pitfalls

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.

Parameter Quick Reference

Fig 2. Score distribution for blocks with score > 0 (enhanced mode, redness+diff+erosion). All 71 selected blocks are brick at every score level. Simple mode (dashed) had 1000+ non-brick blocks at score 0.0–0.1.

Recommended Defaults

OptionRecommendedRationale
--renderprovide packed render PNGEnables enhanced mode: redness AND diff AND erosion. Eliminates bleed before scoring.
--mask-moderednessIsolates red-dominant pixels from semantic mask
--mask-threshold50Redness cut in histogram gap (house 0–39, chimney 50+)
--diff-threshold30Excludes blocks red in both mask and render (naturally reddish structure)
--erosion-px3Removes 1–5 px boundary bleed; chimney core is >10 px wide so safe
--threshold0.5Score cutoff; with enhanced mode, even 0.1 is safe (all score>0 = brick)
--propagatealwaysFills 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