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.
Files changed (25) hide show
  1. structura_geo-0.1.0/PKG-INFO +55 -0
  2. structura_geo-0.1.0/README.md +42 -0
  3. structura_geo-0.1.0/pyproject.toml +34 -0
  4. structura_geo-0.1.0/setup.cfg +4 -0
  5. structura_geo-0.1.0/src/structura_geo/__init__.py +0 -0
  6. structura_geo-0.1.0/src/structura_geo/debug_interior_air.py +58 -0
  7. structura_geo-0.1.0/src/structura_geo/dome_doors.py +234 -0
  8. structura_geo-0.1.0/src/structura_geo/droplet.py +162 -0
  9. structura_geo-0.1.0/src/structura_geo/envelope.py +457 -0
  10. structura_geo-0.1.0/src/structura_geo/fields.py +36 -0
  11. structura_geo-0.1.0/src/structura_geo/placement_report.py +156 -0
  12. structura_geo-0.1.0/src/structura_geo/region_report.py +108 -0
  13. structura_geo-0.1.0/src/structura_geo/slice_view.py +78 -0
  14. structura_geo-0.1.0/src/structura_geo/strip_snow_layer.py +29 -0
  15. structura_geo-0.1.0/src/structura_geo/structure_search.py +112 -0
  16. structura_geo-0.1.0/src/structura_geo/terrain_height_render.py +192 -0
  17. structura_geo-0.1.0/src/structura_geo/terrain_morphology.py +83 -0
  18. structura_geo-0.1.0/src/structura_geo/terrain_pod.py +1270 -0
  19. structura_geo-0.1.0/src/structura_geo/treatment.py +89 -0
  20. structura_geo-0.1.0/src/structura_geo.egg-info/PKG-INFO +55 -0
  21. structura_geo-0.1.0/src/structura_geo.egg-info/SOURCES.txt +23 -0
  22. structura_geo-0.1.0/src/structura_geo.egg-info/dependency_links.txt +1 -0
  23. structura_geo-0.1.0/src/structura_geo.egg-info/entry_points.txt +10 -0
  24. structura_geo-0.1.0/src/structura_geo.egg-info/requires.txt +5 -0
  25. 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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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