structura-core 0.2.2__tar.gz → 0.2.3__tar.gz
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-0.2.3/PKG-INFO +90 -0
- structura_core-0.2.3/README.md +77 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/pyproject.toml +7 -1
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/__init__.py +1 -1
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/analyze.py +3 -7
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/convert_legacy.py +136 -97
- structura_core-0.2.3/src/structura_core/export_schematic.py +108 -0
- structura_core-0.2.3/src/structura_core/nbt.py +386 -0
- structura_core-0.2.3/src/structura_core.egg-info/PKG-INFO +90 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/SOURCES.txt +2 -0
- structura_core-0.2.3/tests/test_convert_legacy_preservation.py +84 -0
- structura_core-0.2.3/tests/test_export_schematic.py +116 -0
- structura_core-0.2.3/tests/test_nbt.py +274 -0
- structura_core-0.2.2/PKG-INFO +0 -44
- structura_core-0.2.2/README.md +0 -31
- structura_core-0.2.2/src/structura_core/export_schematic.py +0 -95
- structura_core-0.2.2/src/structura_core/nbt.py +0 -264
- structura_core-0.2.2/src/structura_core.egg-info/PKG-INFO +0 -44
- structura_core-0.2.2/tests/test_nbt.py +0 -79
- {structura_core-0.2.2 → structura_core-0.2.3}/setup.cfg +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/aesthetics.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/check_doors.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/check_leaks.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/largest_component.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/materials.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/version.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/voxel.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/dependency_links.txt +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/entry_points.txt +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/requires.txt +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/top_level.txt +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/tests/test_convert_legacy_entities.py +0 -0
- {structura_core-0.2.2 → structura_core-0.2.3}/tests/test_voxel.py +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structura-core
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Validated Minecraft Structure NBT I/O, legacy conversion and voxel geometry
|
|
5
|
+
Project-URL: Repository, https://github.com/kirimba1024/structura-core
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: amulet-nbt<3,>=2.1
|
|
9
|
+
Requires-Dist: numpy<3,>=1.26
|
|
10
|
+
Requires-Dist: scipy<2,>=1.13
|
|
11
|
+
Provides-Extra: legacy
|
|
12
|
+
Requires-Dist: amulet-core<2,>=1.9; extra == "legacy"
|
|
13
|
+
|
|
14
|
+
# structura-core
|
|
15
|
+
|
|
16
|
+
Reusable Minecraft Java 1.21.1 structure-processing core. It owns:
|
|
17
|
+
|
|
18
|
+
- validated Java Structure NBT I/O;
|
|
19
|
+
- legacy schematic conversion and numeric analysis;
|
|
20
|
+
- NumPy/SciPy voxel geometry primitives (connected components, closing).
|
|
21
|
+
|
|
22
|
+
Structure input accepts gzip-compressed and raw NBT, including files with
|
|
23
|
+
multiple palettes. Saving preserves all palettes, root metadata, additional
|
|
24
|
+
block fields, block entities and entities. Repeated saves of the same data
|
|
25
|
+
produce reproducible gzip bytes; they need not match the original file bytes.
|
|
26
|
+
|
|
27
|
+
The library targets Minecraft Java 1.21.1 by default. Version constants live
|
|
28
|
+
in `structura_core.version`; callers can still pass an explicit Amulet
|
|
29
|
+
translation target to the legacy converter.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install structura-core
|
|
33
|
+
structura-analyze path/to/structure.nbt --json
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `legacy` extra is needed for legacy input conversion. Structure NBT
|
|
37
|
+
processing and Sponge v2 `.schem` export do not require `amulet-core`.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install 'structura-core[legacy]'
|
|
41
|
+
structura-convert-legacy old.schematic structure.nbt
|
|
42
|
+
structura-export-schematic structure.nbt structure.schem
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development, use `pip install -e '.[legacy]'` from this package's
|
|
46
|
+
directory.
|
|
47
|
+
|
|
48
|
+
Legacy conversion keeps paintings and item frames by default, which is safe
|
|
49
|
+
for generated structures. Preview tools can call `convert(...,
|
|
50
|
+
preserve_all_entities=True)` to retain mobs, armor stands and loose items as
|
|
51
|
+
well, including their exact fractional positions and NBT payloads.
|
|
52
|
+
|
|
53
|
+
For conversion that retains selection bounds, authored air, bedrock and block
|
|
54
|
+
connections, pass `prepare_for_placement=False` as well, or use:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
structura-convert-legacy old.schematic preserved.nbt --preserve-layout --all-entities
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The converter's historical default still prepares a structure for datapack
|
|
61
|
+
placement: it trims bounds, replaces bedrock, repairs pane/bar connections and
|
|
62
|
+
selects interior/door-clearance air. Preview tools use the preserving mode.
|
|
63
|
+
Conversion retains custom entity namespaces and supplies translated block
|
|
64
|
+
entity IDs, allowing the result to be exported as Sponge without guessing IDs.
|
|
65
|
+
|
|
66
|
+
Generated `additions` fill missing cells and preserve authored cells, including
|
|
67
|
+
explicit air. Use `replacements` to intentionally replace a cell; replacement
|
|
68
|
+
also removes its old block entity NBT. Coordinates and palette indices must be
|
|
69
|
+
integers in the NBT range. Every saved block is bounds-checked, and a failed
|
|
70
|
+
write leaves an existing destination intact.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from structura_core import Structure, save_structure
|
|
74
|
+
from structura_core.export_schematic import export_schematic
|
|
75
|
+
|
|
76
|
+
structure = Structure("house.nbt", palette_index=0)
|
|
77
|
+
save_structure(structure, "copy.nbt", structure.size)
|
|
78
|
+
export_schematic(structure, "house.schem")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Selecting `palette_index` chooses the active view without discarding the other
|
|
82
|
+
palettes on save. An integer addition selects the corresponding variant in
|
|
83
|
+
each palette; a string such as `"minecraft:stone"` adds that literal state to
|
|
84
|
+
every palette. Shifts update entity positions and known vanilla hanging-entity
|
|
85
|
+
coordinates, while retaining unrelated custom NBT.
|
|
86
|
+
|
|
87
|
+
Sponge v2 has a single palette and a dense block array: export uses the selected
|
|
88
|
+
palette and fills missing cells with air. Entities and block entity payloads
|
|
89
|
+
are included; an entity without an `id` raises an error. This export does not
|
|
90
|
+
translate Minecraft versions, and the output retains the source `DataVersion`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# structura-core
|
|
2
|
+
|
|
3
|
+
Reusable Minecraft Java 1.21.1 structure-processing core. It owns:
|
|
4
|
+
|
|
5
|
+
- validated Java Structure NBT I/O;
|
|
6
|
+
- legacy schematic conversion and numeric analysis;
|
|
7
|
+
- NumPy/SciPy voxel geometry primitives (connected components, closing).
|
|
8
|
+
|
|
9
|
+
Structure input accepts gzip-compressed and raw NBT, including files with
|
|
10
|
+
multiple palettes. Saving preserves all palettes, root metadata, additional
|
|
11
|
+
block fields, block entities and entities. Repeated saves of the same data
|
|
12
|
+
produce reproducible gzip bytes; they need not match the original file bytes.
|
|
13
|
+
|
|
14
|
+
The library targets Minecraft Java 1.21.1 by default. Version constants live
|
|
15
|
+
in `structura_core.version`; callers can still pass an explicit Amulet
|
|
16
|
+
translation target to the legacy converter.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install structura-core
|
|
20
|
+
structura-analyze path/to/structure.nbt --json
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The `legacy` extra is needed for legacy input conversion. Structure NBT
|
|
24
|
+
processing and Sponge v2 `.schem` export do not require `amulet-core`.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install 'structura-core[legacy]'
|
|
28
|
+
structura-convert-legacy old.schematic structure.nbt
|
|
29
|
+
structura-export-schematic structure.nbt structure.schem
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For local development, use `pip install -e '.[legacy]'` from this package's
|
|
33
|
+
directory.
|
|
34
|
+
|
|
35
|
+
Legacy conversion keeps paintings and item frames by default, which is safe
|
|
36
|
+
for generated structures. Preview tools can call `convert(...,
|
|
37
|
+
preserve_all_entities=True)` to retain mobs, armor stands and loose items as
|
|
38
|
+
well, including their exact fractional positions and NBT payloads.
|
|
39
|
+
|
|
40
|
+
For conversion that retains selection bounds, authored air, bedrock and block
|
|
41
|
+
connections, pass `prepare_for_placement=False` as well, or use:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
structura-convert-legacy old.schematic preserved.nbt --preserve-layout --all-entities
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The converter's historical default still prepares a structure for datapack
|
|
48
|
+
placement: it trims bounds, replaces bedrock, repairs pane/bar connections and
|
|
49
|
+
selects interior/door-clearance air. Preview tools use the preserving mode.
|
|
50
|
+
Conversion retains custom entity namespaces and supplies translated block
|
|
51
|
+
entity IDs, allowing the result to be exported as Sponge without guessing IDs.
|
|
52
|
+
|
|
53
|
+
Generated `additions` fill missing cells and preserve authored cells, including
|
|
54
|
+
explicit air. Use `replacements` to intentionally replace a cell; replacement
|
|
55
|
+
also removes its old block entity NBT. Coordinates and palette indices must be
|
|
56
|
+
integers in the NBT range. Every saved block is bounds-checked, and a failed
|
|
57
|
+
write leaves an existing destination intact.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from structura_core import Structure, save_structure
|
|
61
|
+
from structura_core.export_schematic import export_schematic
|
|
62
|
+
|
|
63
|
+
structure = Structure("house.nbt", palette_index=0)
|
|
64
|
+
save_structure(structure, "copy.nbt", structure.size)
|
|
65
|
+
export_schematic(structure, "house.schem")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Selecting `palette_index` chooses the active view without discarding the other
|
|
69
|
+
palettes on save. An integer addition selects the corresponding variant in
|
|
70
|
+
each palette; a string such as `"minecraft:stone"` adds that literal state to
|
|
71
|
+
every palette. Shifts update entity positions and known vanilla hanging-entity
|
|
72
|
+
coordinates, while retaining unrelated custom NBT.
|
|
73
|
+
|
|
74
|
+
Sponge v2 has a single palette and a dense block array: export uses the selected
|
|
75
|
+
palette and fills missing cells with air. Entities and block entity payloads
|
|
76
|
+
are included; an entity without an `id` raises an error. This export does not
|
|
77
|
+
translate Minecraft versions, and the output retains the source `DataVersion`.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "structura-core"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "Validated Minecraft Structure NBT I/O, legacy conversion and voxel geometry"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -29,3 +29,9 @@ where = ["src"]
|
|
|
29
29
|
|
|
30
30
|
[project.urls]
|
|
31
31
|
Repository = "https://github.com/kirimba1024/structura-core"
|
|
32
|
+
|
|
33
|
+
[tool.ruff]
|
|
34
|
+
target-version = "py39"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E4", "E7", "E9", "F"]
|
|
@@ -266,9 +266,7 @@ class StructureAnalyzer:
|
|
|
266
266
|
"""Unlike dirt/stone/grass, bedrock is never a deliberate material
|
|
267
267
|
choice in a curated build -- its only realistic source is a
|
|
268
268
|
WorldEdit selection that reached all the way down to the world
|
|
269
|
-
floor.
|
|
270
|
-
[Obturonius]peakfortress -- rejected live as "ugly", turned out to
|
|
271
|
-
be 32.7% bedrock): a stronger, unambiguous version of the natural-
|
|
269
|
+
floor. This is a stronger, unambiguous version of the natural-
|
|
272
270
|
terrain heuristic above, worth its own warning rather than being
|
|
273
271
|
folded into that one fuzzier signal."""
|
|
274
272
|
hist = self.block_histogram()
|
|
@@ -388,9 +386,7 @@ class StructureAnalyzer:
|
|
|
388
386
|
# ---- G. environment fit -- material-palette voting, the same
|
|
389
387
|
# technique natural_terrain_fraction already uses, just against
|
|
390
388
|
# different curated block sets. A guess to narrow human/AI attention,
|
|
391
|
-
# not a verdict
|
|
392
|
-
# flagged davegr_house_cave's mismatch (heavy glass/wood, low stone)
|
|
393
|
-
# ahead of the live in-game rejection recorded in its notes. --------
|
|
389
|
+
# not a verdict. ----------------------------------------------------
|
|
394
390
|
|
|
395
391
|
_WATER_BLOCKS = {
|
|
396
392
|
"minecraft:prismarine",
|
|
@@ -730,7 +726,7 @@ class StructureAnalyzer:
|
|
|
730
726
|
warnings.append(
|
|
731
727
|
f"bedrock: {bf * 100:.1f}% of blocks -- almost never a deliberate "
|
|
732
728
|
f"material choice, near-certain sign the selection reached the "
|
|
733
|
-
|
|
729
|
+
"world floor"
|
|
734
730
|
)
|
|
735
731
|
return warnings
|
|
736
732
|
|
|
@@ -25,7 +25,7 @@ from amulet_nbt import (
|
|
|
25
25
|
StringTag,
|
|
26
26
|
)
|
|
27
27
|
|
|
28
|
-
from .nbt import load_root, write_root
|
|
28
|
+
from .nbt import _integer, load_root, write_root
|
|
29
29
|
from .version import DATA_VERSION, JAVA_VERSION
|
|
30
30
|
|
|
31
31
|
# Blocks that are never a deliberate material choice in a curated
|
|
@@ -265,14 +265,27 @@ def convert(
|
|
|
265
265
|
target_version=JAVA_VERSION,
|
|
266
266
|
quiet_errors: bool = True,
|
|
267
267
|
preserve_all_entities: bool = False,
|
|
268
|
+
*,
|
|
269
|
+
prepare_for_placement: bool = True,
|
|
268
270
|
):
|
|
271
|
+
"""Translate legacy input; optionally apply the established placement cleanup.
|
|
272
|
+
|
|
273
|
+
Set prepare_for_placement=False for previews or format conversion that must
|
|
274
|
+
retain the selection's size, air, authored materials and connections.
|
|
275
|
+
The historical default remains available for existing datapack pipelines.
|
|
276
|
+
"""
|
|
277
|
+
data_version = _integer(data_version, "DataVersion")
|
|
269
278
|
if len(target_version) != 3 or any(part < 0 for part in target_version):
|
|
270
279
|
raise ValueError(f"invalid Java target version: {target_version!r}")
|
|
280
|
+
target_version = tuple(_integer(part, "Java version component") for part in target_version)
|
|
281
|
+
logger = logging.getLogger("amulet")
|
|
282
|
+
previous_level = logger.level
|
|
283
|
+
level = None
|
|
271
284
|
if quiet_errors:
|
|
272
|
-
|
|
285
|
+
logger.setLevel(logging.CRITICAL)
|
|
273
286
|
|
|
274
|
-
level = amulet.load_level(src_path)
|
|
275
287
|
try:
|
|
288
|
+
level = amulet.load_level(src_path)
|
|
276
289
|
dim = level.dimensions[0]
|
|
277
290
|
bounds = level.bounds(dim)
|
|
278
291
|
(minx, miny, minz), (maxx, maxy, maxz) = bounds.min, bounds.max
|
|
@@ -301,13 +314,16 @@ def convert(
|
|
|
301
314
|
|
|
302
315
|
# universal air with stray legacy block_data properties
|
|
303
316
|
# (WorldEdit metadata leftovers) collapses to plain air.
|
|
304
|
-
if block.base_name == "air":
|
|
305
|
-
|
|
306
|
-
|
|
317
|
+
if block.base_name == "air" and block.namespace in {"minecraft", "universal_minecraft"}:
|
|
318
|
+
if not prepare_for_placement:
|
|
319
|
+
block = amulet.Block("minecraft", "air")
|
|
320
|
+
else:
|
|
321
|
+
air_positions.add(pos)
|
|
322
|
+
continue
|
|
307
323
|
replacement = FORCED_REPLACEMENTS.get(
|
|
308
324
|
(block.namespace, block.base_name)
|
|
309
325
|
)
|
|
310
|
-
if replacement is not None:
|
|
326
|
+
if prepare_for_placement and replacement is not None:
|
|
311
327
|
block = amulet.Block(*replacement)
|
|
312
328
|
if block.namespace == "universal_minecraft":
|
|
313
329
|
raise ValueError(
|
|
@@ -337,6 +353,7 @@ def convert(
|
|
|
337
353
|
entry_nbt = None
|
|
338
354
|
if block_entity is not None:
|
|
339
355
|
entry_nbt = block_entity.nbt.compound.copy()
|
|
356
|
+
entry_nbt["id"] = StringTag(block_entity.namespaced_name)
|
|
340
357
|
block_entities_count += 1
|
|
341
358
|
|
|
342
359
|
blocks.append((pos, idx, entry_nbt))
|
|
@@ -345,96 +362,98 @@ def convert(
|
|
|
345
362
|
legacy_entities = _legacy_entities(legacy_root, preserve_all_entities)
|
|
346
363
|
legacy_text = _legacy_tile_text(legacy_root)
|
|
347
364
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
trim_positions
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
365
|
+
ox = oy = oz = 0
|
|
366
|
+
if prepare_for_placement:
|
|
367
|
+
# Trim the bounding box to the actual building. The source
|
|
368
|
+
# .schematic's WorldEdit selection is usually padded well beyond the
|
|
369
|
+
# real structure (the original builder eyeballing a region), and
|
|
370
|
+
# `size` isn't just cosmetic -- terrain_adaptation's skirt and
|
|
371
|
+
# dimension_padding both operate over the full declared footprint,
|
|
372
|
+
# so a loose bounding box drags the terrain-blending staircase out
|
|
373
|
+
# across empty margin where there's no building at all.
|
|
374
|
+
trim_positions = [p for p, _, _ in blocks]
|
|
375
|
+
if preserve_all_entities:
|
|
376
|
+
trim_positions.extend(
|
|
377
|
+
tuple(int(value // 1) for value in pos)
|
|
378
|
+
for pos, _payload in legacy_entities
|
|
379
|
+
if all(
|
|
380
|
+
0 <= value < limit
|
|
381
|
+
for value, limit in zip(pos, (size_x, size_y, size_z))
|
|
382
|
+
)
|
|
363
383
|
)
|
|
384
|
+
if not trim_positions:
|
|
385
|
+
raise ValueError(
|
|
386
|
+
f"source contains no renderable blocks or entities: {src_path}"
|
|
387
|
+
)
|
|
388
|
+
bx = [p[0] for p in trim_positions]
|
|
389
|
+
by = [p[1] for p in trim_positions]
|
|
390
|
+
bz = [p[2] for p in trim_positions]
|
|
391
|
+
ox, oy, oz = min(bx), min(by), min(bz)
|
|
392
|
+
new_size_x, new_size_y, new_size_z = (
|
|
393
|
+
max(bx) - ox + 1,
|
|
394
|
+
max(by) - oy + 1,
|
|
395
|
+
max(bz) - oz + 1,
|
|
364
396
|
)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
397
|
+
if (ox, oy, oz) != (0, 0, 0) or (new_size_x, new_size_y, new_size_z) != (
|
|
398
|
+
size_x,
|
|
399
|
+
size_y,
|
|
400
|
+
size_z,
|
|
401
|
+
):
|
|
402
|
+
print(
|
|
403
|
+
f" trimmed bounding box: {size_x}x{size_y}x{size_z} -> "
|
|
404
|
+
f"{new_size_x}x{new_size_y}x{new_size_z} (offset {ox},{oy},{oz})"
|
|
405
|
+
)
|
|
406
|
+
blocks = [
|
|
407
|
+
((x - ox, y - oy, z - oz), idx, nbt) for (x, y, z), idx, nbt in blocks
|
|
408
|
+
]
|
|
409
|
+
air_positions = {
|
|
410
|
+
(x - ox, y - oy, z - oz)
|
|
411
|
+
for (x, y, z) in air_positions
|
|
412
|
+
if ox <= x <= ox + new_size_x - 1
|
|
413
|
+
and oy <= y <= oy + new_size_y - 1
|
|
414
|
+
and oz <= z <= oz + new_size_z - 1
|
|
415
|
+
}
|
|
416
|
+
size_x, size_y, size_z = new_size_x, new_size_y, new_size_z
|
|
417
|
+
|
|
418
|
+
# Air: exterior (open padding around the building) is omitted so it
|
|
419
|
+
# doesn't carve a crater into destination terrain; interior (fully
|
|
420
|
+
# enclosed pockets -- real rooms) is placed explicitly so those
|
|
421
|
+
# rooms get hollowed out even when the piece lands partway inside a
|
|
422
|
+
# hill and would otherwise show raw terrain poking through.
|
|
423
|
+
blocks, pane_fixes = _fix_pane_bar_connections(
|
|
424
|
+
blocks, palette_list, palette_index
|
|
386
425
|
)
|
|
387
|
-
|
|
388
|
-
(
|
|
389
|
-
]
|
|
390
|
-
air_positions = {
|
|
391
|
-
(x - ox, y - oy, z - oz)
|
|
392
|
-
for (x, y, z) in air_positions
|
|
393
|
-
if ox <= x <= ox + new_size_x - 1
|
|
394
|
-
and oy <= y <= oy + new_size_y - 1
|
|
395
|
-
and oz <= z <= oz + new_size_z - 1
|
|
396
|
-
}
|
|
397
|
-
size_x, size_y, size_z = new_size_x, new_size_y, new_size_z
|
|
398
|
-
|
|
399
|
-
# Air: exterior (open padding around the building) is omitted so it
|
|
400
|
-
# doesn't carve a crater into destination terrain; interior (fully
|
|
401
|
-
# enclosed pockets -- real rooms) is placed explicitly so those
|
|
402
|
-
# rooms get hollowed out even when the piece lands partway inside a
|
|
403
|
-
# hill and would otherwise show raw terrain poking through.
|
|
404
|
-
blocks, pane_fixes = _fix_pane_bar_connections(
|
|
405
|
-
blocks, palette_list, palette_index
|
|
406
|
-
)
|
|
407
|
-
if pane_fixes:
|
|
408
|
-
print(f" pane/bars connection fixes: {pane_fixes}")
|
|
426
|
+
if pane_fixes:
|
|
427
|
+
print(f" pane/bars connection fixes: {pane_fixes}")
|
|
409
428
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
429
|
+
solid_positions = {pos for pos, _, _ in blocks}
|
|
430
|
+
exterior_air, interior_air = _split_exterior_interior_air(
|
|
431
|
+
air_positions, solid_positions, size_x, size_y, size_z
|
|
432
|
+
)
|
|
414
433
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
434
|
+
door_air = _door_clearance(
|
|
435
|
+
blocks,
|
|
436
|
+
palette_list,
|
|
437
|
+
(size_x, size_y, size_z),
|
|
438
|
+
solid_positions,
|
|
439
|
+
interior_air,
|
|
440
|
+
)
|
|
441
|
+
air_to_place = interior_air | door_air
|
|
442
|
+
if air_to_place:
|
|
443
|
+
air_idx = palette_index.get("minecraft:air")
|
|
444
|
+
if air_idx is None:
|
|
445
|
+
air_idx = len(palette_list)
|
|
446
|
+
palette_index["minecraft:air"] = air_idx
|
|
447
|
+
comp = CompoundTag()
|
|
448
|
+
comp["Name"] = StringTag("minecraft:air")
|
|
449
|
+
palette_list.append(comp)
|
|
450
|
+
for pos in air_to_place:
|
|
451
|
+
blocks.append((pos, air_idx, None))
|
|
452
|
+
print(
|
|
453
|
+
f" air: {len(exterior_air)} exterior (omitted), "
|
|
454
|
+
f"{len(interior_air)} interior, "
|
|
455
|
+
f"{len(door_air)} door-clearance (placed explicitly)"
|
|
456
|
+
)
|
|
438
457
|
|
|
439
458
|
shifted_text = {
|
|
440
459
|
(tx - ox, ty - oy, tz - oz): lines
|
|
@@ -478,7 +497,7 @@ def convert(
|
|
|
478
497
|
entities_tag = ListTag()
|
|
479
498
|
for (ex, ey, ez), payload in legacy_entities:
|
|
480
499
|
pos = (ex - ox, ey - oy, ez - oz)
|
|
481
|
-
if not all(
|
|
500
|
+
if prepare_for_placement and not all(
|
|
482
501
|
0 <= v < limit for v, limit in zip(pos, (size_x, size_y, size_z))
|
|
483
502
|
):
|
|
484
503
|
continue
|
|
@@ -495,7 +514,12 @@ def convert(
|
|
|
495
514
|
print(f" blocks written: {len(blocks)}")
|
|
496
515
|
print(f" block entities: {block_entities_count}")
|
|
497
516
|
finally:
|
|
498
|
-
|
|
517
|
+
try:
|
|
518
|
+
if level is not None:
|
|
519
|
+
level.close()
|
|
520
|
+
finally:
|
|
521
|
+
if quiet_errors:
|
|
522
|
+
logger.setLevel(previous_level)
|
|
499
523
|
|
|
500
524
|
|
|
501
525
|
LEGACY_ENTITIES = {
|
|
@@ -666,15 +690,21 @@ def _legacy_entities(root, preserve_all=False):
|
|
|
666
690
|
root = _schematic_root(root)
|
|
667
691
|
for entry in root.get("Entities") or []:
|
|
668
692
|
data = _entity_data(entry)
|
|
669
|
-
|
|
693
|
+
identifier = str(data.get("id") or data.get("Id") or "")
|
|
694
|
+
namespace, separator, legacy = identifier.partition(":")
|
|
695
|
+
if not separator:
|
|
696
|
+
legacy, namespace = namespace, ""
|
|
670
697
|
key = legacy.replace("_", "").lower()
|
|
671
698
|
kind = (LEGACY_ENTITY_ALIASES if preserve_all else LEGACY_ENTITIES).get(key)
|
|
699
|
+
custom = bool(namespace and namespace != "minecraft")
|
|
700
|
+
if custom:
|
|
701
|
+
kind = identifier if preserve_all else None
|
|
672
702
|
if kind is None and preserve_all and legacy:
|
|
673
703
|
snake = re.sub(r"(?<!^)(?=[A-Z])", "_", legacy).replace(".", "_").lower()
|
|
674
704
|
kind = f"minecraft:{snake}"
|
|
675
705
|
if kind is None:
|
|
676
706
|
continue
|
|
677
|
-
hanging = key in LEGACY_ENTITIES
|
|
707
|
+
hanging = not custom and key in LEGACY_ENTITIES
|
|
678
708
|
if hanging:
|
|
679
709
|
try:
|
|
680
710
|
pos = tuple(
|
|
@@ -715,6 +745,11 @@ def main():
|
|
|
715
745
|
ap.add_argument("src", help="legacy .schematic path")
|
|
716
746
|
ap.add_argument("dst", help="output vanilla structure .nbt path")
|
|
717
747
|
ap.add_argument("--data-version", type=int, default=DATA_VERSION)
|
|
748
|
+
ap.add_argument(
|
|
749
|
+
"--preserve-layout", action="store_true",
|
|
750
|
+
help="preserve selection bounds, air, materials and connections instead of placement cleanup",
|
|
751
|
+
)
|
|
752
|
+
ap.add_argument("--all-entities", action="store_true", help="retain every source entity")
|
|
718
753
|
ap.add_argument(
|
|
719
754
|
"--target-version",
|
|
720
755
|
default="1.21.1",
|
|
@@ -727,7 +762,11 @@ def main():
|
|
|
727
762
|
ap.error("--target-version must contain three integers, for example 1.21.1")
|
|
728
763
|
if len(target_version) != 3:
|
|
729
764
|
ap.error("--target-version must contain three integers, for example 1.21.1")
|
|
730
|
-
convert(
|
|
765
|
+
convert(
|
|
766
|
+
args.src, args.dst, args.data_version, target_version,
|
|
767
|
+
preserve_all_entities=args.all_entities,
|
|
768
|
+
prepare_for_placement=not args.preserve_layout,
|
|
769
|
+
)
|
|
731
770
|
|
|
732
771
|
|
|
733
772
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Export Structure NBT as Sponge Schematic v2, including entities."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from amulet_nbt import (
|
|
9
|
+
ByteArrayTag, CompoundTag, DoubleTag, IntArrayTag, IntTag, ListTag,
|
|
10
|
+
ShortTag, StringTag,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .nbt import Structure, state_key, write_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _encode_varints(values):
|
|
17
|
+
data = bytearray()
|
|
18
|
+
for value in values:
|
|
19
|
+
value = int(value)
|
|
20
|
+
while value > 127:
|
|
21
|
+
data.append((value & 127) | 128)
|
|
22
|
+
value >>= 7
|
|
23
|
+
data.append(value)
|
|
24
|
+
return bytes(data)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def schematic_root(src):
|
|
28
|
+
"""Build a v2 payload using the selected palette; omitted cells become air.
|
|
29
|
+
|
|
30
|
+
Sponge has one palette and a dense block array. Other Structure palettes
|
|
31
|
+
and the distinction between omitted cells and air are not representable.
|
|
32
|
+
"""
|
|
33
|
+
src.validate()
|
|
34
|
+
sx, sy, sz = src.size
|
|
35
|
+
if any(value > 65535 for value in src.size):
|
|
36
|
+
raise ValueError("Sponge v2 dimensions must fit an unsigned short (1..65535)")
|
|
37
|
+
|
|
38
|
+
dedup_index = {}
|
|
39
|
+
remap = []
|
|
40
|
+
for entry in src.palette_raw:
|
|
41
|
+
key = state_key(entry)
|
|
42
|
+
remap.append(dedup_index.setdefault(key, len(dedup_index)))
|
|
43
|
+
|
|
44
|
+
air_index = dedup_index.setdefault("minecraft:air", len(dedup_index))
|
|
45
|
+
indices = np.full((sx, sy, sz), air_index, dtype=np.uint32)
|
|
46
|
+
for pos, index in src.present.items():
|
|
47
|
+
indices[pos] = remap[index]
|
|
48
|
+
# Sponge order is x + z * Width + y * Width * Length.
|
|
49
|
+
block_data = _encode_varints(np.transpose(indices, (1, 2, 0)).ravel())
|
|
50
|
+
|
|
51
|
+
block_entities = ListTag()
|
|
52
|
+
for pos, nbt in src.block_nbt.items():
|
|
53
|
+
if "id" not in nbt:
|
|
54
|
+
raise ValueError(f"block entity at {pos} has no id for Sponge export")
|
|
55
|
+
if not isinstance(nbt["id"], StringTag) or not str(nbt["id"]):
|
|
56
|
+
raise ValueError(f"block entity at {pos} needs a non-empty string id for Sponge export")
|
|
57
|
+
entry = deepcopy(nbt)
|
|
58
|
+
entry["Id"] = StringTag(str(entry.pop("id")))
|
|
59
|
+
entry["Pos"] = IntArrayTag(list(pos))
|
|
60
|
+
block_entities.append(entry)
|
|
61
|
+
|
|
62
|
+
entities = ListTag()
|
|
63
|
+
for record in src.entities:
|
|
64
|
+
entry = deepcopy(record["nbt"])
|
|
65
|
+
if "id" not in entry:
|
|
66
|
+
raise ValueError("entity has no id for Sponge export")
|
|
67
|
+
if not isinstance(entry["id"], StringTag) or not str(entry["id"]):
|
|
68
|
+
raise ValueError("entity needs a non-empty string id for Sponge export")
|
|
69
|
+
entry["Id"] = StringTag(str(entry.pop("id")))
|
|
70
|
+
entry["Pos"] = ListTag([DoubleTag(value.py_data) for value in record["pos"]])
|
|
71
|
+
entities.append(entry)
|
|
72
|
+
|
|
73
|
+
return CompoundTag({
|
|
74
|
+
"Version": IntTag(2),
|
|
75
|
+
"DataVersion": IntTag(src.data_version),
|
|
76
|
+
"Width": ShortTag(sx if sx < 32768 else sx - 65536),
|
|
77
|
+
"Height": ShortTag(sy if sy < 32768 else sy - 65536),
|
|
78
|
+
"Length": ShortTag(sz if sz < 32768 else sz - 65536),
|
|
79
|
+
"Offset": IntArrayTag([0, 0, 0]),
|
|
80
|
+
"Palette": CompoundTag({key: IntTag(i) for key, i in dedup_index.items()}),
|
|
81
|
+
"PaletteMax": IntTag(len(dedup_index)),
|
|
82
|
+
"BlockData": ByteArrayTag(np.frombuffer(block_data, dtype=np.int8)),
|
|
83
|
+
"BlockEntities": block_entities,
|
|
84
|
+
"Entities": entities,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def export_schematic(src, destination):
|
|
89
|
+
"""Write a Sponge v2 file without requiring the legacy conversion extra."""
|
|
90
|
+
destination = Path(destination)
|
|
91
|
+
write_root(schematic_root(src), destination, name="Schematic")
|
|
92
|
+
return destination
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main():
|
|
96
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
97
|
+
parser.add_argument("src")
|
|
98
|
+
parser.add_argument("dst")
|
|
99
|
+
parser.add_argument("--palette", type=int, default=0, help="Structure palette to export")
|
|
100
|
+
args = parser.parse_args()
|
|
101
|
+
|
|
102
|
+
src = Structure(args.src, palette_index=args.palette)
|
|
103
|
+
export_schematic(src, args.dst)
|
|
104
|
+
print(f"{args.dst} size={src.size} blocks={len(src.present)} entities={len(src.entities)}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
main()
|