Skip to content

Commit d4e4b63

Browse files
Add mesh_detail attribute to select gripper mesh resolution
The gripper's Geometries() mesh doubles as the motion-planning collision geometry, so it was decimated to keep planning responsive. Add a mesh_detail attribute ("high" / "low", default "low") to both gripper models so a full-resolution gripper and a decimated one can run on the same machine for a planning/visual side-by-side. - meshes/gripper/ now holds high/ and low/ variants of each PLY; gen_gripper_meshes.py emits both detail levels. - meshes.go embeds meshes/gripper as an embed.FS; buildGripperMeshes reads the PLY for the requested detail level. - mesh_detail added to SO101GripperConfig and SO101SimulatedGripperConfig (validated; defaults to "low" so existing configs are unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 3a29ab6 commit d4e4b63

18 files changed

Lines changed: 21966 additions & 64 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ ifeq ($(VIAM_TARGET_OS), windows)
99
MODULE_BINARY = bin/arm.exe
1010
endif
1111

12-
$(MODULE_BINARY): Makefile go.mod *.go cmd/module/*.go so101.json meshes/so101/*.glb meshes/gripper/*.ply
12+
$(MODULE_BINARY): Makefile go.mod *.go cmd/module/*.go so101.json meshes/so101/*.glb meshes/gripper/*/*.ply
1313
GOOS=$(VIAM_BUILD_OS) GOARCH=$(VIAM_BUILD_ARCH) $(GO_BUILD_ENV) go build $(GO_BUILD_FLAGS) -o $(MODULE_BINARY) cmd/module/main.go
1414

1515
lint:

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ Follow the [arm setup steps](#first-time-arm-setup) to learn how to set up the a
293293
| `servo_id` | int | Optional | The servo ID for the gripper. Default is `6`. |
294294
| `timeout` | duration | Optional | Communication timeout. Default is system default. |
295295
| `gripper_type` | string | Optional | Which gripper meshes `Geometries()` serves for the 3D viewer: `follower` (moving jaw, default) or `leader` (thumb-loop handle + trigger). The moving part articulates with the live gripper opening. |
296+
| `mesh_detail` | string | Optional | Gripper mesh resolution: `low` (decimated, the default) or `high` (full resolution). `Geometries()` is also the motion-planning collision geometry, so `low` keeps planning fast; `high` is mainly for visual comparison. |
296297

297298
### Communication
298299

@@ -382,6 +383,7 @@ All attributes are optional, so the simulated gripper works with an empty config
382383
| Name | Type | Inclusion | Description |
383384
| -------------- | ------ | --------- | ---------------------------------------------------------------------------------------------------------- |
384385
| `gripper_type` | string | Optional | Which gripper meshes to serve: `follower` (moving jaw, the default) or `leader` (thumb-loop handle + trigger). |
386+
| `mesh_detail` | string | Optional | Gripper mesh resolution: `low` (decimated, the default) or `high` (full resolution). The mesh is also the motion-planning collision geometry, so `low` keeps planning fast; set `high` on a second gripper for a visual side-by-side. |
385387

386388
### DoCommand
387389

gripper.go

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ const (
2828
followerGripper = "follower"
2929
)
3030

31+
// Mesh detail levels selectable via the mesh_detail config attribute. The gripper
32+
// mesh is also the motion-planning collision geometry, so "low" (heavily decimated)
33+
// is the responsive default; "high" is full resolution, for visual comparison.
34+
const (
35+
highDetail = "high"
36+
lowDetail = "low"
37+
)
38+
3139
type SO101GripperConfig struct {
3240
Port string `json:"port,omitempty"`
3341
Baudrate int `json:"baudrate,omitempty"`
@@ -43,6 +51,11 @@ type SO101GripperConfig struct {
4351
// GripperType selects which meshes Geometries() serves: "follower" (moving
4452
// jaw, the default) or "leader" (thumb-loop handle + trigger).
4553
GripperType string `json:"gripper_type,omitempty"`
54+
55+
// MeshDetail selects the gripper mesh resolution Geometries() serves:
56+
// "low" (decimated, the default -- keeps motion planning fast) or "high"
57+
// (full resolution).
58+
MeshDetail string `json:"mesh_detail,omitempty"`
4659
}
4760

4861
// Validate ensures all parts of the config are valid
@@ -68,6 +81,11 @@ func (cfg *SO101GripperConfig) Validate(path string) ([]string, []string, error)
6881
leaderGripper, followerGripper, cfg.GripperType)
6982
}
7083

