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.
- structura_core/__init__.py +6 -0
- structura_core/analyze.py +649 -0
- structura_core/biome_tags.py +60 -0
- structura_core/check_doors.py +78 -0
- structura_core/check_leaks.py +60 -0
- structura_core/chest_loot.py +99 -0
- structura_core/convert_legacy.py +401 -0
- structura_core/debug_interior_air.py +58 -0
- structura_core/default_loot.py +83 -0
- structura_core/dome_doors.py +141 -0
- structura_core/envelope.py +364 -0
- structura_core/export_schematic.py +86 -0
- structura_core/house_pool.py +39 -0
- structura_core/largest_component.py +130 -0
- structura_core/loot_tables.py +39 -0
- structura_core/nbt.py +206 -0
- structura_core/processor_list.py +108 -0
- structura_core/terrain_pod.py +625 -0
- structura_core/version.py +5 -0
- structura_core/voxel.py +18 -0
- structura_core/worldgen.py +77 -0
- structura_core-0.1.0.dist-info/METADATA +37 -0
- structura_core-0.1.0.dist-info/RECORD +26 -0
- structura_core-0.1.0.dist-info/WHEEL +5 -0
- structura_core-0.1.0.dist-info/entry_points.txt +16 -0
- structura_core-0.1.0.dist-info/top_level.txt +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()
|