simready-forge 0.1.0__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.
simready_forge/kit.py ADDED
@@ -0,0 +1,1047 @@
1
+ """The asset model: build123d shapes and generated scans become rigid bodies with materials, physics materials,
2
+ colliders, mass and joints, saved as an NVIDIA SimReady (Robotics-Prop 4.0.0) USD package and exported to URDF and GLB.
3
+
4
+ from simready_forge import Asset
5
+ asset = Asset('desk_lamp', 'simready_assets', label='desk lamp', qcode='Q3216816', units='mm')
6
+ steel = asset.material('steel', color=(0.12, 0.12, 0.13), metallic=1.0, roughness=0.35,
7
+ friction=(0.6, 0.5), restitution=0.2, density=7850, nonvisual=('steel', 'paint'))
8
+ base, arm = asset.body('base'), asset.body('arm')
9
+ asset.add(base, 'disc', base_shape, steel)
10
+ asset.add(arm, 'link', link_shape, steel)
11
+ asset.revolute('shoulder', base, arm, anchor=(0, 0, 40), axis='Y', limits=(-30, 75))
12
+ asset.save() # formats=('usd', 'urdf', 'glb') by default
13
+
14
+ Layout under out_dir: <name>/usd/<name>.usda is the SimReady package (textures and thumbnail beside it);
15
+ <name>_exports/ holds the URDF and GLB; <name>_reports/ holds structure.json and the validator, review and cost reports.
16
+ """
17
+
18
+ import datetime
19
+ import json
20
+ import math
21
+ import re
22
+ import shutil
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ from .scan import UNITS
29
+
30
+ AXES = ("X", "Y", "Z")
31
+ G = 9.81 # m/s^2
32
+ HULL_POINTS = 64 # PhysX's default vertex limit for a convex hull
33
+ DEFAULT_COLOUR = (0.7, 0.7, 0.7)
34
+ COLLIDERS = ("convex_hull", "convex_decomposition", "box", "none")
35
+ FORMATS = ("usd", "urdf", "glb")
36
+ PROJECTIONS = ("box", "fit", "cylinder")
37
+ PROFILE, PROFILE_VERSION = "Robotics-Prop", "4.0.0"
38
+ # NVIDIA's non-visual (sensor) material tokens, from the SimReady FET_007 requirements
39
+ METAL_BASES = frozenset( # the sensor bases (all in NONVISUAL_BASES) that are metals
40
+ {"aluminum", "steel", "oxidized_steel", "iron", "oxidized_iron", "silver", "brass", "bronze", "tin"}
41
+ | {"oxidized_Bronze_Patina"}
42
+ )
43
+ NONVISUAL_BASES = frozenset(
44
+ {
45
+ "aluminum",
46
+ "steel",
47
+ "oxidized_steel",
48
+ "iron",
49
+ "oxidized_iron",
50
+ "silver",
51
+ "brass",
52
+ "bronze",
53
+ "oxidized_Bronze_Patina",
54
+ "tin",
55
+ "plastic",
56
+ "fiberglass",
57
+ "carbon_fiber",
58
+ "vinyl",
59
+ "plexiglass",
60
+ "pvc",
61
+ "nylon",
62
+ "polyester",
63
+ "clear_glass",
64
+ "frosted_glass",
65
+ "one_way_mirror",
66
+ "mirror",
67
+ "ceramic_glass",
68
+ "asphalt",
69
+ "concrete",
70
+ "leaf_grass",
71
+ "dead_leaf_grass",
72
+ "rubber",
73
+ "wood",
74
+ "bark",
75
+ "cardboard",
76
+ "paper",
77
+ "fabric",
78
+ "skin",
79
+ "fur_hair",
80
+ "leather",
81
+ "marble",
82
+ "brick",
83
+ "stone",
84
+ "gravel",
85
+ "dirt",
86
+ "mud",
87
+ "water",
88
+ "salt_water",
89
+ "snow",
90
+ "ice",
91
+ "calibration_lambertian",
92
+ }
93
+ )
94
+ NONVISUAL_COATINGS = frozenset({"none", "paint", "clearcoat", "paint_clearcoat"})
95
+
96
+
97
+ def ident(name):
98
+ """A valid USD prim name."""
99
+ name = re.sub(r"[^A-Za-z0-9_]", "_", str(name)) or "prim"
100
+ return "_" + name if name[0].isdigit() else name
101
+
102
+
103
+ class Material:
104
+ def __init__(self, name, **kw):
105
+ self.name = ident(name)
106
+ self.color = tuple(kw["color"])
107
+ self.metallic, self.roughness, self.opacity, self.ior = (
108
+ kw["metallic"],
109
+ kw["roughness"],
110
+ kw["opacity"],
111
+ kw["ior"],
112
+ )
113
+ self.texture, self.uv_scale, self.lengthwise = kw["texture"], kw["uv_scale"], kw.get("lengthwise", False)
114
+ self.projection = kw.get("projection", "box")
115
+ self.axis = kw.get("axis", "Z") # the axis a 'cylinder' projection wraps round
116
+ self.friction, self.restitution, self.density = tuple(kw["friction"]), kw["restitution"], kw["density"]
117
+ self.label, self.nonvisual = kw["label"], kw["nonvisual"]
118
+ self.metal_rough_texture = self.normal_texture = None
119
+ self.map_metallic = True # take metallic from the metal_rough map's B channel, not only roughness
120
+ self.files = {} # texture files save() wrote, for the exporters
121
+
122
+
123
+ class Body:
124
+ def __init__(self, name, label, mass, com=None):
125
+ self.name, self.label, self.mass, self.com, self.parts = ident(name), label, mass, com, []
126
+
127
+
128
+ class Asset:
129
+ def __init__(
130
+ self,
131
+ name,
132
+ out_dir,
133
+ label,
134
+ qcode,
135
+ units="mm",
136
+ description="",
137
+ category="prop",
138
+ author="simready-forge",
139
+ license="Proprietary",
140
+ ):
141
+ if units not in UNITS:
142
+ raise ValueError(f"units must be one of {list(UNITS)}")
143
+ if not re.fullmatch(r"Q\d+", qcode or ""):
144
+ raise ValueError("qcode must look like 'Q12345' (simready-forge qcode \"<object>\")")
145
+ self.name, self.label, self.qcode, self.scale = ident(name), label, qcode, UNITS[units]
146
+ self.description, self.category, self.author, self.license = description, category, author, license
147
+ self.out_dir = Path(out_dir)
148
+ self.root = self.out_dir / self.name
149
+ self.reports = self.out_dir / f"{self.name}_reports"
150
+ self.exports = self.out_dir / f"{self.name}_exports"
151
+ self.materials, self.bodies, self.joints, self.scans = [], [], [], []
152
+
153
+ # ------------------------------------------------------------------ declaring the asset
154
+ def material(
155
+ self,
156
+ name,
157
+ color=DEFAULT_COLOUR,
158
+ metallic=0.0,
159
+ roughness=0.5,
160
+ opacity=1.0,
161
+ ior=1.5,
162
+ texture=None,
163
+ uv_scale=1.0,
164
+ friction=(0.5, 0.4),
165
+ restitution=0.1,
166
+ density=1000.0,
167
+ label=None,
168
+ nonvisual=None,
169
+ lengthwise=False,
170
+ projection="box",
171
+ axis="Z",
172
+ ):
173
+ """Visual (UsdPreviewSurface) and physics material. density in kg/m^3, friction = (static, dynamic).
174
+
175
+ texture: an image path, PIL image or HxWx3 uint8 array for the base colour; uv_scale = repeats per metre.
176
+ lengthwise: the texture's width (u) runs along each part's longer side on every face, as grain runs along a
177
+ board; otherwise u follows the first axis across the face (X, else Y).
178
+ projection: 'box' repeats the texture every 1/uv_scale metres, projected from the asset's position (wood,
179
+ metal, fabric); 'fit' maps it once across each face of each part (a dial face, a sign, a decal); 'cylinder'
180
+ wraps it once round each part about Z and once up it (a bottle's label, a can, a drum), the image's centre
181
+ facing the front (-Y) and its edges meeting at the back, its ends mapped as with 'fit'. For 'cylinder' draw
182
+ the image as wide as the part's circumference is to its height. axis: what 'cylinder' wraps round: 'Z', or
183
+ 'X' (a drum on its side: the image's middle still faces the front) or 'Y' (a lens ring: its middle faces up);
184
+ the image's width goes round the part and its height along the axis.
185
+ nonvisual: (base, coating) for sensor simulation, e.g. ('steel', 'paint'); see NONVISUAL_BASES.
186
+ """
187
+ if projection not in PROJECTIONS:
188
+ raise ValueError(f"projection must be one of {PROJECTIONS}")
189
+ if axis not in ("X", "Y", "Z"):
190
+ raise ValueError("axis must be 'X', 'Y' or 'Z'")
191
+ if nonvisual is not None and (nonvisual[0] not in NONVISUAL_BASES or nonvisual[1] not in NONVISUAL_COATINGS):
192
+ raise ValueError(
193
+ f"nonvisual={nonvisual!r}: base must be one of NONVISUAL_BASES, "
194
+ f"coating one of {sorted(NONVISUAL_COATINGS)}"
195
+ )
196
+ material = Material(
197
+ name,
198
+ color=color,
199
+ metallic=metallic,
200
+ roughness=roughness,
201
+ opacity=opacity,
202
+ ior=ior,
203
+ texture=texture,
204
+ uv_scale=uv_scale,
205
+ lengthwise=lengthwise,
206
+ projection=projection,
207
+ axis=axis,
208
+ friction=friction,
209
+ restitution=restitution,
210
+ density=density,
211
+ label=label or name,
212
+ nonvisual=nonvisual,
213
+ )
214
+ self.materials.append(material)
215
+ return material
216
+
217
+ def scan_material(
218
+ self,
219
+ scan,
220
+ name,
221
+ friction=(0.5, 0.4),
222
+ restitution=0.1,
223
+ density=1000.0,
224
+ label=None,
225
+ nonvisual=None,
226
+ opacity=1.0,
227
+ color=None,
228
+ metallic=None,
229
+ roughness=None,
230
+ ior=1.5,
231
+ textured=True,
232
+ normal=True,
233
+ ):
234
+ """A material for a scan's faces. By default it keeps the scan's own PBR textures (base colour, metallic and
235
+ roughness, normal map).
236
+
237
+ textured=False drops the base-colour texture for a flat `color` (the texture's average when None): for a
238
+ frosted shade, glass or a paint the generator baked its lighting into. A number for metallic or roughness
239
+ replaces the scan's metallic-roughness map with these values (the other one 0, or 0.5 for roughness).
240
+ normal=False drops the scan's normal map: a generated mesh's surface noise makes clear plastic or glass refract
241
+ like crumpled foil.
242
+ """
243
+ base = scan.images.get("base")
244
+ if color is None:
245
+ color = _linear(_mean_colour(base)) if base is not None and not textured else DEFAULT_COLOUR
246
+ material = self.material(
247
+ name,
248
+ color=color,
249
+ metallic=metallic if metallic is not None else 0.0,
250
+ roughness=roughness if roughness is not None else 0.5,
251
+ opacity=opacity,
252
+ ior=ior,
253
+ texture=base if textured else None,
254
+ friction=friction,
255
+ restitution=restitution,
256
+ density=density,
257
+ label=label,
258
+ nonvisual=nonvisual,
259
+ )
260
+ own_maps = metallic is None and roughness is None
261
+ material.metal_rough_texture = scan.images.get("metal_rough") if own_maps else None
262
+ # TRELLIS.2 often writes metallic ~1 everywhere: only a metal keeps the map's metallic, the rest keep its
263
+ # roughness and take metallic 0 (or the value given)
264
+ material.map_metallic = metallic is None and nonvisual is not None and nonvisual[0] in METAL_BASES
265
+ material.normal_texture = scan.images.get("normal") if normal else None
266
+ return material
267
+
268
+ def body(self, name, label=None, mass=None, com=None):
269
+ """A rigid body. mass in kg; when None it is the sum of part volume x material density.
270
+
271
+ com: its centre of mass (model units, asset frame); when None, the volume x density weighted centre of its
272
+ visible parts, so a part with no collider (a bulb, a socket) still counts.
273
+ """
274
+ com = None if com is None else np.array(com, dtype=float) * self.scale
275
+ body = Body(name, label or name, mass, com)
276
+ self.bodies.append(body)
277
+ return body
278
+
279
+ def add(
280
+ self, body, name, shape, material, collider="convex_hull", label=None, tolerance=None, visible=True, angular=0.2
281
+ ):
282
+ """Add a build123d shape (model units, assembled pose) to a body.
283
+
284
+ collider: convex_hull (default), convex_decomposition (concave parts: bins, cups, frames, hooks), box, none.
285
+ visible=False: a collider only, never rendered or exported as a visual (a drum's flat-bottomed rim, a wheel's
286
+ polygon): the material still gives it friction and density.
287
+ tolerance: how far the triangles may stray from the surface (model units; 0.4 mm by default); angular: the
288
+ most a curve turns between two triangles' edges, in radians, which sets the segments round a large tube.
289
+ """
290
+ if collider not in COLLIDERS:
291
+ raise ValueError(f"collider must be one of {COLLIDERS}")
292
+ if not visible and collider == "none":
293
+ raise ValueError(f"part {name}: an invisible part with no collider is nothing")
294
+ verts, tris = shape.tessellate(tolerance or 0.0004 / self.scale, angular)
295
+ points = np.array([[v.X, v.Y, v.Z] for v in verts], dtype=float) * self.scale
296
+ tris = np.array(tris, dtype=int).reshape(-1, 3)
297
+ if not len(tris):
298
+ raise ValueError(f"part {name} tessellated to nothing")
299
+ body.parts.append(
300
+ {
301
+ "name": ident(name),
302
+ "points": points,
303
+ "tris": tris,
304
+ "material": material,
305
+ "volume": abs(float(shape.volume)) * self.scale**3,
306
+ "collider": collider,
307
+ "label": label or name,
308
+ "uv": None,
309
+ "normals": None,
310
+ "visible": visible,
311
+ }
312
+ )
313
+
314
+ def add_scan(self, body, name, scan, faces, material, collider="convex_hull", label=None):
315
+ """Add the selected faces (a mask from scan.faces_in or scan.remaining) of a scan to a body.
316
+
317
+ collider: convex_hull (default; split a concave part into several add_scan calls instead), box, none, or
318
+ convex_decomposition for a small concave piece (slow, minutes or more, on a large generated mesh).
319
+ """
320
+ import trimesh
321
+
322
+ faces = np.asarray(faces, dtype=bool)
323
+ if not faces.any():
324
+ raise ValueError(f"scan part {name}: no faces selected (check the box against scan.views())")
325
+ scan.used |= faces
326
+ if scan not in self.scans:
327
+ self.scans.append(scan)
328
+ used, inverse = np.unique(scan.tris[faces], return_inverse=True)
329
+ points, local = scan.points[used] * scan.scale, inverse.reshape(-1, 3)
330
+ piece = trimesh.Trimesh(points, local, process=False)
331
+ volume = abs(piece.volume) if piece.is_watertight else 0.6 * piece.convex_hull.volume
332
+ uv = scan.uv.reshape(-1, 3, 2)[faces].reshape(-1, 2) if scan.uv is not None else None
333
+ body.parts.append(
334
+ {
335
+ "name": ident(name),
336
+ "points": points,
337
+ "tris": local,
338
+ "material": material,
339
+ "volume": volume,
340
+ "collider": collider,
341
+ "label": label or name,
342
+ "uv": uv,
343
+ "normals": scan.normals[used],
344
+ "estimated_volume": not piece.is_watertight,
345
+ }
346
+ )
347
+
348
+ def _joint(self, kind, name, body0, body1, anchor, axis, limits, drive, label):
349
+ if axis not in AXES:
350
+ raise ValueError("axis must be 'X', 'Y' or 'Z' (asset frame)")
351
+ if limits[0] >= limits[1]:
352
+ raise ValueError(f"joint {name}: lower limit must be below the upper limit")
353
+ if drive["stiffness"] is not None and drive["hold"] is not None:
354
+ raise ValueError(f"joint {name}: give stiffness or hold, not both")
355
+ self.joints.append(
356
+ {
357
+ "kind": kind,
358
+ "name": ident(name),
359
+ "body0": body0,
360
+ "body1": body1,
361
+ "anchor": np.array(anchor, dtype=float) * self.scale,
362
+ "axis": axis,
363
+ "limits": limits,
364
+ "given": drive,
365
+ "label": label or name,
366
+ }
367
+ )
368
+
369
+ def revolute(
370
+ self, name, body0, body1, anchor, axis, limits, damping=None, stiffness=None, hold=None, loose=False, label=None
371
+ ):
372
+ """Hinge about the asset-frame axis through anchor (model units). limits in degrees from the modelled pose,
373
+ within one turn (PhysX wraps a drive target past 360 degrees); None for a free spin (a wheel, a free knob).
374
+
375
+ hold (degrees): the joint holds its pose, sagging at most this far under gravity in any pose: the kit springs
376
+ it back to the modelled pose with a stiffness sized to the load it carries (its child body and everything
377
+ jointed beyond it). Or give stiffness (N*m/deg) yourself. Without either the joint swings free.
378
+ damping (N*m*s/deg): sized to the load when None, so a push the size of the load's own weight turns it fast.
379
+ loose=True: it swings free on purpose (a lace end, a pendulum, a chain link), so no warning that gravity takes
380
+ it to a limit.
381
+ """
382
+ drive = {"damping": damping, "stiffness": stiffness, "hold": hold, "loose": loose}
383
+ limits = (-math.inf, math.inf) if limits is None else limits
384
+ self._joint("revolute", name, body0, body1, anchor, axis, limits, drive, label)
385
+
386
+ def prismatic(
387
+ self, name, body0, body1, anchor, axis, limits, damping=None, stiffness=None, hold=None, loose=False, label=None
388
+ ):
389
+ """Slide along the asset-frame axis. limits in model units from the modelled pose.
390
+
391
+ hold (model units): the slide holds its pose, sagging at most this far under gravity. Or give stiffness (N/m),
392
+ such as a button's return spring. damping (N*s/m): sized to the load when None.
393
+ """
394
+ limits = (limits[0] * self.scale, limits[1] * self.scale)
395
+ hold = None if hold is None else hold * self.scale
396
+ drive = {"damping": damping, "stiffness": stiffness, "hold": hold, "loose": loose}
397
+ self._joint("prismatic", name, body0, body1, anchor, axis, limits, drive, label)
398
+
399
+ # ------------------------------------------------------------------ writing
400
+ def save(self, formats=FORMATS):
401
+ """Write the SimReady package and the requested exports; return the .usda path."""
402
+ unknown = set(formats) - set(FORMATS)
403
+ if unknown:
404
+ raise ValueError(f"unknown formats {sorted(unknown)}; choose from {FORMATS}")
405
+ path, model, warnings = self._write_usd()
406
+ self.reports.mkdir(parents=True, exist_ok=True)
407
+ files: dict[str, object] = {"usd": str(path.resolve())}
408
+ if "urdf" in formats or "glb" in formats:
409
+ from . import export
410
+
411
+ if "urdf" in formats:
412
+ files["urdf"] = [str(p) for p in export.urdf(model, self.exports / "urdf", warnings)]
413
+ if "glb" in formats:
414
+ files["glb"] = str(export.glb(model, self.exports / f"{self.name}.glb"))
415
+ structure = {
416
+ "name": self.name,
417
+ "label": self.label,
418
+ "qcode": self.qcode,
419
+ "description": self.description,
420
+ "size_m": model["size_m"],
421
+ "mass_kg": model["mass_kg"],
422
+ "triangles": model["triangles"],
423
+ "bodies": [
424
+ {
425
+ "name": b["name"],
426
+ "label": b["label"],
427
+ "mass_kg": round(b["mass"], 4),
428
+ "parts": [p["name"] for p in b["parts"]],
429
+ # 'box' and 'convex_hull' are written as convex mesh colliders (PhysicsMeshCollisionAPI convexHull)
430
+ "colliders": {p["name"]: p["collider"] for p in b["parts"]},
431
+ "hidden": [p["name"] for p in b["parts"] if not p.get("visible", True)], # colliders only
432
+ "materials": {p["name"]: p["material"].name for p in b["parts"]},
433
+ }
434
+ for b in model["bodies"]
435
+ ],
436
+ "joints": [
437
+ {
438
+ "name": j["name"],
439
+ "kind": j["kind"],
440
+ "parent": j["body0"].name,
441
+ "child": j["body1"].name,
442
+ "axis": j["axis"],
443
+ "anchor_m": np.round(j["anchor"], 5).tolist(),
444
+ "limits": [x if math.isfinite(x) else None for x in j["limits"]], # None: free
445
+ "limit_units": "deg" if j["kind"] == "revolute" else "m",
446
+ **j["drive"],
447
+ }
448
+ for j in self.joints
449
+ ],
450
+ "materials": [
451
+ {
452
+ "name": m.name,
453
+ "label": m.label,
454
+ "color": list(m.color),
455
+ # a scan's metallic-roughness map, when bound, wins over the numbers
456
+ "metallic": "texture" if m.metal_rough_texture is not None and m.map_metallic else m.metallic,
457
+ "roughness": "texture" if m.metal_rough_texture is not None else m.roughness,
458
+ "normal_map": m.normal_texture is not None,
459
+ "opacity": m.opacity,
460
+ "ior": m.ior,
461
+ "textured": m.texture is not None,
462
+ "friction": list(m.friction),
463
+ "restitution": m.restitution,
464
+ "density": m.density,
465
+ "nonvisual": list(m.nonvisual) if m.nonvisual else None,
466
+ }
467
+ for m in self.materials
468
+ ],
469
+ "files": files,
470
+ "warnings": warnings,
471
+ }
472
+ (self.reports / "structure.json").write_text(json.dumps(structure, indent=1, default=str), encoding="utf-8")
473
+ print(
474
+ json.dumps(
475
+ {
476
+ "usd": files["usd"],
477
+ "size_m": structure["size_m"],
478
+ "mass_kg": structure["mass_kg"],
479
+ "bodies": {b["name"]: len(b["parts"]) for b in structure["bodies"]},
480
+ "joints": [
481
+ f"{j['name']} ({j['kind']} {j['axis']}, {j['parent']}->{j['child']})"
482
+ for j in structure["joints"]
483
+ ],
484
+ "triangles": structure["triangles"],
485
+ "warnings": warnings,
486
+ },
487
+ indent=1,
488
+ )
489
+ )
490
+ return path
491
+
492
+ def _write_usd(self):
493
+ from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdShade, Vt
494
+
495
+ from .preview import thumbnail
496
+
497
+ T = Sdf.ValueTypeNames
498
+ usd_dir = self.root / "usd"
499
+ shutil.rmtree(
500
+ usd_dir / "textures", ignore_errors=True
501
+ ) # the kit's own folder: no texture of an old build ships
502
+ (usd_dir / "textures").mkdir(parents=True, exist_ok=True)
503
+ (usd_dir / ".thumbs" / "256x256").mkdir(parents=True, exist_ok=True)
504
+ path = usd_dir / f"{self.name}.usda"
505
+ warnings: list[str] = []
506
+
507
+ def vec(a):
508
+ return Gf.Vec3f(*[float(x) for x in a])
509
+
510
+ def labels(prim, **values):
511
+ for instance, value in values.items():
512
+ prim.AddAppliedSchema(f"SemanticsLabelsAPI:{instance}")
513
+ prim.CreateAttribute(f"semantics:labels:{instance}", T.TokenArray).Set([str(value)])
514
+
515
+ def display_colour(gprim, rgb):
516
+ gprim.CreateDisplayColorPrimvar(UsdGeom.Tokens.constant).Set(Vt.Vec3fArray([vec(rgb[:3])]))
517
+
518
+ stage = Usd.Stage.CreateInMemory()
519
+ UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.z)
520
+ UsdGeom.SetStageMetersPerUnit(stage, 1.0)
521
+ stage.SetMetadata("kilogramsPerUnit", 1.0)
522
+ root = UsdGeom.Xform.Define(stage, f"/{self.name}")
523
+ stage.SetDefaultPrim(root.GetPrim())
524
+ root.GetPrim().SetMetadata("kind", "component")
525
+ labels(root.GetPrim(), **{"class": self.label, "wikidata_qcode": self.qcode})
526
+ if self.joints:
527
+ UsdPhysics.ArticulationRootAPI.Apply(root.GetPrim())
528
+
529
+ UsdGeom.Scope.Define(stage, f"/{self.name}/Looks")
530
+ shaders, swatches = {}, {}
531
+ for m in self.materials:
532
+ mat = UsdShade.Material.Define(stage, f"/{self.name}/Looks/{m.name}")
533
+ labels(mat.GetPrim(), material=m.label)
534
+ phys = UsdPhysics.MaterialAPI.Apply(mat.GetPrim())
535
+ phys.CreateStaticFrictionAttr(float(m.friction[0]))
536
+ phys.CreateDynamicFrictionAttr(float(m.friction[1]))
537
+ phys.CreateRestitutionAttr(float(m.restitution))
538
+ phys.CreateDensityAttr(float(m.density))
539
+ if m.restitution >= 0.5: # bouncy: PhysX averages by default, halving it against a dead floor
540
+ mat.GetPrim().AddAppliedSchema("PhysxMaterialAPI")
541
+ mat.GetPrim().CreateAttribute("physxMaterial:restitutionCombineMode", T.Token).Set("max")
542
+ if m.nonvisual:
543
+ prim = mat.GetPrim()
544
+ prim.CreateAttribute("omni:simready:nonvisual:base", T.Token, custom=True).Set(m.nonvisual[0])
545
+ prim.CreateAttribute("omni:simready:nonvisual:coating", T.Token, custom=True).Set(m.nonvisual[1])
546
+ prim.CreateAttribute("omni:simready:nonvisual:attributes", T.TokenArray, custom=True).Set(
547
+ list(m.nonvisual[2]) if len(m.nonvisual) > 2 else ["none"]
548
+ )
549
+ surface = UsdShade.Shader.Define(stage, f"{mat.GetPath()}/PreviewSurface")
550
+ surface.CreateIdAttr("UsdPreviewSurface")
551
+ surface.CreateInput("useSpecularWorkflow", T.Int).Set(0)
552
+ surface.CreateInput("opacity", T.Float).Set(float(m.opacity))
553
+ surface.CreateInput("ior", T.Float).Set(float(m.ior))
554
+ swatches[m.name] = m.color
555
+ textures = _TextureNodes(stage, mat, m, usd_dir / "textures", T)
556
+ if m.texture is not None:
557
+ surface.CreateInput("diffuseColor", T.Color3f).ConnectToSource(
558
+ textures.add("base_color", m.texture, "sRGB", "rgb", T.Float3)
559
+ )
560
+ swatches[m.name] = _linear(_mean_colour(m.files["base_color"])) # displayColor is linear
561
+ if m.color == DEFAULT_COLOUR: # viewers that drop textures (URDF, some GLB) show the texture's average
562
+ m.color = swatches[m.name]
563
+ else:
564
+ surface.CreateInput("diffuseColor", T.Color3f).Set(vec(m.color))
565
+ if m.metal_rough_texture is not None: # glTF packing: G = roughness, B = metallic
566
+ surface.CreateInput("roughness", T.Float).ConnectToSource(
567
+ textures.add("metal_rough", m.metal_rough_texture, "raw", "g", T.Float)
568
+ )
569
+ if m.map_metallic:
570
+ metal = UsdShade.Shader.Get(stage, f"{mat.GetPath()}/metal_rough_texture").CreateOutput(
571
+ "b", T.Float
572
+ )
573
+ surface.CreateInput("metallic", T.Float).ConnectToSource(metal)
574
+ else:
575
+ surface.CreateInput("metallic", T.Float).Set(float(m.metallic))
576
+ else:
577
+ surface.CreateInput("metallic", T.Float).Set(float(m.metallic))
578
+ surface.CreateInput("roughness", T.Float).Set(float(m.roughness))
579
+ if m.normal_texture is not None:
580
+ surface.CreateInput("normal", T.Normal3f).ConnectToSource(
581
+ textures.add("normal", m.normal_texture, "raw", "rgb", T.Float3, ((2, 2, 2, 1), (-1, -1, -1, 0)))
582
+ )
583
+ mat.CreateSurfaceOutput().ConnectToSource(surface.CreateOutput("surface", T.Token))
584
+ shaders[m.name] = mat
585
+
586
+ origins, boxes, preview, model_bodies, total_mass, triangles = {}, {}, [], [], 0.0, 0
587
+ for body in self.bodies:
588
+ if not body.parts:
589
+ raise ValueError(f"body {body.name} has no parts")
590
+ allpts = np.vstack([p["points"] for p in body.parts])
591
+ lo, hi = allpts.min(0), allpts.max(0)
592
+ origin = (lo + hi) / 2
593
+ origins[body.name], boxes[body.name] = origin, (lo, hi)
594
+ xf = UsdGeom.Xform.Define(stage, f"/{self.name}/{body.name}")
595
+ xf.AddTranslateOp().Set(Gf.Vec3d(*[float(x) for x in origin]))
596
+ labels(xf.GetPrim(), **{"class": body.label})
597
+ UsdPhysics.RigidBodyAPI.Apply(xf.GetPrim())
598
+ mass = ( # a collider-only part stands in for geometry the visible parts already weigh
599
+ body.mass
600
+ if body.mass is not None
601
+ else sum(p["volume"] * p["material"].density for p in body.parts if p.get("visible", True))
602
+ )
603
+ if mass <= 0:
604
+ raise ValueError(f"body {body.name} has no mass")
605
+ if body.mass is None and any(p.get("estimated_volume") for p in body.parts):
606
+ warnings.append(
607
+ f"body {body.name}: mass {mass:.3f} kg estimated from a non-watertight scan; "
608
+ f"pass body(..., mass=kg) with the real mass"
609
+ )
610
+ mass_api = UsdPhysics.MassAPI.Apply(xf.GetPrim())
611
+ mass_api.CreateMassAttr(float(mass))
612
+ # PhysX would place the centre of mass from the colliders alone, missing collider-less parts
613
+ com = body.com if body.com is not None else _centre_of_mass(body.parts)
614
+ if com is not None:
615
+ mass_api.CreateCenterOfMassAttr(vec(com - origin))
616
+ total_mass += mass
617
+ model_parts = []
618
+ for part in body.parts:
619
+ m = part["material"]
620
+ if part["uv"] is None: # CAD part: smooth shading and box-projected UVs
621
+ pts, tris, normals = _smooth(part["points"] - origin, part["tris"])
622
+ uv = _uv(pts + origin, tris, m)
623
+ else: # scan part: keeps its own UVs and normals, one per triangle corner as for CAD parts
624
+ pts, tris, uv = part["points"] - origin, np.asarray(part["tris"]), part["uv"]
625
+ normals = np.asarray(part["normals"])[tris].reshape(-1, 3)
626
+ tris = np.asarray(tris)
627
+ if part.get("visible", True): # an invisible part is a collider only
628
+ triangles += len(tris)
629
+ mesh = UsdGeom.Mesh.Define(stage, f"{xf.GetPath()}/{part['name']}")
630
+ labels(mesh.GetPrim(), **{"class": part["label"]})
631
+ mesh.CreateSubdivisionSchemeAttr("none")
632
+ mesh.CreatePointsAttr(Vt.Vec3fArray([vec(p) for p in pts]))
633
+ mesh.CreateFaceVertexCountsAttr(Vt.IntArray([3] * len(tris)))
634
+ mesh.CreateFaceVertexIndicesAttr(Vt.IntArray(tris.reshape(-1).tolist()))
635
+ mesh.CreateNormalsAttr(Vt.Vec3fArray([vec(n) for n in normals]))
636
+ mesh.SetNormalsInterpolation(UsdGeom.Tokens.faceVarying)
637
+ UsdGeom.PrimvarsAPI(mesh).CreatePrimvar("st", T.TexCoord2fArray, UsdGeom.Tokens.faceVarying).Set(
638
+ Vt.Vec2fArray([Gf.Vec2f(float(u), float(v)) for u, v in uv])
639
+ )
640
+ mesh.CreateExtentAttr(Vt.Vec3fArray([vec(pts.min(0)), vec(pts.max(0))]))
641
+ display_colour(mesh, swatches[m.name])
642
+ UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim()).Bind(shaders[m.name])
643
+ preview.append((pts + origin, tris, swatches[m.name], m.opacity))
644
+ hulls = _colliders(part["points"] - origin, part["tris"], part["collider"])
645
+ for k, (hull_pts, hull_tris) in enumerate(hulls):
646
+ col = UsdGeom.Mesh.Define(stage, f"{xf.GetPath()}/{part['name']}_collider{k if k else ''}")
647
+ col.CreatePurposeAttr(UsdGeom.Tokens.guide)
648
+ col.CreatePointsAttr(Vt.Vec3fArray([vec(p) for p in hull_pts]))
649
+ col.CreateFaceVertexCountsAttr(Vt.IntArray([3] * len(hull_tris)))
650
+ col.CreateFaceVertexIndicesAttr(Vt.IntArray(np.asarray(hull_tris).reshape(-1).tolist()))
651
+ col.CreateExtentAttr(Vt.Vec3fArray([vec(hull_pts.min(0)), vec(hull_pts.max(0))]))
652
+ display_colour(col, swatches[m.name])
653
+ labels(col.GetPrim(), **{"class": part["label"]})
654
+ UsdPhysics.CollisionAPI.Apply(col.GetPrim())
655
+ UsdPhysics.MeshCollisionAPI.Apply(col.GetPrim()).CreateApproximationAttr("convexHull")
656
+ col.GetPrim().AddAppliedSchema("NewtonCollisionAPI") # the Newton runtime's mesh collider
657
+ col.GetPrim().AddAppliedSchema("NewtonMeshCollisionAPI")
658
+ binding = UsdShade.MaterialBindingAPI.Apply(col.GetPrim())
659
+ binding.Bind(shaders[m.name])
660
+ binding.Bind(shaders[m.name], UsdShade.Tokens.weakerThanDescendants, "physics")
661
+ model_parts.append(
662
+ {
663
+ "name": part["name"],
664
+ "points": pts,
665
+ "tris": tris,
666
+ "normals": normals,
667
+ "uv": uv,
668
+ "material": m,
669
+ "hulls": hulls,
670
+ "collider": part["collider"],
671
+ "visible": part.get("visible", True),
672
+ }
673
+ )
674
+ model_bodies.append(
675
+ {"name": body.name, "label": body.label, "origin": origin, "mass": mass, "parts": model_parts}
676
+ )
677
+ for scan in self.scans:
678
+ left = int((~scan.used).sum())
679
+ if left > 0.01 * len(scan.tris):
680
+ warnings.append(
681
+ f"{left} of {len(scan.tris)} faces of {Path(scan.path).name} are in no body "
682
+ f"(dropped): add them with scan.remaining()"
683
+ )
684
+
685
+ if self.joints:
686
+ UsdGeom.Scope.Define(stage, f"/{self.name}/Joints")
687
+ loads = _loads(model_bodies, self.joints)
688
+ for j in self.joints:
689
+ load_mass, centre, moment = loads[j["name"]]
690
+ j["drive"] = _drive(j, load_mass, centre, moment, warnings)
691
+ b0, b1 = j["body0"].name, j["body1"].name
692
+ across = [i for i in range(3) if i != AXES.index(j["axis"])] # the anchor may sit anywhere along the axis
693
+ for b in (b0, b1):
694
+ lo, hi = boxes[b]
695
+ pad = 0.1 * (hi - lo).max() + 0.002
696
+ a = j["anchor"]
697
+ if np.any(a[across] < lo[across] - pad) or np.any(a[across] > hi[across] + pad):
698
+ warnings.append(f"joint {j['name']}: anchor {np.round(a, 4).tolist()} m is outside body {b}")
699
+ cls = UsdPhysics.RevoluteJoint if j["kind"] == "revolute" else UsdPhysics.PrismaticJoint
700
+ joint = cls.Define(stage, f"/{self.name}/Joints/{j['name']}")
701
+ labels(joint.GetPrim(), **{"class": j["label"]})
702
+ joint.CreateAxisAttr(j["axis"])
703
+ joint.CreateBody0Rel().SetTargets([Sdf.Path(f"/{self.name}/{b0}")])
704
+ joint.CreateBody1Rel().SetTargets([Sdf.Path(f"/{self.name}/{b1}")])
705
+ joint.CreateLocalPos0Attr(vec(j["anchor"] - origins[b0]))
706
+ joint.CreateLocalPos1Attr(vec(j["anchor"] - origins[b1]))
707
+ joint.CreateLocalRot0Attr(Gf.Quatf(1, 0, 0, 0))
708
+ joint.CreateLocalRot1Attr(Gf.Quatf(1, 0, 0, 0))
709
+ joint.CreateLowerLimitAttr(float(j["limits"][0]))
710
+ joint.CreateUpperLimitAttr(float(j["limits"][1]))
711
+ joint.CreateCollisionEnabledAttr(False)
712
+ drive = UsdPhysics.DriveAPI.Apply(joint.GetPrim(), "angular" if j["kind"] == "revolute" else "linear")
713
+ drive.CreateTypeAttr("force")
714
+ drive.CreateStiffnessAttr(float(j["drive"]["stiffness"]))
715
+ drive.CreateDampingAttr(float(j["drive"]["damping"]))
716
+ drive.CreateTargetPositionAttr(0.0)
717
+ if len(self.bodies) > 1 and len(self.joints) < len(self.bodies) - 1:
718
+ warnings.append(
719
+ f"{len(self.bodies)} bodies but {len(self.joints)} joints: unjointed bodies are loose parts"
720
+ )
721
+
722
+ allpts = np.vstack([pts for pts, _, _, _ in preview])
723
+ lo, hi = allpts.min(0), allpts.max(0)
724
+ UsdGeom.ModelAPI(root).SetExtentsHint(Vt.Vec3fArray([vec(lo), vec(hi)]))
725
+ today = datetime.date.today().isoformat()
726
+ metadata = {
727
+ "asset_name": self.name,
728
+ "asset_type": "prop",
729
+ "author": self.author,
730
+ "asset_license": self.license,
731
+ "category": self.category,
732
+ "description": self.description,
733
+ "qcode": self.qcode,
734
+ "usd_date_generated": today,
735
+ "source_file": Path(sys.argv[0]).name,
736
+ "rigid_body_count": len(self.bodies),
737
+ "joint_count": len(self.joints),
738
+ "mass": round(total_mass, 4),
739
+ "asset_extents": vec(hi - lo),
740
+ "validation": {"profile": PROFILE, "profile_version": PROFILE_VERSION},
741
+ }
742
+ stage.GetRootLayer().customLayerData = {
743
+ "SimReady_Metadata": metadata,
744
+ "asset_name": self.name,
745
+ "asset_type": "prop",
746
+ "source_file": metadata["source_file"],
747
+ "usd_date_generated": today,
748
+ }
749
+ stage.GetRootLayer().documentation = self.description or self.label
750
+ stage.GetRootLayer().Export(str(path))
751
+ thumbnail(preview, usd_dir / ".thumbs" / "256x256" / f"{self.name}.usda.png")
752
+ model = {
753
+ "name": self.name,
754
+ "bodies": model_bodies,
755
+ "joints": self.joints,
756
+ "size_m": np.round(hi - lo, 4).tolist(),
757
+ "mass_kg": round(total_mass, 3),
758
+ "triangles": triangles,
759
+ "preview": preview,
760
+ }
761
+ return path, model, warnings
762
+
763
+
764
+ def _loads(bodies, joints):
765
+ """Each joint's load: the mass of its child body and everything jointed beyond it, that mass's centre (m, asset
766
+ frame) and its moment of inertia about the joint's axis (kg*m^2)."""
767
+ from .export import inertia
768
+
769
+ centres = {}
770
+ for b in bodies:
771
+ com, tensor = inertia(b)
772
+ centres[b["name"]] = (b["mass"], b["origin"] + com, tensor)
773
+ children = {}
774
+ for j in joints:
775
+ children.setdefault(j["body0"].name, []).append(j["body1"].name)
776
+ out = {}
777
+ for j in joints:
778
+ todo, seen = [j["body1"].name], []
779
+ while todo:
780
+ body = todo.pop()
781
+ if body not in seen:
782
+ seen.append(body)
783
+ todo += children.get(body, [])
784
+ mass = sum(centres[b][0] for b in seen)
785
+ centre = sum(centres[b][0] * centres[b][1] for b in seen) / mass
786
+ axis = np.eye(3)[AXES.index(j["axis"])]
787
+ moment = 0.0
788
+ for b in seen:
789
+ m, c, tensor = centres[b]
790
+ arm = c - j["anchor"]
791
+ moment += float(axis @ tensor @ axis) + m * float(arm @ arm - (arm @ axis) ** 2)
792
+ out[j["name"]] = (mass, centre, moment)
793
+ return out
794
+
795
+
796
+ def _drive(j, mass, centre, moment, warnings):
797
+ """The drive the joint is written with: stiffness and damping as given, or sized to its load. Revolute values are
798
+ per degree (N*m/deg, N*m*s/deg), as UsdPhysics writes them; prismatic per metre (N/m, N*s/m)."""
799
+ given, weight = j["given"], mass * G
800
+ axis = np.eye(3)[AXES.index(j["axis"])]
801
+ arm = centre - j["anchor"]
802
+ reach = float(np.linalg.norm(arm - (arm @ axis) * axis)) # the load's centre from the axis
803
+ stiffness = given["stiffness"] if given["stiffness"] is not None else 0.0
804
+ if j["kind"] == "revolute":
805
+ tilt = float(np.sqrt(max(0.0, 1.0 - axis[2] ** 2))) # 0 for a vertical axis: gravity cannot turn it
806
+ load = weight * reach * tilt # the most gravity can turn it by in any pose, N*m
807
+ lever = max(reach, 0.01)
808
+ per = math.degrees(1.0) # degrees per radian
809
+ if given["hold"] is not None:
810
+ stiffness = load / given["hold"]
811
+ critical = 2.0 * math.sqrt(stiffness * per * moment) / per
812
+ damping = given["damping"] if given["damping"] is not None else max(weight * lever / 90.0, 0.5 * critical)
813
+ push = max(weight * lever, 0.1) # N*m: the load's weight at its reach, or a light twist of a hand at least
814
+ speed = push / max(damping, 1e-12) # deg/s
815
+ rest = abs(float(np.cross(arm, (0.0, 0.0, -weight)) @ axis))
816
+ sag, units, slow, free = (rest / stiffness if stiffness else None), "deg", speed < 10.0, load > weight * 0.002
817
+ else:
818
+ load = weight * abs(float(axis[2]))
819
+ if given["hold"] is not None:
820
+ stiffness = load / given["hold"]
821
+ critical = 2.0 * math.sqrt(stiffness * mass)
822
+ damping = given["damping"] if given["damping"] is not None else max(weight, 0.5 * critical)
823
+ push = max(weight, 1.0) # N: the load's weight, or a light push of a finger at least
824
+ speed = push / max(damping, 1e-12) # m/s
825
+ sag, units, slow, free = (load / stiffness if stiffness else None), "m", speed < 0.02, abs(axis[2]) > 0.1
826
+ lo, hi = j["limits"]
827
+ if j["kind"] == "revolute" and math.isfinite(hi - lo) and (hi - lo > 360 or max(abs(lo), abs(hi)) > 360):
828
+ warnings.append(
829
+ f"joint {j['name']}: limits {lo:g} to {hi:g} deg pass one turn, and PhysX wraps a drive target past 360 "
830
+ f"degrees, so it cannot be driven there: keep it within one turn, or limits=None for a free spin"
831
+ )
832
+ if slow and given["damping"] is not None: # the kit's own sizing is fast enough by construction
833
+ warnings.append(
834
+ f"joint {j['name']}: damping {damping:.4g} nearly locks it: a light push moves it only "
835
+ f"{speed:.3g} {units}/s; leave damping=None to have it sized"
836
+ )
837
+ if free and not stiffness and not given.get("loose"):
838
+ warnings.append(
839
+ f"joint {j['name']}: gravity pulls its load along it and nothing holds it, so it falls to a limit; "
840
+ f"pass hold= if it should hold its pose"
841
+ )
842
+ return {
843
+ "stiffness": round(float(stiffness), 6),
844
+ "damping": round(float(damping), 6),
845
+ "sized": "hold"
846
+ if given["hold"] is not None
847
+ else "given"
848
+ if given["stiffness"] is not None
849
+ else "loose"
850
+ if given.get("loose")
851
+ else "free",
852
+ "damping_sized": given["damping"] is None,
853
+ "load_kg": round(mass, 4),
854
+ "rest_sag": None if sag is None else round(float(sag), 6),
855
+ "sag_units": units,
856
+ }
857
+
858
+
859
+ class _TextureNodes:
860
+ """The UsdUVTexture nodes of one material, sharing one primvar reader."""
861
+
862
+ def __init__(self, stage, mat, material, folder, value_types):
863
+ self.stage, self.mat, self.material, self.folder, self.T = stage, mat, material, folder, value_types
864
+ self.reader = None
865
+
866
+ def add(self, key, image, colour_space, output, value_type, scale_bias=None):
867
+ from pxr import Gf, Sdf, UsdShade
868
+
869
+ T = self.T
870
+ if self.reader is None:
871
+ self.reader = UsdShade.Shader.Define(self.stage, f"{self.mat.GetPath()}/st_reader")
872
+ self.reader.CreateIdAttr("UsdPrimvarReader_float2")
873
+ self.reader.CreateInput("varname", T.String).Set("st")
874
+ self.reader.CreateOutput("result", T.Float2)
875
+ file = _save_texture(image, self.folder, f"{self.material.name}_{key}.png")
876
+ self.material.files[key] = self.folder / file
877
+ node = UsdShade.Shader.Define(self.stage, f"{self.mat.GetPath()}/{key}_texture")
878
+ node.CreateIdAttr("UsdUVTexture")
879
+ node.CreateInput("file", T.Asset).Set(Sdf.AssetPath(f"./textures/{file}"))
880
+ node.CreateInput("sourceColorSpace", T.Token).Set(colour_space)
881
+ node.CreateInput("wrapS", T.Token).Set("repeat")
882
+ node.CreateInput("wrapT", T.Token).Set("repeat")
883
+ node.CreateInput("st", T.Float2).ConnectToSource(self.reader.GetOutput("result"))
884
+ if scale_bias:
885
+ node.CreateInput("scale", T.Float4).Set(Gf.Vec4f(*scale_bias[0]))
886
+ node.CreateInput("bias", T.Float4).Set(Gf.Vec4f(*scale_bias[1]))
887
+ return node.CreateOutput(output, value_type)
888
+
889
+
890
+ def _smooth(points, tris, angle=30.0):
891
+ """Welded points and one normal per triangle corner. A corner averages only the faces around its point that lie
892
+ within `angle` of its own face. Smoothing groups by connectivity merged two faces at 90 degrees wherever a fillet
893
+ joined them somewhere else, and shaded their hard edge dark."""
894
+ import trimesh
895
+
896
+ mesh = trimesh.Trimesh(points, tris, process=True)
897
+ pts, faces = np.asarray(mesh.vertices), np.asarray(mesh.faces)
898
+ face_normals = np.asarray(mesh.face_normals)
899
+ weighted = face_normals * np.asarray(mesh.area_faces)[:, None]
900
+ flat = faces.reshape(-1)
901
+ order = np.argsort(flat, kind="stable") # corners grouped by their point
902
+ counts = np.bincount(flat, minlength=len(pts))
903
+ starts = np.concatenate([[0], np.cumsum(counts)[:-1]])
904
+ group = counts[flat[order]]
905
+ corner = np.repeat(order, group) # every corner, paired with every corner around the same point
906
+ offset = np.arange(group.sum()) - np.repeat(np.cumsum(group) - group, group)
907
+ other = order[np.repeat(starts[flat[order]], group) + offset]
908
+ near = np.einsum("ij,ij->i", face_normals[corner // 3], face_normals[other // 3]) >= np.cos(np.radians(angle))
909
+ summed = np.zeros((len(flat), 3))
910
+ np.add.at(summed, corner[near], weighted[other[near] // 3])
911
+ length = np.linalg.norm(summed, axis=1, keepdims=True)
912
+ normals = np.where(length > 1e-12, summed / np.maximum(length, 1e-12), face_normals[np.arange(len(flat)) // 3])
913
+ return pts, faces, normals
914
+
915
+
916
+ def _uv(points, tris, material):
917
+ """A part's UVs, one pair per triangle corner, as the material's projection says."""
918
+ if material.projection == "box":
919
+ return _box_uv(points, tris, material.uv_scale, material.lengthwise)
920
+ if material.projection == "fit":
921
+ return _box_uv(points, tris, 1.0, material.lengthwise, fit=True)
922
+ return _cylinder_uv(_axis_onto_z(points, material.axis), tris)
923
+
924
+
925
+ def _axis_onto_z(points, axis):
926
+ """The points turned so a cylinder's axis lies along Z. About X the front (-Y) stays the front; about Y (a lens
927
+ pointing out of the front) the top (+Z) becomes it."""
928
+ x, y, z = points[:, 0], points[:, 1], points[:, 2]
929
+ if axis == "X":
930
+ return np.stack([-z, y, x], axis=1)
931
+ if axis == "Y":
932
+ return np.stack([x, -z, y], axis=1)
933
+ return points
934
+
935
+
936
+ def _cylinder_uv(points, tris):
937
+ """Wrapped once round the part about Z (u, its middle at -Y, growing anticlockwise seen from above) and once up it
938
+ (v); faces
939
+ that look along Z (a can's ends) get the part's box fitted, as 'fit' does."""
940
+ lo, hi = points.min(0), points.max(0)
941
+ centre = (lo + hi) / 2
942
+ corners = points[tris]
943
+ normal = np.cross(corners[:, 1] - corners[:, 0], corners[:, 2] - corners[:, 0])
944
+ normal /= np.maximum(np.linalg.norm(normal, axis=1, keepdims=True), 1e-12)
945
+ side = np.abs(normal[:, 2]) < 0.7
946
+ angle = np.arctan2(corners[..., 0] - centre[0], -(corners[..., 1] - centre[1])) # 0 at -Y, the front
947
+ u = (angle / (2 * np.pi) + 0.5) % 1.0 # the image's centre faces the front; its edges meet at the back
948
+ wrap = side[:, None] & (u.max(1, keepdims=True) - u > 0.5) # a triangle across the seam: unwrap its low corners
949
+ u = np.where(wrap, u + 1.0, u)
950
+ v = (corners[..., 2] - lo[2]) / max(hi[2] - lo[2], 1e-9)
951
+ uv = np.stack([u, v], axis=-1)
952
+ ends = _box_uv(points, tris, 1.0, fit=True).reshape(-1, 3, 2)
953
+ return np.where(side[:, None, None], uv, ends).reshape(-1, 2)
954
+
955
+
956
+ def _box_uv(points, tris, scale, lengthwise=False, fit=False):
957
+ """Box-projected UVs, one pair per triangle corner: each triangle takes the two axes across its normal."""
958
+ corners = points[tris]
959
+ normal = np.abs(np.cross(corners[:, 1] - corners[:, 0], corners[:, 2] - corners[:, 0]))
960
+ keep = np.array([[1, 2], [0, 2], [0, 1]])[normal.argmax(1)]
961
+ extent = points.max(0) - points.min(0)
962
+ if lengthwise: # u along the part's longer side of the two
963
+ keep = np.where((extent[keep[:, 1]] > extent[keep[:, 0]])[:, None], keep[:, ::-1], keep)
964
+ if fit: # once across the part's box on each face
965
+ corners, scale = (corners - points.min(0)) / np.maximum(extent, 1e-9), 1.0
966
+ uv = np.take_along_axis(corners, np.repeat(keep[:, None, :], 3, axis=1), axis=2)
967
+ return uv.reshape(-1, 2) * scale
968
+
969
+
970
+ def _colliders(points, tris, kind):
971
+ import trimesh
972
+
973
+ if kind == "none":
974
+ return []
975
+ if kind == "box":
976
+ box = trimesh.creation.box(bounds=[points.min(0), points.max(0)])
977
+ return [(np.asarray(box.vertices), np.asarray(box.faces))]
978
+ if kind == "convex_decomposition":
979
+ import coacd
980
+
981
+ coacd.set_log_level("error")
982
+ mesh = trimesh.Trimesh(points, tris, process=True)
983
+ parts = coacd.run_coacd(coacd.Mesh(mesh.vertices, mesh.faces), threshold=0.05, max_convex_hull=16)
984
+ return [_within_limit(np.asarray(v), np.asarray(f)) for v, f in parts]
985
+ hull = trimesh.Trimesh(points, tris, process=True).convex_hull
986
+ return [_within_limit(np.asarray(hull.vertices), np.asarray(hull.faces))]
987
+
988
+
989
+ def _within_limit(vertices, faces):
990
+ """A hull of at most HULL_POINTS points, as PhysX would cut it: the farthest-spread points, hulled again. A
991
+ finely tessellated round part (a filleted disc made 1134) keeps its shape to within a fraction of a millimetre."""
992
+ import trimesh
993
+
994
+ if len(vertices) <= HULL_POINTS:
995
+ return vertices, faces
996
+ keep = [int(np.argmax(np.linalg.norm(vertices - vertices.mean(0), axis=1)))]
997
+ distance = np.linalg.norm(vertices - vertices[keep[0]], axis=1)
998
+ while len(keep) < HULL_POINTS:
999
+ keep.append(int(np.argmax(distance)))
1000
+ distance = np.minimum(distance, np.linalg.norm(vertices - vertices[keep[-1]], axis=1))
1001
+ hull = trimesh.Trimesh(vertices[keep]).convex_hull
1002
+ return np.asarray(hull.vertices), np.asarray(hull.faces)
1003
+
1004
+
1005
+ def _save_texture(texture, folder, name):
1006
+ from PIL import Image
1007
+
1008
+ if isinstance(texture, (str, Path)):
1009
+ image = Image.open(texture)
1010
+ elif isinstance(texture, np.ndarray):
1011
+ image = Image.fromarray(texture.astype(np.uint8))
1012
+ else:
1013
+ image = texture
1014
+ image.convert("RGB").save(folder / name)
1015
+ return name
1016
+
1017
+
1018
+ def _linear(rgb):
1019
+ """An sRGB colour (0-1) as the linear colour UsdPreviewSurface and displayColor take."""
1020
+ return tuple(float(c) ** 2.2 for c in rgb)
1021
+
1022
+
1023
+ def _centre_of_mass(parts):
1024
+ """The volume x density weighted centre of a body's visible parts (asset frame), or None."""
1025
+ import trimesh
1026
+
1027
+ total, moment = 0.0, np.zeros(3)
1028
+ for part in parts:
1029
+ if not part.get("visible", True):
1030
+ continue
1031
+ mesh = trimesh.Trimesh(part["points"], part["tris"], process=True)
1032
+ solid = mesh if mesh.is_watertight and mesh.volume > 0 else mesh.convex_hull
1033
+ weight = part["volume"] * part["material"].density
1034
+ if weight > 0 and solid.volume > 0:
1035
+ total += weight
1036
+ moment += weight * np.asarray(solid.center_mass)
1037
+ return moment / total if total > 0 else None
1038
+
1039
+
1040
+ def _mean_colour(image):
1041
+ """The average colour of a texture: a path, a PIL image or an HxWx3 uint8 array."""
1042
+ from PIL import Image
1043
+
1044
+ if isinstance(image, (str, Path)):
1045
+ image = Image.open(image)
1046
+ pixels = np.asarray(image.convert("RGB") if hasattr(image, "convert") else image, dtype=float)[..., :3] / 255
1047
+ return tuple(float(x) for x in pixels.reshape(-1, 3).mean(0))