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/__init__.py +14 -0
- simready_forge/cli.py +221 -0
- simready_forge/cost.py +120 -0
- simready_forge/examples/__init__.py +0 -0
- simready_forge/examples/cabinet.py +124 -0
- simready_forge/export.py +170 -0
- simready_forge/isaac_probe.py +785 -0
- simready_forge/isaac_review.py +420 -0
- simready_forge/kit.py +1047 -0
- simready_forge/poses.py +293 -0
- simready_forge/prefs.py +105 -0
- simready_forge/preview.py +74 -0
- simready_forge/readback.py +177 -0
- simready_forge/review.py +167 -0
- simready_forge/scan.py +129 -0
- simready_forge/trellis.py +378 -0
- simready_forge/validate.py +66 -0
- simready_forge-0.1.0.dist-info/METADATA +165 -0
- simready_forge-0.1.0.dist-info/RECORD +22 -0
- simready_forge-0.1.0.dist-info/WHEEL +4 -0
- simready_forge-0.1.0.dist-info/entry_points.txt +2 -0
- simready_forge-0.1.0.dist-info/licenses/LICENSE +21 -0
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))
|