structura-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Reusable Minecraft structure processing primitives."""
2
+
3
+ from .nbt import AIR_NAMES, Structure, parse_state, save_structure, state_key
4
+
5
+ __all__ = ["AIR_NAMES", "Structure", "parse_state", "save_structure", "state_key"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,649 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ StructureAnalyzer -- numeric quality/problem metrics for a converted
4
+ vanilla Structure NBT, so curation ("which of 2884 files are worth keeping")
5
+ doesn't require eyeballing every one.
6
+
7
+ Every metric here is a known technique borrowed from existing fields, not
8
+ invented for this project -- see docstrings for the source. Nothing here
9
+ replaces looking at a render eventually; it's a pre-filter to avoid looking
10
+ at obviously-broken or obviously-boring pieces.
11
+ """
12
+ import argparse
13
+ import json
14
+ import math
15
+ import struct
16
+ import zlib
17
+ from collections import Counter
18
+
19
+ import numpy as np
20
+
21
+ from .nbt import AIR_NAMES, Structure
22
+
23
+
24
+ def _sparkline(counts, y0, y1, width=40):
25
+ span = y1 - y0 + 1
26
+ buckets = [0] * min(width, span)
27
+ for y, n in counts.items():
28
+ i = min(len(buckets) - 1, (y - y0) * len(buckets) // span)
29
+ buckets[i] += n
30
+ peak = max(buckets) or 1
31
+ ramp = " ▁▂▃▄▅▆▇█"
32
+ return "".join(ramp[round(b / peak * (len(ramp) - 1))] for b in buckets)
33
+
34
+ # Some pieces omit air entirely (legacy conversion), others list it
35
+ # explicitly (hand-authored jigsaw pieces, to carve into terrain).
36
+ # StructureAnalyzer normalizes both to "not present" so every metric below
37
+ # means the same thing regardless of which authoring path produced the file.
38
+
39
+
40
+ class StructureAnalyzer:
41
+ def __init__(self, path: str):
42
+ self.path = path
43
+ s = Structure(path)
44
+ self.size = s.size
45
+ self.palette = s.palette
46
+ self.palette_raw = s.palette_raw
47
+ self.positions = {pos: idx for pos, idx in s.present.items()
48
+ if s.palette[idx] not in AIR_NAMES}
49
+ self.air_positions = {pos for pos, idx in s.present.items()
50
+ if s.palette[idx] in AIR_NAMES}
51
+ self._components = None # cached
52
+ self._rooms = None # cached
53
+ self._label_cache = None # cached: (labels, local_positions, count, sizes)
54
+ self._state_grid_cache = None # cached
55
+
56
+ # ---- A. geometry / integrity -----------------------------------
57
+
58
+ def bbox_volume(self) -> int:
59
+ sx, sy, sz = self.size
60
+ return sx * sy * sz
61
+
62
+ def density(self) -> float:
63
+ """non-air block count / bounding-box volume.
64
+
65
+ Standard PCG "density" measure (Smith & Whitehead, 2010) repurposed
66
+ for 3D: how much of the reserved footprint is actually solid. Very
67
+ low density on a *converted* piece (after we already strip padding
68
+ air) usually means a sprawling, sparse original build rather than a
69
+ real bug -- but combined with `connected_components` a low density
70
+ + many components is a strong debris signal.
71
+ """
72
+ return len(self.positions) / self.bbox_volume()
73
+
74
+ def _label_solid(self):
75
+ """26-connected component labeling of self.positions, vectorized
76
+ (scipy.ndimage.label -- the same primitive largest_component.py
77
+ already uses for this exact task) and cached. Backs both
78
+ connected_components() and floating_fraction(), which used to
79
+ each run their own pure-Python BFS -- correct, but a dict/deque
80
+ walk with 26 neighbor checks per cell doesn't scale, and visibly
81
+ struggled (60+s) on the largest pieces in the archive. Returns
82
+ (labels, local_positions, count, sizes): labels is label-per-cell
83
+ in a local (offset-cropped) array, local_positions is
84
+ self.positions' keys in that same local coordinate space and
85
+ order, sizes[label] is that component's block count (sizes[0] is
86
+ unlabeled background, unused)."""
87
+ if self._label_cache is not None:
88
+ return self._label_cache
89
+ from scipy import ndimage
90
+
91
+ if not self.positions:
92
+ self._label_cache = (None, np.zeros((0, 3), dtype=np.int32), 0, np.array([0]))
93
+ return self._label_cache
94
+ positions = np.array(list(self.positions.keys()), dtype=np.int32)
95
+ offset = positions.min(axis=0)
96
+ local = positions - offset
97
+ mask = np.zeros(tuple(local.max(axis=0) + 1), dtype=bool)
98
+ mask[tuple(local.T)] = True
99
+ labels, count = ndimage.label(mask, structure=ndimage.generate_binary_structure(3, 3))
100
+ sizes = np.bincount(labels.ravel())
101
+ self._label_cache = (labels, local, count, sizes)
102
+ return self._label_cache
103
+
104
+ def connected_components(self):
105
+ """26-connected flood fill over non-air cells, sizes only (every
106
+ caller here only ever needs len(component), never its contents)
107
+ sorted largest-first. Each "component" is a range(size) so
108
+ len(comps[0]) etc. keep working exactly as before."""
109
+ if self._components is not None:
110
+ return self._components
111
+ _, _, count, sizes = self._label_solid()
112
+ comp_sizes = sorted((int(sizes[i]) for i in range(1, count + 1)), reverse=True)
113
+ self._components = [range(s) for s in comp_sizes]
114
+ return self._components
115
+
116
+ def debris_fraction(self) -> float:
117
+ """Fraction of blocks NOT in the largest connected component."""
118
+ comps = self.connected_components()
119
+ if not comps:
120
+ return 0.0
121
+ main = len(comps[0])
122
+ total = sum(len(c) for c in comps)
123
+ return 1 - main / total
124
+
125
+ def floating_fraction(self) -> float:
126
+ """Fraction of blocks with no support chain down to a block at
127
+ min-Y (the structure's own floor plane).
128
+
129
+ Borrowed from voxel-building-validation practice: a cell is
130
+ "grounded" if the cell directly below it is solid, propagated
131
+ transitively (support graph flood fill), same principle used for
132
+ gravity constraints in block-building tools/games. Anything not
133
+ reachable that way is floating debris or a disconnected floating
134
+ wing -- both worth flagging before this piece goes in the library.
135
+
136
+ Support propagation and connected-component membership are the
137
+ same relation here (26-connectivity, no directionality) -- a
138
+ component is entirely grounded the moment any one of its cells
139
+ touches the min-Y plane, since the flood fill would reach the
140
+ rest of that component regardless of which cell it started from.
141
+ So: reuse _label_solid()'s labeling, just check which labels
142
+ appear at min-Y instead of walking a queue by hand.
143
+ """
144
+ if not self.positions:
145
+ return 0.0
146
+ labels, local, count, sizes = self._label_solid()
147
+ min_y_local = int(local[:, 1].min())
148
+ touching = set(labels[tuple(local[local[:, 1] == min_y_local].T)].tolist())
149
+ touching.discard(0)
150
+ grounded = sum(int(sizes[label]) for label in touching)
151
+ return 1 - grounded / len(self.positions)
152
+
153
+ # ---- B. material diversity --------------------------------------
154
+
155
+ def block_histogram(self):
156
+ c = Counter(self.positions.values())
157
+ result = Counter()
158
+ for index, count in c.items():
159
+ result[self.palette[index]] += count
160
+ return result
161
+
162
+ def palette_entropy(self) -> float:
163
+ """Shannon entropy (bits) of the block-type distribution.
164
+
165
+ Standard information-theory diversity measure. Low entropy = a
166
+ near-monolithic material (fine for e.g. a plain corridor); very
167
+ high entropy relative to block count can indicate a noisy/messy
168
+ legacy build rather than deliberate material variation.
169
+ """
170
+ hist = self.block_histogram()
171
+ total = sum(hist.values())
172
+ h = 0.0
173
+ for n in hist.values():
174
+ p = n / total
175
+ h -= p * math.log2(p)
176
+ return h
177
+
178
+ # ---- C. compressibility / structural complexity -------------------
179
+
180
+ def compression_ratio(self) -> float:
181
+ """compressed size / raw size of the (pos, state) stream, zlib.
182
+
183
+ A real-compressor approximation of Kolmogorov complexity, in the
184
+ Normalized-Compression-Distance lineage used to score procedurally
185
+ generated content. Low ratio = very repetitive geometry (a long
186
+ plain corridor); ratio close to 1 = little redundancy, either a
187
+ deliberately intricate piece or a noisy/dirty one -- read together
188
+ with entropy and debris_fraction to tell those apart.
189
+ """
190
+ items = sorted(self.positions.items())
191
+ raw = b"".join(struct.pack(">iiiI", *pos, state) for pos, state in items)
192
+ if not raw:
193
+ return 0.0
194
+ compressed = zlib.compress(raw, level=9)
195
+ return len(compressed) / len(raw)
196
+
197
+ # ---- D. symmetry -----------------------------------------------
198
+
199
+ def _state_grid(self):
200
+ """self.size-shaped array of palette index, -1 where there's no
201
+ block. Cached; backs mirror_symmetry (called twice, once per axis)."""
202
+ if self._state_grid_cache is not None:
203
+ return self._state_grid_cache
204
+ grid = np.full(self.size, -1, dtype=np.int32)
205
+ if self.positions:
206
+ positions = np.array(list(self.positions.keys()), dtype=np.int32)
207
+ indices = np.array(list(self.positions.values()), dtype=np.int32)
208
+ grid[tuple(positions.T)] = indices
209
+ self._state_grid_cache = grid
210
+ return grid
211
+
212
+ def mirror_symmetry(self, axis: str = "x") -> float:
213
+ """Fraction of blocks whose mirrored position (same axis, across
214
+ the piece's own centerline) also has a block of the same type.
215
+
216
+ Standard mirror-symmetry scoring, the same comparison-to-transformed
217
+ -copy principle used to score symmetry error in voxel/3D generation
218
+ work (e.g. SymTRELLIS). 1.0 = perfectly symmetric on that axis.
219
+
220
+ Vectorized: flipping the whole state grid along one axis is
221
+ exactly the per-block "look up the mirrored position" the
222
+ original pure-Python version did one dict access at a time.
223
+ """
224
+ if not self.positions:
225
+ return 0.0
226
+ axis_i = {"x": 0, "y": 1, "z": 2}[axis]
227
+ grid = self._state_grid()
228
+ mirrored = np.flip(grid, axis=axis_i)
229
+ match = int(np.sum((grid == mirrored) & (grid != -1)))
230
+ return match / len(self.positions)
231
+
232
+ # ---- E. terrain-capture heuristic (domain-specific, not academic) --
233
+
234
+ # Not a borrowed technique like the others -- this is the same thing
235
+ # Minecraft builders already do by eye with WorldEdit's //distr: check
236
+ # what fraction of the block count is "natural terrain" material.
237
+ # Connected-component/floating checks can't catch a captured hill,
238
+ # because attached terrain is topologically indistinguishable from
239
+ # intentional building material (see: SmallHouse.schematic, height 76,
240
+ # ~11% dirt+grass+stone, one connected & fully grounded component).
241
+ _NATURAL_BLOCKS = {
242
+ "minecraft:dirt", "minecraft:grass_block", "minecraft:stone",
243
+ "minecraft:gravel", "minecraft:sand", "minecraft:coarse_dirt",
244
+ "minecraft:podzol", "minecraft:mycelium", "minecraft:andesite",
245
+ "minecraft:diorite", "minecraft:granite", "minecraft:clay",
246
+ }
247
+
248
+ def natural_terrain_fraction(self) -> float:
249
+ hist = self.block_histogram()
250
+ total = sum(hist.values())
251
+ if not total:
252
+ return 0.0
253
+ natural = sum(n for name, n in hist.items() if name in self._NATURAL_BLOCKS)
254
+ return natural / total
255
+
256
+ def bedrock_fraction(self) -> float:
257
+ """Unlike dirt/stone/grass, bedrock is never a deliberate material
258
+ choice in a curated build -- its only realistic source is a
259
+ WorldEdit selection that reached all the way down to the world
260
+ floor. Found by inspecting a real rejected asset (2026-09-01,
261
+ [Obturonius]peakfortress -- rejected live as "ugly", turned out to
262
+ be 32.7% bedrock): a stronger, unambiguous version of the natural-
263
+ terrain heuristic above, worth its own warning rather than being
264
+ folded into that one fuzzier signal."""
265
+ hist = self.block_histogram()
266
+ total = sum(hist.values())
267
+ if not total:
268
+ return 0.0
269
+ return hist.get("minecraft:bedrock", 0) / total
270
+
271
+ def terrain_profile(self):
272
+ """Y-range and per-layer density of the natural-terrain material
273
+ already in this piece (captured hill, authored yard mound, ...).
274
+ natural_terrain_fraction is one ratio for the whole piece; this is
275
+ where it sits and how it's distributed -- a thin scattered dusting
276
+ reads very differently from one dense captured mass, same overall
277
+ fraction."""
278
+ natural_ys = [
279
+ pos[1] for pos, idx in self.positions.items()
280
+ if self.palette[idx] in self._NATURAL_BLOCKS
281
+ ]
282
+ if not natural_ys:
283
+ return None
284
+ y0, y1 = min(natural_ys), max(natural_ys)
285
+ return {
286
+ "min_y": y0,
287
+ "max_y": y1,
288
+ "span": y1 - y0 + 1,
289
+ "block_count": len(natural_ys),
290
+ "density_profile": _sparkline(Counter(natural_ys), y0, y1),
291
+ }
292
+
293
+ # ---- F. rooms / silhouette -- numeric stand-ins for "look at a
294
+ # picture", not academic citations, but each built on the same
295
+ # primitives (connected-component labeling, PCA) used elsewhere in
296
+ # this project and confirmed as the standard approach for exactly
297
+ # this via web research (2026-09-01): room segmentation from dilating
298
+ # a voxel occupancy grid until doorway apertures close, then labeling
299
+ # connected components, is literally how voxel indoor-map room
300
+ # detection is done in robotics/reconstruction work; PCA/SVD on a
301
+ # point cloud for a principal-axis oriented bounding box is the
302
+ # standard cheap elongation/orientation descriptor. -----------------
303
+
304
+ def rooms(self):
305
+ """Explicit-air cells (already interior-only -- see convert_legacy's
306
+ exterior/interior split), 6-connected into individual enclosed
307
+ rooms. Component count/sizes describe interior complexity without
308
+ opening a render."""
309
+ if self._rooms is not None:
310
+ return self._rooms
311
+ if not self.air_positions:
312
+ self._rooms = []
313
+ return self._rooms
314
+ from scipy import ndimage
315
+
316
+ positions = np.asarray(list(self.air_positions), dtype=np.int32)
317
+ offset = positions.min(axis=0)
318
+ local = positions - offset
319
+ mask = np.zeros(tuple(local.max(axis=0) + 1), dtype=bool)
320
+ mask[tuple(local.T)] = True
321
+ labels, count = ndimage.label(mask, structure=ndimage.generate_binary_structure(3, 1))
322
+ sizes = np.bincount(labels.ravel())[1:] if count else np.array([], dtype=int)
323
+ self._rooms = sorted(sizes.tolist(), reverse=True)
324
+ return self._rooms
325
+
326
+ def footprint_elongation(self):
327
+ """PCA on the XZ footprint of solid blocks: ratio of the major to
328
+ minor principal-axis spread, and that major axis's angle off the
329
+ X grid line. A Minecraft build is normally grid-aligned by
330
+ construction -- an angle far from 0/90 degrees is itself a signal
331
+ (diagonal roof detail, or a captured/rotated selection)."""
332
+ if len(self.positions) < 2:
333
+ return 1.0, 0.0
334
+ pts = np.array([(p[0], p[2]) for p in self.positions], dtype=float)
335
+ pts -= pts.mean(axis=0)
336
+ cov = np.cov(pts.T)
337
+ eigvals, eigvecs = np.linalg.eigh(cov)
338
+ order = np.argsort(eigvals)[::-1]
339
+ eigvals = np.clip(eigvals[order], 1e-9, None)
340
+ major = eigvecs[:, order[0]]
341
+ angle = math.degrees(math.atan2(major[1], major[0])) % 90.0
342
+ return float(math.sqrt(eigvals[0] / eigvals[1])), round(angle, 1)
343
+
344
+ def vertical_profile(self, width: int = 40) -> str:
345
+ """One-line block-count-per-Y sparkline -- roof taper, multiple
346
+ floors, or a floating outlier layer all show up as a shape in
347
+ text, no render needed."""
348
+ if not self.positions:
349
+ return ""
350
+ ys = [p[1] for p in self.positions]
351
+ return _sparkline(Counter(ys), min(ys), max(ys), width)
352
+
353
+ def floor_count(self) -> int:
354
+ """Distinct horizontal density peaks in the Y-profile. A floor
355
+ plate (floor planking, or the ceiling below the next room up)
356
+ is a near-full-footprint slab, so it shows up as a local peak in
357
+ blocks-per-Y separated by lower-density room-interior layers.
358
+ scipy.signal.find_peaks is the standard 1D peak-detection
359
+ primitive; a 15%-of-max prominence floor keeps window-band or
360
+ trim ripples from counting as extra floors. Heuristic, not exact
361
+ -- an open-plan atrium or a dome throws it off."""
362
+ if not self.positions:
363
+ return 0
364
+ from scipy.signal import find_peaks
365
+
366
+ ys = [p[1] for p in self.positions]
367
+ y0, y1 = min(ys), max(ys)
368
+ if y1 == y0:
369
+ return 1
370
+ counts = np.zeros(y1 - y0 + 1)
371
+ for y, n in Counter(ys).items():
372
+ counts[y - y0] = n
373
+ peaks, _ = find_peaks(counts, prominence=counts.max() * 0.15, distance=2)
374
+ return max(1, len(peaks))
375
+
376
+ # ---- G. environment fit -- material-palette voting, the same
377
+ # technique natural_terrain_fraction already uses, just against
378
+ # different curated block sets. A guess to narrow human/AI attention,
379
+ # not a verdict -- e.g. this is the kind of signal that could have
380
+ # flagged davegr_house_cave's mismatch (heavy glass/wood, low stone)
381
+ # ahead of the live in-game rejection recorded in its notes. --------
382
+
383
+ _WATER_BLOCKS = {
384
+ "minecraft:prismarine", "minecraft:prismarine_bricks",
385
+ "minecraft:dark_prismarine", "minecraft:sea_lantern",
386
+ "minecraft:kelp", "minecraft:kelp_plant", "minecraft:conduit",
387
+ "minecraft:tube_coral_block", "minecraft:brain_coral_block",
388
+ "minecraft:sponge", "minecraft:wet_sponge",
389
+ }
390
+ _NETHER_BLOCKS = {
391
+ "minecraft:netherrack", "minecraft:nether_bricks", "minecraft:blackstone",
392
+ "minecraft:basalt", "minecraft:soul_sand", "minecraft:soul_soil",
393
+ "minecraft:glowstone", "minecraft:magma_block", "minecraft:crimson_planks",
394
+ "minecraft:warped_planks", "minecraft:nether_wart_block", "minecraft:shroomlight",
395
+ }
396
+
397
+ def environment_fit(self) -> dict:
398
+ hist = self.block_histogram()
399
+ total = sum(hist.values()) or 1
400
+ water = sum(n for name, n in hist.items() if name in self._WATER_BLOCKS) / total
401
+ nether = sum(n for name, n in hist.items() if name in self._NETHER_BLOCKS) / total
402
+ glass = sum(n for name, n in hist.items() if "glass" in name) / total
403
+ stone_family = sum(
404
+ n for name, n in hist.items()
405
+ if "stone" in name or "cobble" in name or "brick" in name
406
+ ) / total
407
+ return {
408
+ "water_material_fraction": round(water, 4),
409
+ "nether_material_fraction": round(nether, 4),
410
+ "glass_fraction": round(glass, 4),
411
+ "stone_family_fraction": round(stone_family, 4),
412
+ "has_own_ground": self.natural_terrain_fraction() > 0.15,
413
+ "cave_friendly_guess": stone_family > 0.5 and glass < 0.05,
414
+ }
415
+
416
+ # ---- H. gameplay-relevant, not just geometric ----------------------
417
+
418
+ _LIGHT_SOURCES = {
419
+ "minecraft:torch", "minecraft:wall_torch", "minecraft:soul_torch",
420
+ "minecraft:soul_wall_torch", "minecraft:lantern", "minecraft:soul_lantern",
421
+ "minecraft:glowstone", "minecraft:sea_lantern", "minecraft:jack_o_lantern",
422
+ "minecraft:campfire", "minecraft:soul_campfire", "minecraft:redstone_lamp",
423
+ "minecraft:shroomlight", "minecraft:beacon", "minecraft:end_rod",
424
+ "minecraft:ochre_froglight", "minecraft:verdant_froglight",
425
+ "minecraft:pearlescent_froglight",
426
+ }
427
+
428
+ def light_coverage(self, radius: float = 7.0):
429
+ """Light-source count, and the fraction of interior_air within
430
+ `radius` blocks of one (nearest-neighbor query, scipy.spatial.
431
+ cKDTree -- the standard tool for this, not a real light-
432
+ propagation simulation: no falloff, no wall occlusion). A dark
433
+ interior is invisible to every geometric metric here but spawns
434
+ hostile mobs the moment this piece is placed -- the one gameplay-
435
+ correctness check this report was missing."""
436
+ lights = [pos for pos, idx in self.positions.items() if self.palette[idx] in self._LIGHT_SOURCES]
437
+ if not lights or not self.air_positions:
438
+ return len(lights), 0.0
439
+ from scipy.spatial import cKDTree
440
+
441
+ tree = cKDTree(np.array(lights))
442
+ distances, _ = tree.query(np.array(list(self.air_positions)))
443
+ covered = int((distances <= radius).sum())
444
+ return len(lights), round(covered / len(self.air_positions), 4)
445
+
446
+ _DOOR_FACING = ("north", "south", "east", "west")
447
+
448
+ def doors(self):
449
+ """Door position + facing, straight off palette Properties --
450
+ convert_legacy.py already detects this shape (for door-clearance
451
+ air carving), this just reports what it found instead of only
452
+ acting on it. Lower half only, one entry per door (not two)."""
453
+ found = []
454
+ for pos, index in self.positions.items():
455
+ name = self.palette[index]
456
+ if not name.endswith("_door"):
457
+ continue
458
+ entry = self.palette_raw[index]
459
+ props = entry.get("Properties")
460
+ if props is None or str(props.get("half", "")) != "lower":
461
+ continue
462
+ facing = props.get("facing")
463
+ if facing is None or str(facing) not in self._DOOR_FACING:
464
+ continue
465
+ found.append({"pos": list(pos), "block": name, "facing": str(facing)})
466
+ return found
467
+
468
+ _MATERIAL_FAMILIES = (
469
+ ("glass", ("glass",)),
470
+ ("wood", ("plank", "log", "wood", "oak", "spruce", "birch", "jungle",
471
+ "acacia", "dark_oak", "mangrove", "cherry", "bamboo",
472
+ "crimson", "warped")),
473
+ ("stone", ("stone", "cobble", "brick", "andesite", "diorite", "granite",
474
+ "deepslate", "blackstone", "basalt", "sandstone", "quartz",
475
+ "prismarine", "terracotta", "concrete", "netherrack")),
476
+ ("metal", ("iron", "copper", "gold", "netherite", "chain")),
477
+ ("nature", ("leaves", "grass", "dirt", "sand", "gravel", "flower", "vine",
478
+ "kelp", "coral", "mycelium", "podzol", "moss", "sapling",
479
+ "fern", "bush", "crop", "wart", "mushroom", "lily_pad")),
480
+ ("functional", ("chest", "furnace", "door", "torch", "lantern", "_bed",
481
+ "table", "shelf", "barrel", "smoker", "loom", "anvil",
482
+ "brewing", "cauldron", "hopper", "dispenser", "dropper",
483
+ "redstone", "lever", "button", "plate", "rail", "sign",
484
+ "banner", "frame", "armor_stand")),
485
+ )
486
+
487
+ def material_families(self) -> dict:
488
+ """Block histogram collapsed into a 6-bucket aesthetic fingerprint
489
+ (wood/stone/glass/metal/nature/functional/other), percentages --
490
+ one glance instead of reading a 90-row block list. Keyword-
491
+ substring classifier checked in the order above, first match
492
+ wins (e.g. oak_door reads as wood, not functional) -- a fast
493
+ fingerprint, not a precise taxonomy."""
494
+ hist = self.block_histogram()
495
+ total = sum(hist.values()) or 1
496
+ totals = Counter()
497
+ for name, n in hist.items():
498
+ base = name.split(":", 1)[-1]
499
+ family = next(
500
+ (fam for fam, keys in self._MATERIAL_FAMILIES if any(k in base for k in keys)),
501
+ "other",
502
+ )
503
+ totals[family] += n
504
+ return {fam: round(100 * n / total, 1) for fam, n in totals.most_common()}
505
+
506
+ def summary_line(self, report: dict) -> str:
507
+ """One line synthesized from an already-built report() dict --
508
+ the fastest way to grasp "what is this" without reading every
509
+ field or opening a render."""
510
+ sx, sy, sz = report["size"]
511
+ materials = ", ".join(
512
+ f"{fam} {pct}%" for fam, pct in list(report["material_families"].items())[:2]
513
+ )
514
+ angle = report["footprint_axis_deg"]
515
+ aligned = "grid-aligned" if angle < 5 or angle > 85 else f"rotated {angle} deg"
516
+ bits = [
517
+ f"{sx}x{sy}x{sz}",
518
+ f"{report['floor_count']} floor(s)",
519
+ f"{report['room_count']} room(s)",
520
+ materials,
521
+ aligned,
522
+ f"{report['door_count']} door(s)",
523
+ f"light coverage {report['light_coverage'] * 100:.0f}%",
524
+ ]
525
+ if report["warnings"]:
526
+ bits.append(f"{len(report['warnings'])} warning(s)")
527
+ return " | ".join(bits)
528
+
529
+ # ---- report -------------------------------------------------------
530
+
531
+ def report(self) -> dict:
532
+ comps = self.connected_components()
533
+ rooms = self.rooms()
534
+ elongation, axis_angle = self.footprint_elongation()
535
+ doors = self.doors()
536
+ light_count, light_coverage = self.light_coverage()
537
+ debris_fraction = self.debris_fraction()
538
+ floating_fraction = self.floating_fraction()
539
+ r = {
540
+ "path": self.path,
541
+ "size": self.size,
542
+ "block_count": len(self.positions),
543
+ "palette_size": len(self.palette),
544
+ "density": round(self.density(), 4),
545
+ "components": len(comps),
546
+ "main_component_size": len(comps[0]) if comps else 0,
547
+ "debris_fraction": round(debris_fraction, 4),
548
+ "floating_fraction": round(floating_fraction, 4),
549
+ "palette_entropy_bits": round(self.palette_entropy(), 3),
550
+ "compression_ratio": round(self.compression_ratio(), 4),
551
+ "mirror_symmetry_x": round(self.mirror_symmetry("x"), 3),
552
+ "mirror_symmetry_z": round(self.mirror_symmetry("z"), 3),
553
+ "natural_terrain_fraction": round(self.natural_terrain_fraction(), 4),
554
+ "bedrock_fraction": round(self.bedrock_fraction(), 4),
555
+ "room_count": len(rooms),
556
+ "room_sizes": rooms[:8],
557
+ "footprint_elongation": round(elongation, 2),
558
+ "footprint_axis_deg": axis_angle,
559
+ "floor_count": self.floor_count(),
560
+ "vertical_profile": self.vertical_profile(),
561
+ "terrain_profile": self.terrain_profile(),
562
+ "environment_fit": self.environment_fit(),
563
+ "material_families": self.material_families(),
564
+ "door_count": len(doors),
565
+ "doors": doors,
566
+ "light_source_count": light_count,
567
+ "light_coverage": light_coverage,
568
+ "warnings": self._warnings(comps, debris_fraction, floating_fraction),
569
+ }
570
+ r["summary"] = self.summary_line(r)
571
+ return r
572
+
573
+ def _warnings(self, comps, debris_fraction, floating_fraction):
574
+ warnings = []
575
+ if debris_fraction > 0.005:
576
+ warnings.append(
577
+ f"debris: {len(comps)-1} disconnected component(s), "
578
+ f"{debris_fraction*100:.1f}% of blocks -- consider dropping"
579
+ )
580
+ if floating_fraction > 0.02:
581
+ warnings.append(
582
+ f"floating: {floating_fraction*100:.1f}% of blocks have no "
583
+ f"support chain to the floor plane -- check for a detached wing"
584
+ )
585
+ if self.density() < 0.02:
586
+ warnings.append(
587
+ f"very low density ({self.density()*100:.2f}%) -- bounding box "
588
+ f"likely still has untrimmed padding"
589
+ )
590
+ ntf = self.natural_terrain_fraction()
591
+ if ntf > 0.15:
592
+ warnings.append(
593
+ f"likely captured terrain: {ntf*100:.1f}% of blocks are natural "
594
+ f"material (dirt/grass/stone/...) -- check for an attached hill/tree"
595
+ )
596
+ bf = self.bedrock_fraction()
597
+ if bf > 0.001:
598
+ warnings.append(
599
+ f"bedrock: {bf*100:.1f}% of blocks -- almost never a deliberate "
600
+ f"material choice, near-certain sign the selection reached the "
601
+ f"world floor (see [Obturonius]peakfortress, rejected live at 32.7%)"
602
+ )
603
+ return warnings
604
+
605
+
606
+ def main():
607
+ ap = argparse.ArgumentParser()
608
+ ap.add_argument("path")
609
+ ap.add_argument("--json", action="store_true")
610
+ ap.add_argument(
611
+ "--histogram", action="store_true",
612
+ help="print block name/count/percent instead of the full report",
613
+ )
614
+ args = ap.parse_args()
615
+ analyzer = StructureAnalyzer(args.path)
616
+
617
+ if args.histogram:
618
+ hist = analyzer.block_histogram()
619
+ total = sum(hist.values()) or 1
620
+ rows = [
621
+ {"block": name, "count": n, "percent": round(100 * n / total, 2)}
622
+ for name, n in hist.most_common()
623
+ ]
624
+ if args.json:
625
+ print(json.dumps(rows, indent=2))
626
+ else:
627
+ for row in rows:
628
+ print(f"{row['count']:7d} {row['percent']:5.1f}% {row['block']}")
629
+ return
630
+
631
+ r = analyzer.report()
632
+ if args.json:
633
+ print(json.dumps(r, indent=2))
634
+ else:
635
+ print(f"summary : {r['summary']}")
636
+ for k, v in r.items():
637
+ if k in ("warnings", "summary"):
638
+ continue
639
+ print(f"{k:22s}: {v}")
640
+ if r["warnings"]:
641
+ print("warnings:")
642
+ for w in r["warnings"]:
643
+ print(f" - {w}")
644
+ else:
645
+ print("warnings: none")
646
+
647
+
648
+ if __name__ == "__main__":
649
+ main()