structura-geo 0.1.0__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_geo-0.1.0/PKG-INFO +55 -0
- structura_geo-0.1.0/README.md +42 -0
- structura_geo-0.1.0/pyproject.toml +34 -0
- structura_geo-0.1.0/setup.cfg +4 -0
- structura_geo-0.1.0/src/structura_geo/__init__.py +0 -0
- structura_geo-0.1.0/src/structura_geo/debug_interior_air.py +58 -0
- structura_geo-0.1.0/src/structura_geo/dome_doors.py +234 -0
- structura_geo-0.1.0/src/structura_geo/droplet.py +162 -0
- structura_geo-0.1.0/src/structura_geo/envelope.py +457 -0
- structura_geo-0.1.0/src/structura_geo/fields.py +36 -0
- structura_geo-0.1.0/src/structura_geo/placement_report.py +156 -0
- structura_geo-0.1.0/src/structura_geo/region_report.py +108 -0
- structura_geo-0.1.0/src/structura_geo/slice_view.py +78 -0
- structura_geo-0.1.0/src/structura_geo/strip_snow_layer.py +29 -0
- structura_geo-0.1.0/src/structura_geo/structure_search.py +112 -0
- structura_geo-0.1.0/src/structura_geo/terrain_height_render.py +192 -0
- structura_geo-0.1.0/src/structura_geo/terrain_morphology.py +83 -0
- structura_geo-0.1.0/src/structura_geo/terrain_pod.py +1270 -0
- structura_geo-0.1.0/src/structura_geo/treatment.py +89 -0
- structura_geo-0.1.0/src/structura_geo.egg-info/PKG-INFO +55 -0
- structura_geo-0.1.0/src/structura_geo.egg-info/SOURCES.txt +23 -0
- structura_geo-0.1.0/src/structura_geo.egg-info/dependency_links.txt +1 -0
- structura_geo-0.1.0/src/structura_geo.egg-info/entry_points.txt +10 -0
- structura_geo-0.1.0/src/structura_geo.egg-info/requires.txt +5 -0
- structura_geo-0.1.0/src/structura_geo.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structura-geo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terrain reconstruction and terraforming around a captured Minecraft structure: envelope, per-column ground height, and the additive pod that completes a cut-out landscape
|
|
5
|
+
Project-URL: Repository, https://github.com/kirimba1024/structura-geo
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: structura-core<0.3,>=0.2.0
|
|
9
|
+
Requires-Dist: structura-render<0.3,>=0.2.0
|
|
10
|
+
Requires-Dist: numpy<3,>=1.26
|
|
11
|
+
Requires-Dist: scipy<2,>=1.13
|
|
12
|
+
Requires-Dist: matplotlib<4,>=3.9
|
|
13
|
+
|
|
14
|
+
# structura-geo
|
|
15
|
+
|
|
16
|
+
Terrain reconstruction and terraforming around a captured Minecraft structure.
|
|
17
|
+
|
|
18
|
+
A structure exported from a world is a rectangular *cut* out of a landscape:
|
|
19
|
+
the building comes with whatever ground the selection box happened to slice
|
|
20
|
+
through, and the cut faces are vertical walls. This package rebuilds the
|
|
21
|
+
landscape that was lost, additively — it never removes or replaces a single
|
|
22
|
+
authored block — so the result can be dropped into any terrain at any height
|
|
23
|
+
and still read as a place rather than as a box.
|
|
24
|
+
|
|
25
|
+
## What is in here
|
|
26
|
+
|
|
27
|
+
| Module | What it does |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| `envelope` | The structure's own envelope, interior air, worldgen clearance buffers, `base_y` detection, and `terrain_height_field` — the per-column reconstruction of the natural grade from the ground materials the builder walked on |
|
|
30
|
+
| `terrain_pod` | The pod: the organic body of terrain that completes the cut-out landscape, shaped against that per-column grade |
|
|
31
|
+
| `dome_doors` | Connects authored doors to a glass dome and clips the pod to it |
|
|
32
|
+
| `strip_snow_layer` | Removes the weather layer, which is not part of the build |
|
|
33
|
+
| `terrain_morphology` | Text/ASCII report of a structure's own terrain shape |
|
|
34
|
+
| `terrain_height_render` | The same signal as PNGs: a hillshaded height map, and its contours over a top-down render of the build |
|
|
35
|
+
| `region_report`, `slice_view`, `structure_search` | Inspection of structures without opening the game |
|
|
36
|
+
| `debug_interior_air` | Writes the interior-air mask out as a structure for eyeballing |
|
|
37
|
+
|
|
38
|
+
## The rules this package holds to
|
|
39
|
+
|
|
40
|
+
1. **Additive only.** No authored block is ever removed or replaced.
|
|
41
|
+
2. **Never above the build.** For every XZ column, added material may sit
|
|
42
|
+
beside or below the authored structure — never above it, and never on
|
|
43
|
+
top of ground the builder themselves declared.
|
|
44
|
+
3. **No face steeper than the angle of repose**, where that is achievable
|
|
45
|
+
additively — loose material does not stand vertically anywhere in nature,
|
|
46
|
+
and a vertical cut is what makes a placed structure read as a glitch.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
pip install structura-geo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Depends on `structura-core` (structure NBT) and `structura-render` (block
|
|
55
|
+
colours for the height-field overlay).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# structura-geo
|
|
2
|
+
|
|
3
|
+
Terrain reconstruction and terraforming around a captured Minecraft structure.
|
|
4
|
+
|
|
5
|
+
A structure exported from a world is a rectangular *cut* out of a landscape:
|
|
6
|
+
the building comes with whatever ground the selection box happened to slice
|
|
7
|
+
through, and the cut faces are vertical walls. This package rebuilds the
|
|
8
|
+
landscape that was lost, additively — it never removes or replaces a single
|
|
9
|
+
authored block — so the result can be dropped into any terrain at any height
|
|
10
|
+
and still read as a place rather than as a box.
|
|
11
|
+
|
|
12
|
+
## What is in here
|
|
13
|
+
|
|
14
|
+
| Module | What it does |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `envelope` | The structure's own envelope, interior air, worldgen clearance buffers, `base_y` detection, and `terrain_height_field` — the per-column reconstruction of the natural grade from the ground materials the builder walked on |
|
|
17
|
+
| `terrain_pod` | The pod: the organic body of terrain that completes the cut-out landscape, shaped against that per-column grade |
|
|
18
|
+
| `dome_doors` | Connects authored doors to a glass dome and clips the pod to it |
|
|
19
|
+
| `strip_snow_layer` | Removes the weather layer, which is not part of the build |
|
|
20
|
+
| `terrain_morphology` | Text/ASCII report of a structure's own terrain shape |
|
|
21
|
+
| `terrain_height_render` | The same signal as PNGs: a hillshaded height map, and its contours over a top-down render of the build |
|
|
22
|
+
| `region_report`, `slice_view`, `structure_search` | Inspection of structures without opening the game |
|
|
23
|
+
| `debug_interior_air` | Writes the interior-air mask out as a structure for eyeballing |
|
|
24
|
+
|
|
25
|
+
## The rules this package holds to
|
|
26
|
+
|
|
27
|
+
1. **Additive only.** No authored block is ever removed or replaced.
|
|
28
|
+
2. **Never above the build.** For every XZ column, added material may sit
|
|
29
|
+
beside or below the authored structure — never above it, and never on
|
|
30
|
+
top of ground the builder themselves declared.
|
|
31
|
+
3. **No face steeper than the angle of repose**, where that is achievable
|
|
32
|
+
additively — loose material does not stand vertically anywhere in nature,
|
|
33
|
+
and a vertical cut is what makes a placed structure read as a glitch.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
pip install structura-geo
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Depends on `structura-core` (structure NBT) and `structura-render` (block
|
|
42
|
+
colours for the height-field overlay).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "structura-geo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terrain reconstruction and terraforming around a captured Minecraft structure: envelope, per-column ground height, and the additive pod that completes a cut-out landscape"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"structura-core>=0.2.0,<0.3",
|
|
13
|
+
"structura-render>=0.2.0,<0.3",
|
|
14
|
+
"numpy>=1.26,<3",
|
|
15
|
+
"scipy>=1.13,<2",
|
|
16
|
+
"matplotlib>=3.9,<4",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Repository = "https://github.com/kirimba1024/structura-geo"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
structura-envelope = "structura_geo.envelope:main"
|
|
24
|
+
structura-terrain-pod = "structura_geo.terrain_pod:main"
|
|
25
|
+
structura-dome-doors = "structura_geo.dome_doors:main"
|
|
26
|
+
structura-strip-snow-layer = "structura_geo.strip_snow_layer:main"
|
|
27
|
+
structura-terrain-morphology = "structura_geo.terrain_morphology:main"
|
|
28
|
+
structura-terrain-height-render = "structura_geo.terrain_height_render:main"
|
|
29
|
+
structura-region-report = "structura_geo.region_report:main"
|
|
30
|
+
structura-slice-view = "structura_geo.slice_view:main"
|
|
31
|
+
structura-structure-search = "structura_geo.structure_search:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render the interior_air cavern alone as solid lapis blocks, for visual debugging."""
|
|
3
|
+
import argparse
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .envelope import adaptive_cavern_radius, compute_masks
|
|
9
|
+
from structura_core import AIR_NAMES, Structure, save_structure
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BlankSource:
|
|
13
|
+
def __init__(self, src):
|
|
14
|
+
self.data_version = src.data_version
|
|
15
|
+
self.palette_raw = []
|
|
16
|
+
self.palette = []
|
|
17
|
+
self.present = {}
|
|
18
|
+
self.block_nbt = {}
|
|
19
|
+
self.entities = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main():
|
|
23
|
+
parser = argparse.ArgumentParser()
|
|
24
|
+
parser.add_argument("src")
|
|
25
|
+
parser.add_argument("dst")
|
|
26
|
+
parser.add_argument("--envelope-radius", type=float, default=1.5)
|
|
27
|
+
parser.add_argument("--block", default="minecraft:lapis_block")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--target", choices=("interior_air", "envelope"), default="interior_air",
|
|
30
|
+
)
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
src = Structure(args.src)
|
|
34
|
+
solid = np.zeros(src.size, dtype=bool)
|
|
35
|
+
for pos, idx in src.present.items():
|
|
36
|
+
if src.palette[idx] not in AIR_NAMES:
|
|
37
|
+
solid[pos] = True
|
|
38
|
+
|
|
39
|
+
cavern_radius = adaptive_cavern_radius(solid)
|
|
40
|
+
margin = math.ceil(args.envelope_radius + cavern_radius) + 1
|
|
41
|
+
solid = np.pad(solid, margin)
|
|
42
|
+
base_y = int(np.flatnonzero(solid.any(axis=(0, 2)))[0])
|
|
43
|
+
|
|
44
|
+
envelope, _, interior_air, _, _ = compute_masks(
|
|
45
|
+
solid, args.envelope_radius, 0.0, 0.0, base_y,
|
|
46
|
+
)
|
|
47
|
+
target = envelope if args.target == "envelope" else interior_air
|
|
48
|
+
|
|
49
|
+
shift = (margin, 0, margin)
|
|
50
|
+
block_count = save_structure(
|
|
51
|
+
BlankSource(src), args.dst, target.shape, shift,
|
|
52
|
+
((pos, args.block) for pos in np.argwhere(target)),
|
|
53
|
+
)
|
|
54
|
+
print(f"size={target.shape} lapis_blocks={block_count}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Cut sealed door sockets into a glass dome's own contour. Closed doors
|
|
3
|
+
block water on their own, so the structure is a self-sufficient, sealed
|
|
4
|
+
underwater room with no dependency on anything else ever being built."""
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from structura_core import AIR_NAMES, Structure, save_structure
|
|
12
|
+
|
|
13
|
+
AXIS = {"west": (0, -1), "east": (0, 1), "north": (2, -1), "south": (2, 1)}
|
|
14
|
+
HINGE = {"west": "left", "east": "left", "north": "left", "south": "left"}
|
|
15
|
+
FRAME_SEARCH_RADIUS = 8
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_wall(src, center, axis, sign, size, start=None):
|
|
19
|
+
pos = center[axis] if start is None else start
|
|
20
|
+
while 0 <= pos < size[axis]:
|
|
21
|
+
cell = list(center)
|
|
22
|
+
cell[axis] = pos
|
|
23
|
+
if tuple(cell) not in src.present:
|
|
24
|
+
return pos
|
|
25
|
+
pos += sign
|
|
26
|
+
return pos
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def find_dome_exit(in_shell, center, axis, sign, size):
|
|
30
|
+
"""Where a ray from center first leaves the glass shell -- not where it
|
|
31
|
+
leaves *any* solid block, which is find_wall()'s job and would walk
|
|
32
|
+
straight through the pod's own seabed sand beyond the dome without
|
|
33
|
+
stopping. entered guards against a ray that starts outside the shell
|
|
34
|
+
(center misestimated, or a very thin/holed dome) returning its very
|
|
35
|
+
first step."""
|
|
36
|
+
pos = center[axis]
|
|
37
|
+
entered = False
|
|
38
|
+
while 0 <= pos < size[axis]:
|
|
39
|
+
cell = list(center)
|
|
40
|
+
cell[axis] = pos
|
|
41
|
+
if in_shell(tuple(cell)):
|
|
42
|
+
entered = True
|
|
43
|
+
elif entered:
|
|
44
|
+
return pos
|
|
45
|
+
pos += sign
|
|
46
|
+
return pos
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def shifted_lookup(mask, shift):
|
|
50
|
+
def lookup(pos):
|
|
51
|
+
idx = tuple(p - s for p, s in zip(pos, shift))
|
|
52
|
+
return all(0 <= v < limit for v, limit in zip(idx, mask.shape)) and bool(mask[idx])
|
|
53
|
+
return lookup
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_solid(src, pos):
|
|
57
|
+
index = src.present.get(tuple(pos))
|
|
58
|
+
return index is not None and src.palette[index] not in AIR_NAMES
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def frame_positions(door_pos, perp):
|
|
62
|
+
up = tuple(v + (1 if i == 1 else 0) for i, v in enumerate(door_pos))
|
|
63
|
+
sill = tuple(v - (1 if i == 1 else 0) for i, v in enumerate(door_pos))
|
|
64
|
+
sides = []
|
|
65
|
+
for level in (door_pos, up):
|
|
66
|
+
for d in (-1, 1):
|
|
67
|
+
side = list(level)
|
|
68
|
+
side[perp] += d
|
|
69
|
+
sides.append(tuple(side))
|
|
70
|
+
return up, sill, sides
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_wall_at_base(src, anchor, axis, sign, size, perp, start=None):
|
|
74
|
+
for radius in range(FRAME_SEARCH_RADIUS + 1):
|
|
75
|
+
for dp in ({0} if radius == 0 else {-radius, radius}):
|
|
76
|
+
candidate = list(anchor)
|
|
77
|
+
candidate[perp] += dp
|
|
78
|
+
if not 0 <= candidate[perp] < size[perp]:
|
|
79
|
+
continue
|
|
80
|
+
wall = find_wall(src, candidate, axis, sign, size, start)
|
|
81
|
+
door_pos = list(candidate)
|
|
82
|
+
door_pos[axis] = wall - sign
|
|
83
|
+
return tuple(door_pos)
|
|
84
|
+
return tuple(anchor)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def tunnel_levels(anchor, dome_exit, door_axis_pos, axis, sign):
|
|
88
|
+
"""Every axis position between the dome's own glass shell and the door,
|
|
89
|
+
inclusive, regardless of which is further out -- a door can legitimately
|
|
90
|
+
land at or behind the dome exit (no pod material past the glass in that
|
|
91
|
+
direction at all, so find_wall_at_base's wall-minus-sign lands right at
|
|
92
|
+
the shell), not always past it. A fixed dome_exit -> door_axis_pos walk
|
|
93
|
+
stepping by `sign` never terminates when the door isn't actually further
|
|
94
|
+
out in that direction."""
|
|
95
|
+
lo, hi = sorted((dome_exit, door_axis_pos))
|
|
96
|
+
for pos in range(lo, hi + 1):
|
|
97
|
+
level = list(anchor)
|
|
98
|
+
level[axis] = pos
|
|
99
|
+
yield tuple(level)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def door_state(facing):
|
|
103
|
+
lower = f"minecraft:oak_door[half=lower,facing={facing},hinge={HINGE[facing]},open=false,powered=false]"
|
|
104
|
+
upper = f"minecraft:oak_door[half=upper,facing={facing},hinge={HINGE[facing]},open=false,powered=false]"
|
|
105
|
+
return lower, upper
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def auto_center(pod_masks):
|
|
109
|
+
"""Any point at floor height, roughly inside the footprint, works --
|
|
110
|
+
doors are cut by shooting rays out to the shell, not by a meaningful
|
|
111
|
+
building center. base_y/raw_footprint are already computed by
|
|
112
|
+
terrain_pod for this exact structure, so no per-asset input is needed."""
|
|
113
|
+
masks = np.load(pod_masks)
|
|
114
|
+
base_y = int(masks["base_y"])
|
|
115
|
+
footprint = masks["raw_footprint"]
|
|
116
|
+
xs, zs = np.nonzero(footprint)
|
|
117
|
+
return int(round(xs.mean())), base_y, int(round(zs.mean()))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main():
|
|
121
|
+
parser = argparse.ArgumentParser()
|
|
122
|
+
parser.add_argument("src")
|
|
123
|
+
parser.add_argument("dst")
|
|
124
|
+
parser.add_argument("--center-x", type=int)
|
|
125
|
+
parser.add_argument("--center-y", type=int)
|
|
126
|
+
parser.add_argument("--center-z", type=int)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--pod-masks",
|
|
129
|
+
help="terrain_pod's --masks npz; derives the center automatically "
|
|
130
|
+
"(base_y + footprint centroid) when --center-x/y/z are omitted",
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--envelope-masks",
|
|
134
|
+
help="envelope.py's --masks npz for this same structure; its "
|
|
135
|
+
"glass_dome tells a door where the dome's own glass shell "
|
|
136
|
+
"actually is, so the door -- and a connecting glass tunnel -- "
|
|
137
|
+
"land there instead of wherever terrain_pod's seabed pod "
|
|
138
|
+
"happens to end, possibly many blocks further out. Without "
|
|
139
|
+
"it, doors are cut at the first solid-to-air boundary same as "
|
|
140
|
+
"before, which is only correct when there's no pod material "
|
|
141
|
+
"past the dome to begin with",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--directions", default="north,south,east,west",
|
|
145
|
+
help="comma-separated subset of north,south,east,west",
|
|
146
|
+
)
|
|
147
|
+
args = parser.parse_args()
|
|
148
|
+
|
|
149
|
+
directions = [d.strip() for d in args.directions.split(",") if d.strip()]
|
|
150
|
+
for d in directions:
|
|
151
|
+
if d not in AXIS:
|
|
152
|
+
parser.error(f"unknown direction {d!r}; choose from {sorted(AXIS)}")
|
|
153
|
+
|
|
154
|
+
manual = (args.center_x, args.center_y, args.center_z)
|
|
155
|
+
if all(v is not None for v in manual):
|
|
156
|
+
center = manual
|
|
157
|
+
elif any(v is not None for v in manual):
|
|
158
|
+
parser.error("--center-x/y/z must be given together, or not at all")
|
|
159
|
+
elif args.pod_masks:
|
|
160
|
+
center = auto_center(args.pod_masks)
|
|
161
|
+
else:
|
|
162
|
+
parser.error("need --center-x/y/z or --pod-masks")
|
|
163
|
+
|
|
164
|
+
src = Structure(args.src)
|
|
165
|
+
size = src.size
|
|
166
|
+
|
|
167
|
+
in_shell = None
|
|
168
|
+
if args.envelope_masks:
|
|
169
|
+
env = np.load(args.envelope_masks)
|
|
170
|
+
if "glass_dome" in env and env["glass_dome"].any():
|
|
171
|
+
shift = (0, 0, 0)
|
|
172
|
+
if args.pod_masks:
|
|
173
|
+
shift = tuple(int(v) for v in np.load(args.pod_masks)["shift"])
|
|
174
|
+
in_shell = shifted_lookup(env["glass_dome"], shift)
|
|
175
|
+
|
|
176
|
+
def in_bounds(pos):
|
|
177
|
+
return all(0 <= v < limit for v, limit in zip(pos, size))
|
|
178
|
+
|
|
179
|
+
replacements = []
|
|
180
|
+
doors = []
|
|
181
|
+
for facing in directions:
|
|
182
|
+
axis, sign = AXIS[facing]
|
|
183
|
+
perp = 2 - axis
|
|
184
|
+
|
|
185
|
+
if in_shell is None:
|
|
186
|
+
door_pos = find_wall_at_base(src, center, axis, sign, size, perp)
|
|
187
|
+
upper_pos, sill, sides = frame_positions(door_pos, perp)
|
|
188
|
+
patched = 0
|
|
189
|
+
for pos in (sill, *sides):
|
|
190
|
+
if in_bounds(pos) and not is_solid(src, pos):
|
|
191
|
+
replacements.append((pos, "minecraft:glass"))
|
|
192
|
+
patched += 1
|
|
193
|
+
if patched:
|
|
194
|
+
print(f"forced glass frame around {facing} door: {patched} block(s)", file=sys.stderr)
|
|
195
|
+
else:
|
|
196
|
+
dome_exit = find_dome_exit(in_shell, center, axis, sign, size)
|
|
197
|
+
exit_anchor = list(center)
|
|
198
|
+
exit_anchor[axis] = dome_exit
|
|
199
|
+
door_pos = find_wall_at_base(
|
|
200
|
+
src, tuple(exit_anchor), axis, sign, size, perp, start=dome_exit,
|
|
201
|
+
)
|
|
202
|
+
upper_pos, sill, sides = frame_positions(door_pos, perp)
|
|
203
|
+
cleared = glazed = 0
|
|
204
|
+
for level in tunnel_levels(door_pos, dome_exit, door_pos[axis], axis, sign):
|
|
205
|
+
level_up, level_sill, level_sides = frame_positions(level, perp)
|
|
206
|
+
frame = (level_sill, *level_sides) if level == door_pos else level_sides
|
|
207
|
+
if level != door_pos:
|
|
208
|
+
for pos in (level, level_up):
|
|
209
|
+
if in_bounds(pos) and is_solid(src, pos):
|
|
210
|
+
replacements.append((pos, "minecraft:air"))
|
|
211
|
+
cleared += 1
|
|
212
|
+
for pos in frame:
|
|
213
|
+
if in_bounds(pos):
|
|
214
|
+
replacements.append((pos, "minecraft:glass"))
|
|
215
|
+
glazed += 1
|
|
216
|
+
if cleared or glazed:
|
|
217
|
+
print(
|
|
218
|
+
f"{facing} passage: cleared {cleared}, glazed {glazed} "
|
|
219
|
+
f"block(s) from the dome shell out to the door",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
lower, upper = door_state(facing)
|
|
224
|
+
replacements.append((door_pos, lower))
|
|
225
|
+
replacements.append((upper_pos, upper))
|
|
226
|
+
doors.append((door_pos, facing))
|
|
227
|
+
|
|
228
|
+
save_structure(src, args.dst, size, replacements=replacements)
|
|
229
|
+
for pos, facing in doors:
|
|
230
|
+
print(f"door {facing}: {pos}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy import ndimage
|
|
4
|
+
|
|
5
|
+
from .fields import NO_CEILING as NO_LID, patch_noise
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SAG = 4.0
|
|
9
|
+
SIGMA = 3.0
|
|
10
|
+
THRESHOLD = 0.25
|
|
11
|
+
WOBBLE = 0.30
|
|
12
|
+
CELL = 9
|
|
13
|
+
SALT = 7
|
|
14
|
+
HANG = 0.35
|
|
15
|
+
SPREAD = 1.3
|
|
16
|
+
LEAN = 0.6
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def lateral_reach(thickness, spread=SPREAD, sigma=SIGMA):
|
|
20
|
+
return int(np.ceil(thickness * spread + 3.0 * sigma))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def vertical_reach(inradius, hang=HANG, sigma=SIGMA):
|
|
24
|
+
return int(np.ceil(hang * inradius + 6.0 * sigma)) + 8
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sag_kernel(length):
|
|
28
|
+
reach = max(1, int(4 * length))
|
|
29
|
+
weights = np.exp(-np.arange(reach) / max(length, 1e-6))
|
|
30
|
+
return np.concatenate([weights[::-1], np.zeros(reach - 1)]) / weights.max()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ground_lid(solid, interior_air):
|
|
34
|
+
height = solid.shape[1]
|
|
35
|
+
roofed = np.where(
|
|
36
|
+
interior_air.any(axis=1), np.argmax(interior_air, axis=1), NO_LID,
|
|
37
|
+
)
|
|
38
|
+
rows = np.arange(height)[None, :, None]
|
|
39
|
+
ground = solid & ((roofed[:, None, :] == NO_LID) | (rows < roofed[:, None, :]))
|
|
40
|
+
top = np.where(
|
|
41
|
+
ground.any(axis=1), height - 1 - np.argmax(ground[:, ::-1, :], axis=1), -1,
|
|
42
|
+
)
|
|
43
|
+
known = top >= 0
|
|
44
|
+
if not known.any():
|
|
45
|
+
return np.full(top.shape, NO_LID)
|
|
46
|
+
_, nearest = ndimage.distance_transform_edt(~known, return_indices=True)
|
|
47
|
+
return np.where(known, top, top[nearest[0], nearest[1]])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def downhill(solid):
|
|
51
|
+
footprint = solid.any(axis=1)
|
|
52
|
+
if footprint.sum() < 16:
|
|
53
|
+
return None
|
|
54
|
+
top = np.where(
|
|
55
|
+
footprint, solid.shape[1] - 1 - np.argmax(solid[:, ::-1, :], axis=1), np.nan,
|
|
56
|
+
).astype(np.float64)
|
|
57
|
+
filled = np.nan_to_num(top, nan=float(np.nanmean(top)))
|
|
58
|
+
smoothed = ndimage.gaussian_filter(filled, 6.0)
|
|
59
|
+
gradient_x, gradient_z = np.gradient(smoothed)
|
|
60
|
+
vector = np.array([gradient_x[footprint].mean(), gradient_z[footprint].mean()])
|
|
61
|
+
length = float(np.hypot(*vector))
|
|
62
|
+
if length < 1e-3:
|
|
63
|
+
return None
|
|
64
|
+
return -vector / length
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def flow_target(solid, hang, spread, lean=0.0):
|
|
68
|
+
footprint = solid.any(axis=1)
|
|
69
|
+
if not footprint.any() or (hang <= 0 and spread <= 0):
|
|
70
|
+
return np.zeros_like(solid)
|
|
71
|
+
height = solid.shape[1]
|
|
72
|
+
rows = np.arange(height)[None, :, None]
|
|
73
|
+
floor = np.where(footprint, np.argmax(solid, axis=1), 0)
|
|
74
|
+
reach = ndimage.distance_transform_edt(footprint)
|
|
75
|
+
if lean > 0:
|
|
76
|
+
direction = downhill(solid)
|
|
77
|
+
if direction is not None:
|
|
78
|
+
rows_xz, cols_xz = np.indices(footprint.shape)
|
|
79
|
+
centred_x = rows_xz - rows_xz[footprint].mean()
|
|
80
|
+
centred_z = cols_xz - cols_xz[footprint].mean()
|
|
81
|
+
along = centred_x * direction[0] + centred_z * direction[1]
|
|
82
|
+
extent = max(float(np.abs(along[footprint]).max()), 1.0)
|
|
83
|
+
reach = reach * (1.0 + lean * along / extent)
|
|
84
|
+
reach = np.maximum(reach, 0.0)
|
|
85
|
+
sunk = np.maximum(floor - hang * reach, 0.0)
|
|
86
|
+
target = (rows >= sunk[:, None, :]) & (rows < floor[:, None, :]) & footprint[:, None, :]
|
|
87
|
+
if spread > 0:
|
|
88
|
+
thickness = solid.sum(axis=1).astype(np.float64)
|
|
89
|
+
top = np.where(footprint, height - 1 - np.argmax(solid[:, ::-1, :], axis=1), 0)
|
|
90
|
+
outward, source = ndimage.distance_transform_edt(
|
|
91
|
+
~footprint, return_indices=True,
|
|
92
|
+
)
|
|
93
|
+
near = (source[0], source[1])
|
|
94
|
+
run = np.maximum(thickness[near] * spread, 1e-6)
|
|
95
|
+
taper = np.clip(1.0 - outward / run, 0.0, 1.0)
|
|
96
|
+
crest = top[near].astype(np.float64)
|
|
97
|
+
base = sunk[near]
|
|
98
|
+
lip = crest - (crest - base) * 0.5
|
|
99
|
+
apron = (
|
|
100
|
+
~footprint[:, None, :]
|
|
101
|
+
& (taper[:, None, :] > 0)
|
|
102
|
+
& (rows <= (lip * taper + base * (1 - taper))[:, None, :])
|
|
103
|
+
& (rows >= (base - hang * 0.0)[:, None, :])
|
|
104
|
+
)
|
|
105
|
+
target |= apron
|
|
106
|
+
return target
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def droplet(solid, interior_air, reserved, sag=SAG, sigma=SIGMA,
|
|
110
|
+
threshold=THRESHOLD, wobble=WOBBLE, cell=CELL, salt=SALT,
|
|
111
|
+
hang=HANG, spread=SPREAD, lean=LEAN):
|
|
112
|
+
shape = solid | flow_target(solid, hang, spread, lean)
|
|
113
|
+
field = ndimage.convolve1d(
|
|
114
|
+
shape.astype(np.float32), sag_kernel(sag), axis=1, mode="constant",
|
|
115
|
+
)
|
|
116
|
+
field = ndimage.gaussian_filter(field, sigma)
|
|
117
|
+
field /= float(sag_kernel(sag).sum())
|
|
118
|
+
if wobble > 0:
|
|
119
|
+
corner = np.argwhere(solid.any(axis=1))
|
|
120
|
+
anchor = -corner.min(axis=0) if len(corner) else (0, 0)
|
|
121
|
+
noise = patch_noise(
|
|
122
|
+
(solid.shape[0], solid.shape[2]), cell, salt, origin=anchor,
|
|
123
|
+
) - 0.5
|
|
124
|
+
field *= (1.0 + wobble * noise).astype(np.float32)[:, None, :]
|
|
125
|
+
blob = field >= threshold
|
|
126
|
+
rows = np.arange(solid.shape[1])[None, :, None]
|
|
127
|
+
lid = ground_lid(solid, interior_air)
|
|
128
|
+
blob &= rows <= lid[:, None, :]
|
|
129
|
+
added = blob & ~solid & ~reserved
|
|
130
|
+
sealed = ndimage.binary_fill_holes(solid | added) & ~solid & ~reserved
|
|
131
|
+
sealed &= rows <= lid[:, None, :]
|
|
132
|
+
added = added | sealed
|
|
133
|
+
body = solid | added
|
|
134
|
+
labels, count = ndimage.label(body)
|
|
135
|
+
if count > 1:
|
|
136
|
+
keep = 1 + int(np.argmax(ndimage.sum(body, labels, range(1, count + 1))))
|
|
137
|
+
added &= labels == keep
|
|
138
|
+
return added
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def droplet_for_budget(solid, interior_air, reserved, budget=0.4, steps=9,
|
|
142
|
+
low=0.02, high=0.95, **kwargs):
|
|
143
|
+
if "threshold" in kwargs:
|
|
144
|
+
raise TypeError(
|
|
145
|
+
"droplet_for_budget solves for the threshold; passing one asks it "
|
|
146
|
+
"to hit a budget and use a fixed isolevel at the same time. Call "
|
|
147
|
+
"droplet() directly if the isolevel is what you want to set.",
|
|
148
|
+
)
|
|
149
|
+
mass = max(int(solid.sum()), 1)
|
|
150
|
+
best = None
|
|
151
|
+
for _ in range(steps):
|
|
152
|
+
middle = (low + high) / 2.0
|
|
153
|
+
candidate = droplet(solid, interior_air, reserved, threshold=middle, **kwargs)
|
|
154
|
+
if candidate.sum() / mass > budget:
|
|
155
|
+
low = middle
|
|
156
|
+
else:
|
|
157
|
+
high = middle
|
|
158
|
+
if best is None or candidate.sum() > best.sum():
|
|
159
|
+
best = candidate
|
|
160
|
+
if best is None:
|
|
161
|
+
best = np.zeros_like(solid)
|
|
162
|
+
return best
|