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.
Files changed (33) hide show
  1. structura_core-0.2.3/PKG-INFO +90 -0
  2. structura_core-0.2.3/README.md +77 -0
  3. {structura_core-0.2.2 → structura_core-0.2.3}/pyproject.toml +7 -1
  4. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/__init__.py +1 -1
  5. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/analyze.py +3 -7
  6. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/convert_legacy.py +136 -97
  7. structura_core-0.2.3/src/structura_core/export_schematic.py +108 -0
  8. structura_core-0.2.3/src/structura_core/nbt.py +386 -0
  9. structura_core-0.2.3/src/structura_core.egg-info/PKG-INFO +90 -0
  10. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/SOURCES.txt +2 -0
  11. structura_core-0.2.3/tests/test_convert_legacy_preservation.py +84 -0
  12. structura_core-0.2.3/tests/test_export_schematic.py +116 -0
  13. structura_core-0.2.3/tests/test_nbt.py +274 -0
  14. structura_core-0.2.2/PKG-INFO +0 -44
  15. structura_core-0.2.2/README.md +0 -31
  16. structura_core-0.2.2/src/structura_core/export_schematic.py +0 -95
  17. structura_core-0.2.2/src/structura_core/nbt.py +0 -264
  18. structura_core-0.2.2/src/structura_core.egg-info/PKG-INFO +0 -44
  19. structura_core-0.2.2/tests/test_nbt.py +0 -79
  20. {structura_core-0.2.2 → structura_core-0.2.3}/setup.cfg +0 -0
  21. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/aesthetics.py +0 -0
  22. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/check_doors.py +0 -0
  23. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/check_leaks.py +0 -0
  24. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/largest_component.py +0 -0
  25. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/materials.py +0 -0
  26. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/version.py +0 -0
  27. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core/voxel.py +0 -0
  28. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/dependency_links.txt +0 -0
  29. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/entry_points.txt +0 -0
  30. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/requires.txt +0 -0
  31. {structura_core-0.2.2 → structura_core-0.2.3}/src/structura_core.egg-info/top_level.txt +0 -0
  32. {structura_core-0.2.2 → structura_core-0.2.3}/tests/test_convert_legacy_entities.py +0 -0
  33. {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.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"]
@@ -3,4 +3,4 @@
3
3
  from .nbt import AIR_NAMES, Structure, parse_state, save_structure, state_key
4
4
 
5
5
  __all__ = ["AIR_NAMES", "Structure", "parse_state", "save_structure", "state_key"]
6
- __version__ = "0.2.2"
6
+ __version__ = "0.2.3"
@@ -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. Found by inspecting a real rejected asset (2026-09-01,
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 -- e.g. this is the kind of signal that could have
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
- f"world floor (see [Obturonius]peakfortress, rejected live at 32.7%)"
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
- logging.getLogger("amulet").setLevel(logging.CRITICAL)
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
- air_positions.add(pos)
306
- continue
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
- # Trim the bounding box to the actual building. The source
349
- # .schematic's WorldEdit selection is usually padded well beyond the
350
- # real structure (the original builder eyeballing a region), and
351
- # `size` isn't just cosmetic -- terrain_adaptation's skirt and
352
- # dimension_padding both operate over the full declared footprint,
353
- # so a loose bounding box drags the terrain-blending staircase out
354
- # across empty margin where there's no building at all.
355
- trim_positions = [p for p, _, _ in blocks]
356
- if preserve_all_entities:
357
- trim_positions.extend(
358
- tuple(int(value // 1) for value in pos)
359
- for pos, _payload in legacy_entities
360
- if all(
361
- 0 <= value < limit
362
- for value, limit in zip(pos, (size_x, size_y, size_z))
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
- if not trim_positions:
366
- raise ValueError(
367
- f"source contains no renderable blocks or entities: {src_path}"
368
- )
369
- bx = [p[0] for p in trim_positions]
370
- by = [p[1] for p in trim_positions]
371
- bz = [p[2] for p in trim_positions]
372
- ox, oy, oz = min(bx), min(by), min(bz)
373
- new_size_x, new_size_y, new_size_z = (
374
- max(bx) - ox + 1,
375
- max(by) - oy + 1,
376
- max(bz) - oz + 1,
377
- )
378
- if (ox, oy, oz) != (0, 0, 0) or (new_size_x, new_size_y, new_size_z) != (
379
- size_x,
380
- size_y,
381
- size_z,
382
- ):
383
- print(
384
- f" trimmed bounding box: {size_x}x{size_y}x{size_z} -> "
385
- f"{new_size_x}x{new_size_y}x{new_size_z} (offset {ox},{oy},{oz})"
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
- blocks = [
388
- ((x - ox, y - oy, z - oz), idx, nbt) for (x, y, z), idx, nbt in blocks
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
- solid_positions = {pos for pos, _, _ in blocks}
411
- exterior_air, interior_air = _split_exterior_interior_air(
412
- air_positions, solid_positions, size_x, size_y, size_z
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
- door_air = _door_clearance(
416
- blocks,
417
- palette_list,
418
- (size_x, size_y, size_z),
419
- solid_positions,
420
- interior_air,
421
- )
422
- air_to_place = interior_air | door_air
423
- if air_to_place:
424
- air_idx = palette_index.get("minecraft:air")
425
- if air_idx is None:
426
- air_idx = len(palette_list)
427
- palette_index["minecraft:air"] = air_idx
428
- comp = CompoundTag()
429
- comp["Name"] = StringTag("minecraft:air")
430
- palette_list.append(comp)
431
- for pos in air_to_place:
432
- blocks.append((pos, air_idx, None))
433
- print(
434
- f" air: {len(exterior_air)} exterior (omitted), "
435
- f"{len(interior_air)} interior, "
436
- f"{len(door_air)} door-clearance (placed explicitly)"
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
- level.close()
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
- legacy = str(data.get("id") or data.get("Id") or "").split(":")[-1]
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(args.src, args.dst, args.data_version, target_version)
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()