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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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())
@@ -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
+ }