granulaire 0.2.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- granulaire/__init__.py +21 -0
- granulaire/__main__.py +3 -0
- granulaire/cli.py +167 -0
- granulaire/generator.py +378 -0
- granulaire/measure.py +49 -0
- granulaire/moisture.py +95 -0
- granulaire/presets.py +744 -0
- granulaire/render.py +422 -0
- granulaire/shape.py +329 -0
- granulaire/usd_export.py +303 -0
- granulaire-0.2.0b3.dist-info/METADATA +541 -0
- granulaire-0.2.0b3.dist-info/RECORD +16 -0
- granulaire-0.2.0b3.dist-info/WHEEL +5 -0
- granulaire-0.2.0b3.dist-info/entry_points.txt +2 -0
- granulaire-0.2.0b3.dist-info/licenses/LICENSE +21 -0
- granulaire-0.2.0b3.dist-info/top_level.txt +1 -0
granulaire/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Granulaire: realistic granular assets generator for Physical AI, exporting to OpenUSD."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("granulaire")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without installation
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
from .generator import Scene, Variations, generate_scene, write_outputs # noqa: E402
|
|
11
|
+
from .presets import CATEGORIES, PRESETS # noqa: E402
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CATEGORIES",
|
|
15
|
+
"PRESETS",
|
|
16
|
+
"Scene",
|
|
17
|
+
"Variations",
|
|
18
|
+
"__version__",
|
|
19
|
+
"generate_scene",
|
|
20
|
+
"write_outputs",
|
|
21
|
+
]
|
granulaire/__main__.py
ADDED
granulaire/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
granulaire list
|
|
4
|
+
granulaire generate --preset beach_sand [--shapes 100] [--copies 5] [--no-shuffle] [--seed 1]
|
|
5
|
+
[--no-size] [--no-shape] [--no-roundness] [--no-mineralogy]
|
|
6
|
+
[--no-orientation] [--moisture] [--moisture-weights 40,30,20,10]
|
|
7
|
+
granulaire render output/beach_sand output/crushed_gravel
|
|
8
|
+
granulaire render-moisture output/beach_pebbles output/crushed_gravel output/beach_sand
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import __version__, moisture
|
|
18
|
+
from .generator import Variations, generate_scene, moisture_applies, summarize, write_outputs
|
|
19
|
+
from .presets import CATEGORIES, PRESETS
|
|
20
|
+
|
|
21
|
+
VARIATION_HELP = {
|
|
22
|
+
"size": "diameter from the grading curve (off: d50 for all)",
|
|
23
|
+
"shape": "elongation, flatness and irregularity drawn (off: mean values)",
|
|
24
|
+
"roundness": "facets and edge rounding drawn (off: median values)",
|
|
25
|
+
"mineralogy": "mineral and tint drawn (off: dominant mineral)",
|
|
26
|
+
"orientation": "random orientation (off: all aligned)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cmd_list(_args) -> int:
|
|
31
|
+
for c in CATEGORIES:
|
|
32
|
+
print(c)
|
|
33
|
+
for k, p in PRESETS.items():
|
|
34
|
+
if p["category"] == c:
|
|
35
|
+
notes = [] if p.get("moisture", True) else ["no moisture"]
|
|
36
|
+
if p.get("deformable"):
|
|
37
|
+
notes.append("deformable")
|
|
38
|
+
note = f" ({', '.join(notes)})" if notes else ""
|
|
39
|
+
print(f" {k:<18} {p['label']:<30} ~{2.0 ** (-p['phi_mean']):.3g} mm{note}")
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _positive_int(text: str) -> int:
|
|
44
|
+
value = int(text)
|
|
45
|
+
if value < 1:
|
|
46
|
+
raise argparse.ArgumentTypeError(f"must be at least 1, got {value}")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cmd_generate(args) -> int:
|
|
51
|
+
var = Variations(**{k: getattr(args, k) for k in VARIATION_HELP}, moisture=args.moisture)
|
|
52
|
+
if args.moisture and not moisture_applies(args.preset):
|
|
53
|
+
print(f"moisture ignored: not applicable to '{PRESETS[args.preset]['label']}'")
|
|
54
|
+
scene = generate_scene(
|
|
55
|
+
args.preset,
|
|
56
|
+
args.shapes,
|
|
57
|
+
args.copies,
|
|
58
|
+
args.seed,
|
|
59
|
+
var,
|
|
60
|
+
args.moisture_weights,
|
|
61
|
+
args.deformable,
|
|
62
|
+
args.shuffle,
|
|
63
|
+
)
|
|
64
|
+
out = write_outputs(scene, args.out)
|
|
65
|
+
summary = summarize(scene)
|
|
66
|
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
67
|
+
print(f"written to {out}")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_render(args) -> int:
|
|
72
|
+
from . import render
|
|
73
|
+
|
|
74
|
+
folders = [Path(f) for f in args.folders]
|
|
75
|
+
for folder in folders:
|
|
76
|
+
print(render.shape_sheet(folder))
|
|
77
|
+
print(render.mix_preview(folder))
|
|
78
|
+
print(render.metrics_chart(folders[:6], folders[0].parent / "metrics.png"))
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cmd_render_moisture(args) -> int:
|
|
83
|
+
from . import render
|
|
84
|
+
|
|
85
|
+
folders = [Path(f) for f in args.folders]
|
|
86
|
+
print(render.moisture_states(folders, folders[0].parent / "moisture_states.png"))
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cmd_render_gallery(args) -> int:
|
|
91
|
+
from . import render
|
|
92
|
+
|
|
93
|
+
folders = [Path(f) for f in args.folders]
|
|
94
|
+
out = Path(args.output) if args.output else folders[0].parent / "moisture_gallery.png"
|
|
95
|
+
print(render.moisture_gallery(folders, out, grains=args.grains))
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
100
|
+
ap = argparse.ArgumentParser(
|
|
101
|
+
prog="granulaire",
|
|
102
|
+
description="Realistic granular assets generator for Physical AI, exporting to OpenUSD.",
|
|
103
|
+
)
|
|
104
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
105
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
106
|
+
|
|
107
|
+
sub.add_parser("list", help="list presets by category").set_defaults(func=_cmd_list)
|
|
108
|
+
|
|
109
|
+
g = sub.add_parser("generate", help="generate a grain library and an OpenUSD scene")
|
|
110
|
+
g.add_argument("--preset", default="beach_sand", choices=sorted(PRESETS))
|
|
111
|
+
g.add_argument(
|
|
112
|
+
"--shapes", type=_positive_int, default=100, help="number of distinct shapes (default: 100)"
|
|
113
|
+
)
|
|
114
|
+
g.add_argument("--copies", type=_positive_int, default=5, help="copies of each shape (default: 5)")
|
|
115
|
+
g.add_argument(
|
|
116
|
+
"--shuffle",
|
|
117
|
+
action=argparse.BooleanOptionalAction,
|
|
118
|
+
default=True,
|
|
119
|
+
help="mix the copies in random order (--no-shuffle: copies of each shape side by side)",
|
|
120
|
+
)
|
|
121
|
+
g.add_argument("--seed", type=int, default=1)
|
|
122
|
+
g.add_argument("--out", default="output", help="output root folder")
|
|
123
|
+
for name, text in VARIATION_HELP.items():
|
|
124
|
+
g.add_argument(f"--{name}", action=argparse.BooleanOptionalAction, default=True, help=text)
|
|
125
|
+
g.add_argument(
|
|
126
|
+
"--moisture",
|
|
127
|
+
action=argparse.BooleanOptionalAction,
|
|
128
|
+
default=False,
|
|
129
|
+
help='"moisture" variant set with a state drawn per grain',
|
|
130
|
+
)
|
|
131
|
+
g.add_argument(
|
|
132
|
+
"--moisture-weights",
|
|
133
|
+
default=moisture.DEFAULT_WEIGHTS,
|
|
134
|
+
help="share of grains per state: dry,damp,wet,saturated",
|
|
135
|
+
)
|
|
136
|
+
g.add_argument(
|
|
137
|
+
"--deformable",
|
|
138
|
+
action=argparse.BooleanOptionalAction,
|
|
139
|
+
default=True,
|
|
140
|
+
help="deformable presets (packing peanuts): TetMesh + NVIDIA OmniPhysics deformable schema, "
|
|
141
|
+
"simulated only by Omniverse / Isaac Sim (off: rigid bodies for any engine)",
|
|
142
|
+
)
|
|
143
|
+
g.set_defaults(func=_cmd_generate)
|
|
144
|
+
|
|
145
|
+
r = sub.add_parser("render", help="shape sheet, mixed preview and metrics chart (needs [render])")
|
|
146
|
+
r.add_argument("folders", nargs="+", help="output folders written by 'generate'")
|
|
147
|
+
r.set_defaults(func=_cmd_render)
|
|
148
|
+
|
|
149
|
+
rm = sub.add_parser("render-moisture", help="one grain per preset in the four moisture states")
|
|
150
|
+
rm.add_argument("folders", nargs="+", help="output folders generated with --moisture")
|
|
151
|
+
rm.set_defaults(func=_cmd_render_moisture)
|
|
152
|
+
|
|
153
|
+
rg = sub.add_parser("render-gallery", help="several grains per preset in every moisture state")
|
|
154
|
+
rg.add_argument("folders", nargs="+", help="output folders written by 'generate'")
|
|
155
|
+
rg.add_argument("--grains", type=int, default=5, help="grains shown per preset and state")
|
|
156
|
+
rg.add_argument("--output", help="image path (default: <root>/moisture_gallery.png)")
|
|
157
|
+
rg.set_defaults(func=_cmd_render_gallery)
|
|
158
|
+
return ap
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def main(argv: list[str] | None = None) -> int:
|
|
162
|
+
args = build_parser().parse_args(argv)
|
|
163
|
+
return args.func(args)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
raise SystemExit(main())
|
granulaire/generator.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Scene generation: grain library, mixing, moisture states, and file outputs.
|
|
2
|
+
|
|
3
|
+
The library API is separate from the command line so it can be used from
|
|
4
|
+
Python and tested directly:
|
|
5
|
+
|
|
6
|
+
from granulaire import Variations, generate_scene, write_outputs
|
|
7
|
+
scene = generate_scene("beach_sand", shapes=100, copies=5, seed=1,
|
|
8
|
+
variations=Variations(moisture=True))
|
|
9
|
+
write_outputs(scene, "output")
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import csv
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from . import moisture
|
|
22
|
+
from .measure import measure
|
|
23
|
+
from .presets import PRESETS
|
|
24
|
+
from .shape import make_grain
|
|
25
|
+
from .usd_export import export_stage
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Variations:
|
|
30
|
+
"""Which properties vary from grain to grain (the "checkboxes").
|
|
31
|
+
|
|
32
|
+
Turning one off does not change the random draws of the others: with the
|
|
33
|
+
same seed you compare the same scene with a single difference.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
size: bool = True # diameter from the grading curve, else d50 for all
|
|
37
|
+
shape: bool = True # elongation and flatness drawn, else mean values
|
|
38
|
+
roundness: bool = True # facets and edge rounding drawn, else median values
|
|
39
|
+
mineralogy: bool = True # mineral and tint drawn, else dominant mineral
|
|
40
|
+
orientation: bool = True # random orientation, else all aligned
|
|
41
|
+
moisture: bool = False # "moisture" variant set + state drawn per grain
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Scene:
|
|
46
|
+
preset_key: str
|
|
47
|
+
preset: dict
|
|
48
|
+
seed: int
|
|
49
|
+
variations: Variations
|
|
50
|
+
prototypes: list
|
|
51
|
+
instances: list
|
|
52
|
+
states: list | None
|
|
53
|
+
order: np.ndarray
|
|
54
|
+
spacing: float
|
|
55
|
+
side: int
|
|
56
|
+
moisture_weights: dict | None = field(default=None)
|
|
57
|
+
deformable: dict | None = field(default=None)
|
|
58
|
+
shuffle: bool = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _beta_mean(ab):
|
|
62
|
+
return ab[0] / (ab[0] + ab[1])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sample_params(rng, preset, var: Variations):
|
|
66
|
+
# always draw, then override when the variation is off:
|
|
67
|
+
# the random sequence stays the same whatever boxes are ticked
|
|
68
|
+
phi = rng.normal(preset["phi_mean"], preset["phi_std"])
|
|
69
|
+
elong = rng.beta(*preset["elongation_beta"])
|
|
70
|
+
flat = rng.beta(*preset["flatness_beta"])
|
|
71
|
+
facets = int(rng.integers(preset["facets"][0], preset["facets"][1] + 1))
|
|
72
|
+
lo_p, hi_p = preset["roundness_p"]
|
|
73
|
+
p = float(np.exp(rng.uniform(np.log(lo_p), np.log(hi_p))))
|
|
74
|
+
|
|
75
|
+
# irregularity: low-frequency lumps, egg taper, fracture chips, pits
|
|
76
|
+
lump_lo, lump_hi = preset.get("lumpiness", (0.0, 0.0))
|
|
77
|
+
lump = rng.uniform(lump_lo, lump_hi)
|
|
78
|
+
# taper: a number gives a random sign up to that amount, a range keeps its sign
|
|
79
|
+
taper_spec = preset.get("taper", 0.0)
|
|
80
|
+
taper_lo, taper_hi = taper_spec if isinstance(taper_spec, tuple) else (-taper_spec, taper_spec)
|
|
81
|
+
taper = rng.uniform(taper_lo, taper_hi)
|
|
82
|
+
chips_lo, chips_hi = preset.get("chips", (0, 0))
|
|
83
|
+
chips = int(rng.integers(chips_lo, chips_hi + 1))
|
|
84
|
+
pits_lo, pits_hi = preset.get("pits", (0, 0))
|
|
85
|
+
pits = int(rng.integers(pits_lo, pits_hi + 1))
|
|
86
|
+
aspect_lo, aspect_hi = preset.get("aspect", (1.0, 1.0))
|
|
87
|
+
aspect = rng.uniform(aspect_lo, aspect_hi)
|
|
88
|
+
|
|
89
|
+
if not var.size:
|
|
90
|
+
phi = preset["phi_mean"]
|
|
91
|
+
if not var.shape:
|
|
92
|
+
elong = _beta_mean(preset["elongation_beta"])
|
|
93
|
+
flat = _beta_mean(preset["flatness_beta"])
|
|
94
|
+
lump, taper = (lump_lo + lump_hi) / 2, (taper_lo + taper_hi) / 2
|
|
95
|
+
chips, pits = round((chips_lo + chips_hi) / 2), round((pits_lo + pits_hi) / 2)
|
|
96
|
+
aspect = (aspect_lo + aspect_hi) / 2
|
|
97
|
+
if not var.roundness:
|
|
98
|
+
facets = (preset["facets"][0] + preset["facets"][1]) // 2
|
|
99
|
+
p = float(np.sqrt(lo_p * hi_p))
|
|
100
|
+
return {
|
|
101
|
+
"phi": phi,
|
|
102
|
+
"diameter_m": 2.0 ** (-phi) / 1000.0,
|
|
103
|
+
# lower bounds avoid unrealistic flakes (cf. aggregate flakiness index)
|
|
104
|
+
"elongation": max(elong, preset.get("elongation_min", 0.4)),
|
|
105
|
+
"flatness": max(flat, preset.get("flatness_min", 0.4)),
|
|
106
|
+
"facets": facets,
|
|
107
|
+
"roundness_p": p,
|
|
108
|
+
"roughness": preset["roughness"],
|
|
109
|
+
"kind": preset.get("kind", "rock"),
|
|
110
|
+
"exponent": preset.get("exponent", 2.0),
|
|
111
|
+
"aspect": aspect,
|
|
112
|
+
"crease": preset.get("crease"),
|
|
113
|
+
"dent": preset.get("dent"),
|
|
114
|
+
"dent_dir": preset.get("dent_dir"),
|
|
115
|
+
# drawn inside make_grain from the grain's own stream; mean when shape is off
|
|
116
|
+
"bend": preset.get("bend") if var.shape or not preset.get("bend") else (sum(preset["bend"]) / 2,) * 2,
|
|
117
|
+
"chip_axis": preset.get("chip_axis"),
|
|
118
|
+
"lumpiness": lump,
|
|
119
|
+
"taper": taper,
|
|
120
|
+
"chips": chips,
|
|
121
|
+
"chip_depth": preset.get("chip_depth", (0.6, 0.85)),
|
|
122
|
+
"pits": pits,
|
|
123
|
+
"pit_depth": preset.get("pit_depth", (0.08, 0.2)),
|
|
124
|
+
"pit_size": preset.get("pit_size", (0.2, 0.4)),
|
|
125
|
+
"subdivisions": preset["subdivisions"],
|
|
126
|
+
"sphere": preset.get("sphere", False),
|
|
127
|
+
# tube grains: curve drawn inside make_grain; mean amplitude when shape is off
|
|
128
|
+
"curves": preset.get("curves"),
|
|
129
|
+
"curve_amplitude": preset.get("curve_amplitude", (0.0, 0.0)),
|
|
130
|
+
"vary": var.shape,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def sample_color(rng, preset, var: Variations):
|
|
135
|
+
names = [m[0] for m in preset["minerals"]]
|
|
136
|
+
probs = np.array([m[1] for m in preset["minerals"]])
|
|
137
|
+
k = rng.choice(len(names), p=probs / probs.sum())
|
|
138
|
+
factor = rng.uniform(0.88, 1.12)
|
|
139
|
+
noise = rng.normal(0, 0.02, 3)
|
|
140
|
+
if not var.mineralogy:
|
|
141
|
+
k, factor, noise = int(np.argmax(probs)), 1.0, np.zeros(3)
|
|
142
|
+
base = np.array(preset["minerals"][k][2])
|
|
143
|
+
return names[k], tuple(np.clip(base * factor + noise, 0.0, 1.0))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def random_quaternion(rng):
|
|
147
|
+
"""Uniform rotation (Shoemake's method), returned as (w, x, y, z)."""
|
|
148
|
+
u1, u2, u3 = rng.random(3)
|
|
149
|
+
a, b = np.sqrt(1 - u1), np.sqrt(u1)
|
|
150
|
+
return (
|
|
151
|
+
b * np.cos(2 * np.pi * u3),
|
|
152
|
+
a * np.sin(2 * np.pi * u2),
|
|
153
|
+
a * np.cos(2 * np.pi * u2),
|
|
154
|
+
b * np.sin(2 * np.pi * u3),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def moisture_applies(preset_key: str) -> bool:
|
|
159
|
+
return PRESETS[preset_key].get("moisture", True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def generate_scene(
|
|
163
|
+
preset_key: str,
|
|
164
|
+
shapes: int = 100,
|
|
165
|
+
copies: int = 5,
|
|
166
|
+
seed: int = 1,
|
|
167
|
+
variations: Variations | None = None,
|
|
168
|
+
moisture_weights: str = moisture.DEFAULT_WEIGHTS,
|
|
169
|
+
deformable: bool = True,
|
|
170
|
+
shuffle: bool = True,
|
|
171
|
+
) -> Scene:
|
|
172
|
+
"""Build the grain library and the mixed scene in memory.
|
|
173
|
+
|
|
174
|
+
The scene holds `shapes` distinct grains, each placed `copies` times
|
|
175
|
+
(shapes * copies grains in all). With shuffle=True (default) the copies are
|
|
176
|
+
mixed in random order; with shuffle=False the copies of each shape are
|
|
177
|
+
placed next to each other, shape after shape, which is handy to compare
|
|
178
|
+
shapes. Everything else in the scene is identical either way.
|
|
179
|
+
|
|
180
|
+
Presets with a "deformable" entry (packing peanuts) also get a tetrahedral
|
|
181
|
+
simulation mesh per grain and are exported as deformable bodies, unless
|
|
182
|
+
deformable=False (then they are exported as rigid bodies like the others).
|
|
183
|
+
"""
|
|
184
|
+
if preset_key not in PRESETS:
|
|
185
|
+
raise KeyError(f"unknown preset '{preset_key}'")
|
|
186
|
+
if shapes < 1 or copies < 1:
|
|
187
|
+
raise ValueError(f"shapes and copies must be at least 1 (got shapes={shapes}, copies={copies})")
|
|
188
|
+
preset = PRESETS[preset_key]
|
|
189
|
+
var = variations or Variations()
|
|
190
|
+
use_moisture = var.moisture and moisture_applies(preset_key)
|
|
191
|
+
weights = moisture.parse_weights(moisture_weights) if use_moisture else None
|
|
192
|
+
states = list(moisture.STATES) if use_moisture else None
|
|
193
|
+
soft = preset.get("deformable") if deformable else None
|
|
194
|
+
|
|
195
|
+
rng = np.random.default_rng(seed)
|
|
196
|
+
prototypes = []
|
|
197
|
+
for i in range(shapes):
|
|
198
|
+
params = sample_params(rng, preset, var)
|
|
199
|
+
# each grain's geometry uses its own stream: the number of chips or pits
|
|
200
|
+
# of one grain never shifts the draws of the next ones
|
|
201
|
+
tet_points = tets = None
|
|
202
|
+
if soft:
|
|
203
|
+
params.update(tets=True, tet_resolution=soft.get("tet_resolution", 18))
|
|
204
|
+
verts, faces, tet_points, tets = make_grain(np.random.default_rng([seed, 2, i]), params)
|
|
205
|
+
else:
|
|
206
|
+
verts, faces = make_grain(np.random.default_rng([seed, 2, i]), params)
|
|
207
|
+
metrics = measure(verts, faces)
|
|
208
|
+
mineral, color = sample_color(rng, preset, var)
|
|
209
|
+
dry = metrics["volume_m3"] * preset["density"]
|
|
210
|
+
proto = {
|
|
211
|
+
"verts": verts,
|
|
212
|
+
"faces": faces,
|
|
213
|
+
"params": params,
|
|
214
|
+
"metrics": metrics,
|
|
215
|
+
"mineral": mineral,
|
|
216
|
+
"color": color,
|
|
217
|
+
"mass": dry,
|
|
218
|
+
}
|
|
219
|
+
if soft:
|
|
220
|
+
proto["tet_points"], proto["tets"] = tet_points, tets
|
|
221
|
+
if use_moisture:
|
|
222
|
+
proto["moisture"] = {}
|
|
223
|
+
for s in states:
|
|
224
|
+
w = moisture.water_mass(
|
|
225
|
+
s, dry, metrics["area_m2"], preset.get("absorption", 0.0), preset["density"]
|
|
226
|
+
)
|
|
227
|
+
proto["moisture"][s] = {
|
|
228
|
+
"water_mass": w,
|
|
229
|
+
"mass": dry + w,
|
|
230
|
+
"cohesion": moisture.cohesion(s, metrics["diameter_nominal_m"]),
|
|
231
|
+
}
|
|
232
|
+
prototypes.append(proto)
|
|
233
|
+
|
|
234
|
+
# Mixing: each shape appears `copies` times, in random order unless
|
|
235
|
+
# shuffle=False, with its own random orientation. The permutation is always
|
|
236
|
+
# drawn so that turning shuffling off leaves positions and orientations
|
|
237
|
+
# unchanged.
|
|
238
|
+
grouped = np.repeat(np.arange(shapes), copies)
|
|
239
|
+
order = rng.permutation(grouped)
|
|
240
|
+
if not shuffle:
|
|
241
|
+
order = grouped
|
|
242
|
+
n = len(order)
|
|
243
|
+
side = int(np.ceil(n ** (1 / 3)))
|
|
244
|
+
# bounding sphere of every grain around its origin: two neighbours never touch,
|
|
245
|
+
# whatever their orientation, even after the +/-5 % jitter on each axis
|
|
246
|
+
reach = max(float(np.linalg.norm(p["verts"], axis=1).max()) for p in prototypes)
|
|
247
|
+
spacing = 1.02 * 2 * reach / 0.9
|
|
248
|
+
instances = []
|
|
249
|
+
for j, proto in enumerate(order):
|
|
250
|
+
ix, iy, iz = j % side, (j // side) % side, j // (side * side)
|
|
251
|
+
jitter = rng.uniform(-0.05, 0.05, 3) * spacing
|
|
252
|
+
pos = (
|
|
253
|
+
np.array([(ix - (side - 1) / 2) * spacing, (iy - (side - 1) / 2) * spacing, (iz + 1) * spacing])
|
|
254
|
+
+ jitter
|
|
255
|
+
)
|
|
256
|
+
q = random_quaternion(rng)
|
|
257
|
+
if not var.orientation:
|
|
258
|
+
q = (1.0, 0.0, 0.0, 0.0)
|
|
259
|
+
instances.append({"proto": int(proto), "position": tuple(pos), "orient": q, "state": None})
|
|
260
|
+
|
|
261
|
+
if use_moisture:
|
|
262
|
+
# separate generator: enabling moisture leaves the rest of the scene unchanged
|
|
263
|
+
rng_w = np.random.default_rng([seed, 1])
|
|
264
|
+
drawn = rng_w.choice(states, size=n, p=[weights[s] for s in states])
|
|
265
|
+
for inst, s in zip(instances, drawn, strict=True):
|
|
266
|
+
inst["state"] = str(s)
|
|
267
|
+
|
|
268
|
+
effective = Variations(**{**asdict(var), "moisture": bool(use_moisture)})
|
|
269
|
+
return Scene(
|
|
270
|
+
preset_key,
|
|
271
|
+
preset,
|
|
272
|
+
seed,
|
|
273
|
+
effective,
|
|
274
|
+
prototypes,
|
|
275
|
+
instances,
|
|
276
|
+
states,
|
|
277
|
+
order,
|
|
278
|
+
spacing,
|
|
279
|
+
side,
|
|
280
|
+
weights,
|
|
281
|
+
soft,
|
|
282
|
+
shuffle,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def summarize(scene: Scene) -> dict:
|
|
287
|
+
protos = scene.prototypes
|
|
288
|
+
keys = list(protos[0]["metrics"].keys())
|
|
289
|
+
m = {k: np.array([p["metrics"][k] for p in protos]) for k in keys}
|
|
290
|
+
dry_total = sum(protos[i]["mass"] for i in scene.order)
|
|
291
|
+
summary = {
|
|
292
|
+
"preset": scene.preset_key,
|
|
293
|
+
"label": scene.preset["label"],
|
|
294
|
+
"category": scene.preset["category"],
|
|
295
|
+
"shapes": len(protos),
|
|
296
|
+
"grains": len(scene.instances),
|
|
297
|
+
"copies_per_shape": len(scene.instances) // len(protos),
|
|
298
|
+
"shuffled": bool(scene.shuffle),
|
|
299
|
+
"seed": scene.seed,
|
|
300
|
+
"variations": asdict(scene.variations),
|
|
301
|
+
"d50_mm": float(np.median(m["diameter_nominal_m"]) * 1000),
|
|
302
|
+
"d_min_mm": float(m["diameter_nominal_m"].min() * 1000),
|
|
303
|
+
"d_max_mm": float(m["diameter_nominal_m"].max() * 1000),
|
|
304
|
+
"mean_sphericity": float(m["sphericity"].mean()),
|
|
305
|
+
"mean_elongation": float(m["elongation_IL"].mean()),
|
|
306
|
+
"mean_flatness": float(m["flatness_SI"].mean()),
|
|
307
|
+
"mean_angularity_deg": float(m["angularity_deg"].mean()),
|
|
308
|
+
"min_convexity": float(m["convexity"].min()),
|
|
309
|
+
"total_dry_mass_g": float(dry_total * 1000),
|
|
310
|
+
"deformable": bool(scene.deformable),
|
|
311
|
+
}
|
|
312
|
+
if scene.deformable:
|
|
313
|
+
summary["tetrahedra_per_grain_median"] = int(np.median([len(p["tets"]) for p in protos]))
|
|
314
|
+
if scene.states:
|
|
315
|
+
wet_total = sum(protos[i["proto"]]["moisture"][i["state"]]["mass"] for i in scene.instances)
|
|
316
|
+
summary["moisture"] = {
|
|
317
|
+
"grains_per_state": {s: sum(1 for i in scene.instances if i["state"] == s) for s in scene.states},
|
|
318
|
+
"total_wet_mass_g": float(wet_total * 1000),
|
|
319
|
+
"water_g": float((wet_total - dry_total) * 1000),
|
|
320
|
+
# water content of the median grain, per state (% of dry mass)
|
|
321
|
+
"median_water_content_pct": {
|
|
322
|
+
s: float(np.median([p["moisture"][s]["water_mass"] / p["mass"] * 100 for p in protos]))
|
|
323
|
+
for s in scene.states
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
return summary
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def write_outputs(scene: Scene, out_root: str | Path = "output") -> Path:
|
|
330
|
+
"""Write the USD scene, CSV tables, mesh archive and summary. Returns the folder."""
|
|
331
|
+
out = Path(out_root) / scene.preset_key
|
|
332
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
protos, states = scene.prototypes, scene.states
|
|
334
|
+
|
|
335
|
+
usd_path = out / f"granulaire_{scene.preset_key}.usdc"
|
|
336
|
+
export_stage(
|
|
337
|
+
usd_path,
|
|
338
|
+
scene.preset,
|
|
339
|
+
protos,
|
|
340
|
+
scene.instances,
|
|
341
|
+
ground_size=4 * scene.side * scene.spacing,
|
|
342
|
+
states=states,
|
|
343
|
+
deformable=scene.deformable,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
keys = list(protos[0]["metrics"].keys())
|
|
347
|
+
with open(out / "prototype_metrics.csv", "w", newline="") as f:
|
|
348
|
+
w = csv.writer(f)
|
|
349
|
+
extra = [f"mass_{s}_kg" for s in states] if states else []
|
|
350
|
+
w.writerow(["prototype", "mineral", "mass_kg", "facets", "roundness_p"] + keys + extra)
|
|
351
|
+
for i, p in enumerate(protos):
|
|
352
|
+
row = [
|
|
353
|
+
i,
|
|
354
|
+
p["mineral"],
|
|
355
|
+
f"{p['mass']:.6e}",
|
|
356
|
+
p["params"]["facets"],
|
|
357
|
+
f"{p['params']['roundness_p']:.2f}",
|
|
358
|
+
] + [f"{p['metrics'][k]:.6g}" for k in keys]
|
|
359
|
+
if states:
|
|
360
|
+
row += [f"{p['moisture'][s]['mass']:.6e}" for s in states]
|
|
361
|
+
w.writerow(row)
|
|
362
|
+
with open(out / "instances.csv", "w", newline="") as f:
|
|
363
|
+
w = csv.writer(f)
|
|
364
|
+
w.writerow(["grain", "prototype", "x_m", "y_m", "z_m", "moisture"])
|
|
365
|
+
for j, inst in enumerate(scene.instances):
|
|
366
|
+
w.writerow([j, inst["proto"], *[f"{c:.6g}" for c in inst["position"]], inst["state"] or ""])
|
|
367
|
+
|
|
368
|
+
np.savez_compressed(
|
|
369
|
+
out / "prototypes.npz",
|
|
370
|
+
**{f"v{i}": p["verts"] for i, p in enumerate(protos)},
|
|
371
|
+
**{f"f{i}": p["faces"] for i, p in enumerate(protos)},
|
|
372
|
+
colors=np.array([p["color"] for p in protos]),
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
summary = summarize(scene)
|
|
376
|
+
summary["usd"] = usd_path.name
|
|
377
|
+
(out / "summary.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
378
|
+
return out
|
granulaire/measure.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shape descriptors measured on the mesh actually generated.
|
|
2
|
+
|
|
3
|
+
We do not trust the input parameters: we measure what comes out.
|
|
4
|
+
Measurements are taken on a copy scaled to unit size, then converted back:
|
|
5
|
+
trimesh uses absolute tolerances (~1e-8) that break on micron-sized grains.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import trimesh
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def measure(verts, faces):
|
|
13
|
+
scale = float(np.ptp(verts, axis=0).max())
|
|
14
|
+
mesh = trimesh.Trimesh(verts / scale, faces, process=False)
|
|
15
|
+
volume = abs(mesh.volume)
|
|
16
|
+
area = mesh.area
|
|
17
|
+
|
|
18
|
+
# dimensions L >= I >= S: bounding box along the principal axes of the
|
|
19
|
+
# surface (a caliper-like measure; much faster than a minimum-volume box)
|
|
20
|
+
pts = mesh.vertices - mesh.vertices.mean(axis=0)
|
|
21
|
+
axes = np.linalg.svd(pts, full_matrices=False)[2]
|
|
22
|
+
extents = np.sort(np.ptp(pts @ axes.T, axis=0))[::-1]
|
|
23
|
+
L, I, S = extents # noqa: E741 - standard sedimentology notation
|
|
24
|
+
|
|
25
|
+
# Wadell sphericity: area of the equal-volume sphere / actual area
|
|
26
|
+
sphericity = (np.pi ** (1 / 3)) * (6 * volume) ** (2 / 3) / area
|
|
27
|
+
|
|
28
|
+
# convexity: volume / convex hull volume
|
|
29
|
+
convexity = volume / mesh.convex_hull.volume
|
|
30
|
+
|
|
31
|
+
# angularity (proxy): 95th percentile of dihedral angles between adjacent
|
|
32
|
+
# faces, in degrees. Only comparable between grains of the same mesh
|
|
33
|
+
# resolution; not a reliable roundness metric yet.
|
|
34
|
+
angles = np.degrees(mesh.face_adjacency_angles)
|
|
35
|
+
angularity = float(np.percentile(angles, 95))
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"diameter_nominal_m": (6 * volume / np.pi) ** (1 / 3) * scale,
|
|
39
|
+
"volume_m3": volume * scale**3,
|
|
40
|
+
"area_m2": area * scale**2,
|
|
41
|
+
"L_m": L * scale,
|
|
42
|
+
"I_m": I * scale,
|
|
43
|
+
"S_m": S * scale,
|
|
44
|
+
"elongation_IL": I / L,
|
|
45
|
+
"flatness_SI": S / I,
|
|
46
|
+
"sphericity": sphericity,
|
|
47
|
+
"convexity": convexity,
|
|
48
|
+
"angularity_deg": angularity,
|
|
49
|
+
}
|