84+
if cfg.MeshDetail != "" && cfg.MeshDetail != highDetail && cfg.MeshDetail != lowDetail {
85+
return nil, nil, fmt.Errorf("mesh_detail must be %q or %q, got %q",
86+
highDetail, lowDetail, cfg.MeshDetail)
87+
}
88+
7189
return nil, nil, nil
7290
}
7391

@@ -78,6 +96,7 @@ type so101Gripper struct {
7896
logger logging.Logger
7997
controller *SafeSoArmController
8098
gripperType string
99+
meshDetail string
81100
servoID int
82101

83102
mu sync.Mutex
@@ -144,11 +163,17 @@ func newSO101Gripper(ctx context.Context, deps resource.Dependencies, conf resou
144163
gripperType = followerGripper
145164
}
146165

166+
meshDetail := cfg.MeshDetail
167+
if meshDetail == "" {
168+
meshDetail = lowDetail
169+
}
170+
147171
g := &so101Gripper{
148172
name: conf.ResourceName(),
149173
logger: logger,
150174
controller: controller,
151175
gripperType: gripperType,
176+
meshDetail: meshDetail,
152177
servoID: cfg.ServoID,
153178
speed: 30,
154179
acceleration: 50,
@@ -263,22 +288,26 @@ const (
263288

264289
// buildGripperMeshes returns the gripper's static body mesh plus the moving part
265290
// (jaw for the follower, trigger for the leader) posed at jawAngle radians about
266-
// the gripper joint. It is split out from Geometries so it can be tested without
267-
// a hardware controller.
268-
func buildGripperMeshes(gripperType string, jawAngle float64) ([]spatialmath.Geometry, error) {
269-
// Static meshes (all posed at the gripper body pose) plus the moving-part mesh.
270-
// The follower's body is one piece; the leader's is the wrist-roll part (which
271-
// connects to the arm) plus the handle attached to it.
272-
staticPLYs := [][]byte{so101FollowerBodyPLY}
273-
movingPLY := so101FollowerJawPLY
291+
// the gripper joint, at the requested mesh detail level ("high" or "low"). It is
292+
// split out from Geometries so it can be tested without a hardware controller.
293+
func buildGripperMeshes(gripperType, meshDetail string, jawAngle float64) ([]spatialmath.Geometry, error) {
294+
// Mesh names for this gripper variant: the static body part(s) plus the moving
295+
// part. The follower's body is one piece; the leader's is the wrist-roll part
296+
// (which connects to the arm) plus the handle attached to it.
297+
staticNames := []string{"follower_body"}
298+
movingName := "follower_jaw"
274299
if gripperType == leaderGripper {
275-
staticPLYs = [][]byte{so101LeaderWristRollPLY, so101LeaderBodyPLY}
276-
movingPLY = so101LeaderTriggerPLY
300+
staticNames = []string{"leader_wrist_roll", "leader_body"}
301+
movingName = "leader_trigger"
277302
}
278303

279304
bodyPose := spatialmath.Compose(toolFromGripperLink, gripperBodyPose)
280-
geoms := make([]spatialmath.Geometry, 0, len(staticPLYs)+1)
281-
for i, ply := range staticPLYs {
305+
geoms := make([]spatialmath.Geometry, 0, len(staticNames)+1)
306+
for i, name := range staticNames {
307+
ply, err := gripperMeshPLY(meshDetail, name)
308+
if err != nil {
309+
return nil, err
310+
}
282311
m, err := spatialmath.NewMeshFromProto(bodyPose,
283312
&commonpb.Mesh{ContentType: "ply", Mesh: ply}, fmt.Sprintf("gripper_body_%d", i))
284313
if err != nil {
@@ -294,6 +323,10 @@ func buildGripperMeshes(gripperType string, jawAngle float64) ([]spatialmath.Geo
294323
spatialmath.NewPose(r3.Vector{}, &spatialmath.EulerAngles{Yaw: jawAngle})),
295324
gripperMovingVisualPose,
296325
))
326+
movingPLY, err := gripperMeshPLY(meshDetail, movingName)
327+
if err != nil {
328+
return nil, err
329+
}
297330
moving, err := spatialmath.NewMeshFromProto(movingPose,
298331
&commonpb.Mesh{ContentType: "ply", Mesh: movingPLY}, "gripper_moving")
299332
if err != nil {
@@ -317,7 +350,7 @@ func (g *so101Gripper) jawAngle(ctx context.Context) float64 {
317350
// Geometries serves the gripper as meshes: a static body and a moving part posed by
318351
// the live gripper opening, so the jaw/trigger articulates in the 3D viewer.
319352
func (g *so101Gripper) Geometries(ctx context.Context, extra map[string]interface{}) ([]spatialmath.Geometry, error) {
320-
return buildGripperMeshes(g.gripperType, g.jawAngle(ctx))
353+
return buildGripperMeshes(g.gripperType, g.meshDetail, g.jawAngle(ctx))
321354
}
322355

323356
func (g *so101Gripper) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {

gripper_test.go

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,35 +9,48 @@ import (
99
)
1010

1111
func TestSO101GripperConfigValidate(t *testing.T) {
12-
// An unset gripper_type is accepted (defaults to follower).
12+
// An unset gripper_type / mesh_detail is accepted (defaults applied).
1313
_, _, err := (&SO101GripperConfig{Port: "/dev/ttyUSB0"}).Validate("")
1414
require.NoError(t, err)
1515

1616
for _, gt := range []string{leaderGripper, followerGripper} {
1717
_, _, err := (&SO101GripperConfig{Port: "/dev/ttyUSB0", GripperType: gt}).Validate("")
1818
require.NoError(t, err, "gripper_type %q should be valid", gt)
1919
}
20+
for _, md := range []string{highDetail, lowDetail} {
21+
_, _, err := (&SO101GripperConfig{Port: "/dev/ttyUSB0", MeshDetail: md}).Validate("")
22+
require.NoError(t, err, "mesh_detail %q should be valid", md)
23+
}
2024

2125
_, _, err = (&SO101GripperConfig{Port: "/dev/ttyUSB0", GripperType: "bogus"}).Validate("")
2226
require.Error(t, err)
27+
_, _, err = (&SO101GripperConfig{Port: "/dev/ttyUSB0", MeshDetail: "bogus"}).Validate("")
28+
require.Error(t, err)
2329
}
2430

2531
func TestBuildGripperMeshes(t *testing.T) {
26-
// Exercises the pose math and confirms each embedded PLY parses into a mesh.
32+
// Exercises the pose math, confirms each embedded PLY parses into a mesh at
33+
// both detail levels, and that "high" really is denser than "low".
2734
for _, tc := range []struct {
2835
gripperType string
2936
meshes int
3037
}{
3138
{followerGripper, 2}, // body + jaw
3239
{leaderGripper, 3}, // wrist-roll + handle + trigger
3340
} {
34-
geoms, err := buildGripperMeshes(tc.gripperType, 0.5)
35-
require.NoError(t, err, "gripper_type %q", tc.gripperType)
36-
require.Len(t, geoms, tc.meshes)
37-
for _, geom := range geoms {
38-
mesh, ok := geom.(*spatialmath.Mesh)
39-
require.True(t, ok, "gripper geometry should be a mesh")
40-
assert.NotEmpty(t, mesh.Triangles(), "mesh should have triangles")
41+
triCount := map[string]int{}
42+
for _, detail := range []string{highDetail, lowDetail} {
43+
geoms, err := buildGripperMeshes(tc.gripperType, detail, 0.5)
44+
require.NoError(t, err, "gripper_type %q detail %q", tc.gripperType, detail)
45+
require.Len(t, geoms, tc.meshes)
46+
for _, geom := range geoms {
47+
mesh, ok := geom.(*spatialmath.Mesh)
48+
require.True(t, ok, "gripper geometry should be a mesh")
49+
assert.NotEmpty(t, mesh.Triangles(), "mesh should have triangles")
50+
triCount[detail] += len(mesh.Triangles())
51+
}
4152
}
53+
assert.Greater(t, triCount[highDetail], triCount[lowDetail],
54+
"high detail should be denser than low for %q", tc.gripperType)
4255
}
4356
}

meshes.go

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package so_arm
22

33
import (
4-
_ "embed"
4+
"embed"
55

66
commonpb "go.viam.com/api/common/v1"
77
)
@@ -34,22 +34,18 @@ var so101EEFrameGLB []byte
3434

3535
// SO-101 gripper meshes (ASCII PLY, see meshes/gen_gripper_meshes.py). The gripper
3636
// component serves these via Geometries() as spatialmath.Mesh geometries: a static
37-
// body and a moving part (jaw for the follower, trigger for the leader).
38-
39-
//go:embed meshes/gripper/follower_body.ply
40-
var so101FollowerBodyPLY []byte
41-
42-
//go:embed meshes/gripper/follower_jaw.ply
43-
var so101FollowerJawPLY []byte
44-
45-
//go:embed meshes/gripper/leader_wrist_roll.ply
46-
var so101LeaderWristRollPLY []byte
47-
48-
//go:embed meshes/gripper/leader_body.ply
49-
var so101LeaderBodyPLY []byte
37+
// body and a moving part (jaw for the follower, trigger for the leader). Each mesh
38+
// is embedded at two detail levels -- meshes/gripper/{high,low}/ -- chosen by the
39+
// gripper's mesh_detail attribute.
40+
//
41+
//go:embed meshes/gripper
42+
var gripperMeshFS embed.FS
5043

51-
//go:embed meshes/gripper/leader_trigger.ply
52-
var so101LeaderTriggerPLY []byte
44+
// gripperMeshPLY returns the embedded PLY bytes for a gripper mesh ("follower_body",
45+
// "leader_trigger", ...) at the given detail level ("high" or "low").
46+
func gripperMeshPLY(detail, name string) ([]byte, error) {
47+
return gripperMeshFS.ReadFile("meshes/gripper/" + detail + "/" + name + ".ply")
48+
}
5349

5450
const gltfBinary = "model/gltf-binary"
5551

meshes/gen_gripper_meshes.py

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
written as ASCII PLY -- rdk's spatialmath.NewMeshFromProto accepts ASCII PLY only,
88
and the gripper component serves these via Geometries() as spatialmath.Mesh.
99
10+
Each mesh is written at two detail levels -- meshes/gripper/high/ and
11+
meshes/gripper/low/ -- chosen at runtime by the gripper's mesh_detail attribute.
12+
The gripper mesh doubles as the motion-planning collision geometry, so "low" is
13+
the responsive default and "high" is for visual comparison.
14+
1015
PLY vertices are kept in METERS (rdk's PLY reader scales x1000 to millimeters).
1116
1217
Run from the repo root: python3 meshes/gen_gripper_meshes.py
@@ -19,17 +24,20 @@
1924

2025
BASE = "https://raw.githubusercontent.com/TheRobotStudio/SO-ARM100/main"
2126

22-
# name -> (source URL, grid cell size in meters, source-unit-to-meters scale).
23-
# The follower simulation assets are authored in meters; the leader printable
24-
# STLs are in millimeters, so they need a 0.001 scale.
27+
# name -> (source URL, source-unit-to-meters scale). The follower simulation
28+
# assets are authored in meters; the leader printable STLs are in millimeters.
2529
MESHES = {
26-
"follower_body": (f"{BASE}/Simulation/SO101/assets/wrist_roll_follower_so101_v1.stl", 0.01, 1.0),
27-
"follower_jaw": (f"{BASE}/Simulation/SO101/assets/moving_jaw_so101_v1.stl", 0.01, 1.0),
28-
"leader_wrist_roll": (f"{BASE}/STL/SO101/Individual/Wrist_Roll_SO101.stl", 0.01, 0.001),
29-
"leader_body": (f"{BASE}/STL/SO101/Individual/Handle_SO101.stl", 0.01, 0.001),
30-
"leader_trigger": (f"{BASE}/STL/SO101/Individual/Trigger_SO101.stl", 0.01, 0.001),
30+
"follower_body": (f"{BASE}/Simulation/SO101/assets/wrist_roll_follower_so101_v1.stl", 1.0),
31+
"follower_jaw": (f"{BASE}/Simulation/SO101/assets/moving_jaw_so101_v1.stl", 1.0),
32+
"leader_wrist_roll": (f"{BASE}/STL/SO101/Individual/Wrist_Roll_SO101.stl", 0.001),
33+
"leader_body": (f"{BASE}/STL/SO101/Individual/Handle_SO101.stl", 0.001),
34+
"leader_trigger": (f"{BASE}/STL/SO101/Individual/Trigger_SO101.stl", 0.001),
3135
}
3236

37+
# detail level -> weld grid cell size in meters. A coarser grid welds more
38+
# vertices together, yielding far fewer faces (and far cheaper collision checks).
39+
GRIDS = {"high": 0.0015, "low": 0.01}
40+
3341

3442
def load_binary_stl(data):
3543
"""Return an (n, 3, 3) array of triangle vertices from binary STL bytes."""
@@ -75,20 +83,19 @@ def write_ascii_ply(path, verts, faces):
7583

7684

7785
def main():
78-
out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gripper")
79-
os.makedirs(out_dir, exist_ok=True)
80-
for name, (url, grid, scale) in MESHES.items():
86+
base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gripper")
87+
for name, (url, scale) in MESHES.items():
8188
with urllib.request.urlopen(url) as resp:
8289
stl = resp.read()
8390
verts = load_binary_stl(stl) * scale
84-
reps, faces = weld(verts, grid)
85-
path = os.path.join(out_dir, f"{name}.ply")
86-
write_ascii_ply(path, reps, faces)
87-
bb = verts.reshape(-1, 3)
88-
dims = bb.max(0) - bb.min(0)
89-
print(f"{name:16s} {len(verts):6d} tris -> {len(faces):6d} faces, "
90-
f"{len(reps):6d} verts, {os.path.getsize(path) / 1024:7.1f} KB "
91-
f"bbox(m) {dims[0]:.3f} x {dims[1]:.3f} x {dims[2]:.3f}")
91+
for detail, grid in GRIDS.items():
92+
out_dir = os.path.join(base_dir, detail)
93+
os.makedirs(out_dir, exist_ok=True)
94+
reps, faces = weld(verts, grid)
95+
path = os.path.join(out_dir, f"{name}.ply")
96+
write_ascii_ply(path, reps, faces)
97+
print(f"{detail:4s} {name:18s} {len(faces):6d} faces, "
98+
f"{os.path.getsize(path) / 1024:7.1f} KB")
9299

93100

94101
if __name__ == "__main__":

0 commit comments

Comments
 (0)