jolly-cli 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.
jolly/core/models.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from importlib.resources import files
5
+ from pathlib import Path
6
+
7
+ from jolly.core.errors import ConfigurationError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RobotModel:
12
+ id: str
13
+ name: str
14
+ urdf: str
15
+ arm_joint_names: tuple[str, ...]
16
+ gripper_joint_names: tuple[str, ...]
17
+ end_effector_link: str
18
+ home_degrees: tuple[float, ...]
19
+ description: str
20
+ source: str
21
+ license: str
22
+
23
+ @property
24
+ def dof(self) -> int:
25
+ return len(self.arm_joint_names)
26
+
27
+ def export(self) -> dict[str, object]:
28
+ data = asdict(self)
29
+ data["dof"] = self.dof
30
+ return data
31
+
32
+
33
+ MODELS: dict[str, RobotModel] = {
34
+ "jolly6": RobotModel(
35
+ id="jolly6",
36
+ name="Jolly-6",
37
+ urdf="jolly6.urdf",
38
+ arm_joint_names=(
39
+ "base_yaw",
40
+ "shoulder_pitch",
41
+ "elbow_pitch",
42
+ "wrist_pitch",
43
+ "wrist_yaw",
44
+ "wrist_roll",
45
+ ),
46
+ gripper_joint_names=("left_finger", "right_finger"),
47
+ end_effector_link="tool_link",
48
+ home_degrees=(0.0, -25.0, 70.0, -45.0, 0.0, 0.0),
49
+ description="Original six-axis educational arm with a parallel gripper.",
50
+ source="Bundled with Jolly",
51
+ license="MIT OR Apache-2.0",
52
+ ),
53
+ "so101": RobotModel(
54
+ id="so101",
55
+ name="SO-101 simplified simulation profile",
56
+ urdf="so101.urdf",
57
+ arm_joint_names=(
58
+ "shoulder_pan",
59
+ "shoulder_lift",
60
+ "elbow_flex",
61
+ "wrist_flex",
62
+ "wrist_roll",
63
+ ),
64
+ gripper_joint_names=("gripper",),
65
+ end_effector_link="tool_link",
66
+ home_degrees=(0.0, 0.0, 0.0, 0.0, 0.0),
67
+ description="Lightweight collision model that follows the open SO-101 five-axis kinematic layout.",
68
+ source="Derived from TheRobotStudio/SO-ARM100 SO-101 URDF metadata",
69
+ license="Apache-2.0",
70
+ ),
71
+ }
72
+
73
+
74
+ def get_model(model_id: str) -> RobotModel:
75
+ try:
76
+ return MODELS[model_id]
77
+ except KeyError as exc:
78
+ available = ", ".join(sorted(MODELS))
79
+ raise ConfigurationError(f"Unknown robot model '{model_id}'. Available: {available}") from exc
80
+
81
+
82
+ def model_path(model: RobotModel) -> Path:
83
+ return Path(str(files("jolly.assets").joinpath(model.urdf)))
84
+
85
+
86
+ def list_models() -> list[dict[str, object]]:
87
+ return [MODELS[key].export() for key in sorted(MODELS)]
jolly/core/physics.py ADDED
@@ -0,0 +1,516 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ import sys
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Iterable, Sequence
9
+
10
+ def _import_pybullet():
11
+ """Import PyBullet without its unconditional native build-time banner."""
12
+ try:
13
+ output_fds = [sys.stdout.fileno(), sys.stderr.fileno()]
14
+ except (AttributeError, OSError):
15
+ output_fds = []
16
+ if not output_fds or os.name == "nt":
17
+ import pybullet
18
+
19
+ return pybullet
20
+ saved_fds = [os.dup(fd) for fd in output_fds]
21
+ try:
22
+ with open(os.devnull, "w", encoding="utf-8") as null:
23
+ for fd in output_fds:
24
+ os.dup2(null.fileno(), fd)
25
+ import pybullet
26
+ finally:
27
+ for fd, saved_fd in zip(output_fds, saved_fds, strict=True):
28
+ os.dup2(saved_fd, fd)
29
+ os.close(saved_fd)
30
+ return pybullet
31
+
32
+
33
+ p = _import_pybullet()
34
+
35
+ from jolly.core.errors import MotionError
36
+ from jolly.core.models import RobotModel, get_model, model_path
37
+ from jolly.core.scenes import get_scene
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class JointInfo:
42
+ index: int
43
+ q_index: int
44
+ name: str
45
+ link_name: str
46
+ lower: float
47
+ upper: float
48
+ max_force: float
49
+ max_velocity: float
50
+
51
+
52
+ class PhysicsEngine:
53
+ """Own a PyBullet world and expose deterministic robot-arm operations."""
54
+
55
+ timestep = 1.0 / 240.0
56
+
57
+ def __init__(
58
+ self,
59
+ model: str = "jolly6",
60
+ scene: str = "empty",
61
+ *,
62
+ gui: bool = False,
63
+ realtime: bool = False,
64
+ ) -> None:
65
+ self.model: RobotModel = get_model(model)
66
+ self.scene_id = scene
67
+ self.gui = gui
68
+ self.realtime = realtime
69
+ self.client = p.connect(p.GUI if gui else p.DIRECT)
70
+ if self.client < 0:
71
+ raise RuntimeError("PyBullet could not create a physics client.")
72
+ self.robot_id = -1
73
+ self.plane_id = -1
74
+ self.joints: dict[str, JointInfo] = {}
75
+ self.arm_joints: list[JointInfo] = []
76
+ self.gripper_joints: list[JointInfo] = []
77
+ self.ik_solution_indexes: dict[int, int] = {}
78
+ self.end_effector_index = -1
79
+ self.object_ids: dict[str, int] = {}
80
+ self.object_specs: dict[str, dict[str, object]] = {}
81
+ self.held_object: str | None = None
82
+ self.gripper = 0.0
83
+ try:
84
+ self._load_world()
85
+ except Exception:
86
+ p.disconnect(self.client)
87
+ raise
88
+
89
+ def __enter__(self) -> "PhysicsEngine":
90
+ return self
91
+
92
+ def __exit__(self, *_: object) -> None:
93
+ self.close()
94
+
95
+ def close(self) -> None:
96
+ if p.isConnected(self.client):
97
+ p.disconnect(self.client)
98
+
99
+ def _load_world(self) -> None:
100
+ p.resetSimulation(physicsClientId=self.client)
101
+ p.setGravity(0, 0, -9.81, physicsClientId=self.client)
102
+ p.setTimeStep(self.timestep, physicsClientId=self.client)
103
+ p.setPhysicsEngineParameter(
104
+ fixedTimeStep=self.timestep,
105
+ numSolverIterations=100,
106
+ deterministicOverlappingPairs=1,
107
+ physicsClientId=self.client,
108
+ )
109
+ plane_shape = p.createCollisionShape(p.GEOM_PLANE, physicsClientId=self.client)
110
+ self.plane_id = p.createMultiBody(0, plane_shape, physicsClientId=self.client)
111
+ p.changeVisualShape(self.plane_id, -1, rgbaColor=[0.13, 0.15, 0.18, 1], physicsClientId=self.client)
112
+ self.robot_id = p.loadURDF(
113
+ str(model_path(self.model)),
114
+ useFixedBase=True,
115
+ flags=p.URDF_USE_SELF_COLLISION | p.URDF_USE_INERTIA_FROM_FILE,
116
+ physicsClientId=self.client,
117
+ )
118
+ self._index_robot()
119
+ self._load_scene()
120
+ self.reset()
121
+
122
+ def _index_robot(self) -> None:
123
+ for index in range(p.getNumJoints(self.robot_id, physicsClientId=self.client)):
124
+ raw = p.getJointInfo(self.robot_id, index, physicsClientId=self.client)
125
+ info = JointInfo(
126
+ index=index,
127
+ q_index=int(raw[3]),
128
+ name=raw[1].decode(),
129
+ link_name=raw[12].decode(),
130
+ lower=float(raw[8]),
131
+ upper=float(raw[9]),
132
+ max_force=float(raw[10]) or 25.0,
133
+ max_velocity=float(raw[11]) or 2.0,
134
+ )
135
+ self.joints[info.name] = info
136
+ if info.link_name == self.model.end_effector_link:
137
+ self.end_effector_index = index
138
+ try:
139
+ self.arm_joints = [self.joints[name] for name in self.model.arm_joint_names]
140
+ self.gripper_joints = [self.joints[name] for name in self.model.gripper_joint_names]
141
+ except KeyError as exc:
142
+ raise RuntimeError(f"Robot model is missing expected joint {exc.args[0]!r}.") from exc
143
+ if self.end_effector_index < 0:
144
+ raise RuntimeError(f"Robot model is missing end-effector link '{self.model.end_effector_link}'.")
145
+ movable = sorted((info for info in self.joints.values() if info.q_index >= 0), key=lambda info: info.q_index)
146
+ self.ik_solution_indexes = {info.index: offset for offset, info in enumerate(movable)}
147
+
148
+ def _load_scene(self) -> None:
149
+ scene = get_scene(self.scene_id)
150
+ self.object_ids.clear()
151
+ self.object_specs.clear()
152
+ for raw_spec in scene["objects"]:
153
+ spec = dict(raw_spec)
154
+ half_extents = [float(value) / 2.0 for value in spec["size"]]
155
+ collision = p.createCollisionShape(p.GEOM_BOX, halfExtents=half_extents, physicsClientId=self.client)
156
+ visual = p.createVisualShape(
157
+ p.GEOM_BOX,
158
+ halfExtents=half_extents,
159
+ rgbaColor=spec["color"],
160
+ physicsClientId=self.client,
161
+ )
162
+ body = p.createMultiBody(
163
+ baseMass=float(spec["mass"]),
164
+ baseCollisionShapeIndex=collision,
165
+ baseVisualShapeIndex=visual,
166
+ basePosition=spec["position"],
167
+ physicsClientId=self.client,
168
+ )
169
+ name = str(spec["name"])
170
+ self.object_ids[name] = body
171
+ self.object_specs[name] = spec
172
+ p.changeDynamics(body, -1, lateralFriction=0.8, restitution=0.05, physicsClientId=self.client)
173
+
174
+ def reset(self) -> dict[str, object]:
175
+ self.held_object = None
176
+ self.gripper = 0.0
177
+ for info, degrees in zip(self.arm_joints, self.model.home_degrees, strict=True):
178
+ p.resetJointState(self.robot_id, info.index, math.radians(degrees), physicsClientId=self.client)
179
+ self._set_gripper_state(0.0)
180
+ self._hold_current_pose()
181
+ self._step(60)
182
+ return self.state()
183
+
184
+ def restore(self, state: dict[str, object]) -> None:
185
+ joints = state.get("joints", [])
186
+ if isinstance(joints, list) and len(joints) == self.model.dof:
187
+ for info, joint in zip(self.arm_joints, joints, strict=True):
188
+ value = float(joint["position_degrees"] if isinstance(joint, dict) else joint)
189
+ p.resetJointState(self.robot_id, info.index, math.radians(value), physicsClientId=self.client)
190
+ self.gripper = float(state.get("gripper", 0.0))
191
+ self._set_gripper_state(self.gripper)
192
+ saved_objects = state.get("objects", [])
193
+ if isinstance(saved_objects, list):
194
+ for item in saved_objects:
195
+ if not isinstance(item, dict) or item.get("name") not in self.object_ids:
196
+ continue
197
+ p.resetBasePositionAndOrientation(
198
+ self.object_ids[str(item["name"])],
199
+ item.get("position", [0, 0, 0]),
200
+ item.get("orientation", [0, 0, 0, 1]),
201
+ physicsClientId=self.client,
202
+ )
203
+ held = state.get("held_object")
204
+ self.held_object = str(held) if held in self.object_ids else None
205
+ self._sync_held_object()
206
+ self._hold_current_pose()
207
+ p.performCollisionDetection(physicsClientId=self.client)
208
+
209
+ def move_joints(
210
+ self,
211
+ degrees: Sequence[float],
212
+ *,
213
+ gripper: float | None = None,
214
+ steps: int = 120,
215
+ allow_collision: bool = False,
216
+ ) -> dict[str, object]:
217
+ if len(degrees) != self.model.dof:
218
+ raise MotionError(f"Model '{self.model.id}' needs {self.model.dof} joint angles, received {len(degrees)}.")
219
+ if not all(math.isfinite(float(value)) for value in degrees):
220
+ raise MotionError("Joint angles must be finite numbers.")
221
+ targets = [math.radians(float(value)) for value in degrees]
222
+ for target, info in zip(targets, self.arm_joints, strict=True):
223
+ if not info.lower <= target <= info.upper:
224
+ raise MotionError(
225
+ f"Joint '{info.name}' target {math.degrees(target):.2f}° is outside "
226
+ f"[{math.degrees(info.lower):.2f}°, {math.degrees(info.upper):.2f}°]."
227
+ )
228
+ start = [p.getJointState(self.robot_id, info.index, physicsClientId=self.client)[0] for info in self.arm_joints]
229
+ old_gripper = self.gripper
230
+ old_held_object = self.held_object
231
+ old_objects = self._object_poses()
232
+ steps = max(1, min(int(steps), 2400))
233
+ collision_detected = False
234
+ for frame in range(1, steps + 1):
235
+ blend = frame / steps
236
+ values = [a + (b - a) * blend for a, b in zip(start, targets, strict=True)]
237
+ for info, value in zip(self.arm_joints, values, strict=True):
238
+ p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
239
+ if gripper is not None:
240
+ self._set_gripper_state(old_gripper + (float(gripper) - old_gripper) * blend)
241
+ self._sync_held_object()
242
+ p.performCollisionDetection(physicsClientId=self.client)
243
+ if self.collisions()["collision"]:
244
+ collision_detected = True
245
+ if not allow_collision:
246
+ break
247
+ if self.realtime:
248
+ time.sleep(self.timestep)
249
+ if collision_detected and not allow_collision:
250
+ for info, value in zip(self.arm_joints, start, strict=True):
251
+ p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
252
+ self._set_gripper_state(old_gripper)
253
+ self._restore_object_poses(old_objects)
254
+ raise MotionError("Motion stopped because the robot collided. Use --allow-collision only for controlled tests.")
255
+ if gripper is not None:
256
+ self._set_gripper_state(float(gripper))
257
+ self._update_grasp()
258
+ self._hold_current_pose()
259
+ self._step(8)
260
+ self._clamp_arm_to_limits()
261
+ final_collisions = self.collisions()
262
+ if final_collisions["collision"] and not allow_collision:
263
+ for info, value in zip(self.arm_joints, start, strict=True):
264
+ p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
265
+ self._set_gripper_state(old_gripper)
266
+ self.held_object = old_held_object
267
+ self._restore_object_poses(old_objects)
268
+ self._hold_current_pose()
269
+ raise MotionError("Motion rolled back because settling created a collision.")
270
+ return self.state()
271
+
272
+ def _clamp_arm_to_limits(self) -> None:
273
+ for info in self.arm_joints:
274
+ value = p.getJointState(self.robot_id, info.index, physicsClientId=self.client)[0]
275
+ clamped = min(max(value, info.lower), info.upper)
276
+ if clamped != value:
277
+ p.resetJointState(self.robot_id, info.index, clamped, physicsClientId=self.client)
278
+ self._hold_current_pose()
279
+
280
+ def reach(
281
+ self,
282
+ x: float,
283
+ y: float,
284
+ z: float,
285
+ *,
286
+ gripper: float | None = None,
287
+ steps: int = 160,
288
+ allow_collision: bool = False,
289
+ tolerance: float = 0.025,
290
+ ) -> dict[str, object]:
291
+ target = [float(x), float(y), float(z)]
292
+ if not all(math.isfinite(value) for value in target):
293
+ raise MotionError("Target coordinates must be finite numbers.")
294
+ if z < 0.005 or math.sqrt(x * x + y * y + z * z) > 0.75:
295
+ raise MotionError("Target is outside the simulator safety workspace.")
296
+ solution = p.calculateInverseKinematics(
297
+ self.robot_id,
298
+ self.end_effector_index,
299
+ target,
300
+ maxNumIterations=300,
301
+ residualThreshold=1e-5,
302
+ physicsClientId=self.client,
303
+ )
304
+ degrees: list[float] = []
305
+ for info in self.arm_joints:
306
+ solution_index = self.ik_solution_indexes.get(info.index, -1)
307
+ if solution_index < 0 or solution_index >= len(solution):
308
+ raise MotionError(f"IK did not return a value for joint '{info.name}'.")
309
+ radians = min(max(float(solution[solution_index]), info.lower), info.upper)
310
+ degrees.append(math.degrees(radians))
311
+ result = self.move_joints(degrees, gripper=gripper, steps=steps, allow_collision=allow_collision)
312
+ actual = result["end_effector"]["position"]
313
+ error = math.dist(actual, target)
314
+ result["ik"] = {"target": target, "error_meters": round(error, 6), "within_tolerance": error <= tolerance}
315
+ if error > tolerance:
316
+ raise MotionError(f"IK target was not reachable within {tolerance:.3f} m; residual error is {error:.3f} m.")
317
+ return result
318
+
319
+ def _set_gripper_state(self, value: float) -> None:
320
+ if not 0.0 <= value <= 1.0:
321
+ raise MotionError("Gripper must be between 0.0 (open) and 1.0 (closed).")
322
+ self.gripper = float(value)
323
+ for info in self.gripper_joints:
324
+ target = info.upper * (1.0 - self.gripper)
325
+ p.resetJointState(self.robot_id, info.index, target, physicsClientId=self.client)
326
+
327
+ def _hold_current_pose(self) -> None:
328
+ for info in [*self.arm_joints, *self.gripper_joints]:
329
+ target = p.getJointState(self.robot_id, info.index, physicsClientId=self.client)[0]
330
+ p.setJointMotorControl2(
331
+ self.robot_id,
332
+ info.index,
333
+ p.POSITION_CONTROL,
334
+ targetPosition=target,
335
+ force=max(info.max_force, 10.0),
336
+ maxVelocity=max(info.max_velocity, 0.2),
337
+ physicsClientId=self.client,
338
+ )
339
+
340
+ def _update_grasp(self) -> None:
341
+ if self.gripper <= 0.25:
342
+ self.held_object = None
343
+ return
344
+ if self.gripper < 0.75 or self.held_object is not None:
345
+ return
346
+ ee = self.end_effector_position()
347
+ candidates: list[tuple[float, str]] = []
348
+ for name, body in self.object_ids.items():
349
+ if not self.object_specs[name].get("graspable"):
350
+ continue
351
+ position, _ = p.getBasePositionAndOrientation(body, physicsClientId=self.client)
352
+ candidates.append((math.dist(ee, position), name))
353
+ if candidates:
354
+ distance, name = min(candidates)
355
+ if distance <= 0.10:
356
+ self.held_object = name
357
+ self._sync_held_object()
358
+
359
+ def _sync_held_object(self) -> None:
360
+ if self.held_object is None or self.held_object not in self.object_ids:
361
+ return
362
+ position = self.end_effector_position()
363
+ held_position = [position[0], position[1], max(0.02, position[2] - 0.045)]
364
+ p.resetBasePositionAndOrientation(
365
+ self.object_ids[self.held_object], held_position, [0, 0, 0, 1], physicsClientId=self.client
366
+ )
367
+
368
+ def _step(self, count: int) -> None:
369
+ for _ in range(count):
370
+ self._sync_held_object()
371
+ p.stepSimulation(physicsClientId=self.client)
372
+ if self.realtime:
373
+ time.sleep(self.timestep)
374
+
375
+ def end_effector_position(self) -> list[float]:
376
+ state = p.getLinkState(
377
+ self.robot_id,
378
+ self.end_effector_index,
379
+ computeForwardKinematics=True,
380
+ physicsClientId=self.client,
381
+ )
382
+ return [float(value) for value in state[4]]
383
+
384
+ def forward_kinematics(self, degrees: Sequence[float] | None = None) -> dict[str, object]:
385
+ if degrees is not None:
386
+ if len(degrees) != self.model.dof:
387
+ raise MotionError(f"Forward kinematics needs {self.model.dof} joint angles.")
388
+ previous = [p.getJointState(self.robot_id, j.index, physicsClientId=self.client)[0] for j in self.arm_joints]
389
+ for info, value in zip(self.arm_joints, degrees, strict=True):
390
+ p.resetJointState(self.robot_id, info.index, math.radians(float(value)), physicsClientId=self.client)
391
+ else:
392
+ previous = []
393
+ link = p.getLinkState(
394
+ self.robot_id,
395
+ self.end_effector_index,
396
+ computeForwardKinematics=True,
397
+ physicsClientId=self.client,
398
+ )
399
+ if previous:
400
+ for info, value in zip(self.arm_joints, previous, strict=True):
401
+ p.resetJointState(self.robot_id, info.index, value, physicsClientId=self.client)
402
+ quaternion = [float(value) for value in link[5]]
403
+ return {
404
+ "position": [round(float(value), 6) for value in link[4]],
405
+ "orientation_quaternion": [round(value, 6) for value in quaternion],
406
+ "orientation_euler_degrees": [round(math.degrees(value), 4) for value in p.getEulerFromQuaternion(quaternion)],
407
+ }
408
+
409
+ def collisions(self) -> dict[str, object]:
410
+ p.performCollisionDetection(physicsClientId=self.client)
411
+ details: list[dict[str, object]] = []
412
+ names_by_body = {body: name for name, body in self.object_ids.items()}
413
+ contacts = p.getContactPoints(bodyA=self.robot_id, physicsClientId=self.client)
414
+ for contact in contacts:
415
+ body_b = int(contact[2])
416
+ link_a = int(contact[3])
417
+ link_b = int(contact[4])
418
+ if self.held_object and body_b == self.object_ids.get(self.held_object):
419
+ continue
420
+ if body_b == self.plane_id and link_a == -1:
421
+ continue
422
+ if body_b == self.robot_id and abs(link_a - link_b) <= 1:
423
+ continue
424
+ details.append(
425
+ {
426
+ "type": "self" if body_b == self.robot_id else "environment",
427
+ "robot_link": self._link_name(link_a),
428
+ "other": self._link_name(link_b) if body_b == self.robot_id else names_by_body.get(body_b, "floor"),
429
+ "distance": round(float(contact[8]), 6),
430
+ "normal_force": round(float(contact[9]), 6),
431
+ }
432
+ )
433
+ if self.held_object and self.held_object in self.object_ids:
434
+ held_body = self.object_ids[self.held_object]
435
+ for contact in p.getContactPoints(bodyA=held_body, physicsClientId=self.client):
436
+ body_b = int(contact[2])
437
+ if body_b in (self.robot_id, held_body):
438
+ continue
439
+ details.append(
440
+ {
441
+ "type": "held_object_environment",
442
+ "robot_link": self.model.end_effector_link,
443
+ "other": names_by_body.get(body_b, "floor"),
444
+ "distance": round(float(contact[8]), 6),
445
+ "normal_force": round(float(contact[9]), 6),
446
+ }
447
+ )
448
+ return {"collision": bool(details), "count": len(details), "contacts": details}
449
+
450
+ def _link_name(self, index: int) -> str:
451
+ if index == -1:
452
+ return "base_link"
453
+ raw = p.getJointInfo(self.robot_id, index, physicsClientId=self.client)
454
+ return raw[12].decode()
455
+
456
+ def _object_poses(self) -> dict[str, tuple[Sequence[float], Sequence[float]]]:
457
+ return {
458
+ name: p.getBasePositionAndOrientation(body, physicsClientId=self.client)
459
+ for name, body in self.object_ids.items()
460
+ }
461
+
462
+ def _restore_object_poses(self, poses: dict[str, tuple[Sequence[float], Sequence[float]]]) -> None:
463
+ for name, (position, orientation) in poses.items():
464
+ p.resetBasePositionAndOrientation(
465
+ self.object_ids[name], position, orientation, physicsClientId=self.client
466
+ )
467
+
468
+ def state(self) -> dict[str, object]:
469
+ joint_states = []
470
+ for info in self.arm_joints:
471
+ raw = p.getJointState(self.robot_id, info.index, physicsClientId=self.client)
472
+ joint_states.append(
473
+ {
474
+ "name": info.name,
475
+ "position_degrees": round(math.degrees(float(raw[0])), 5),
476
+ "velocity_degrees_per_second": round(math.degrees(float(raw[1])), 5),
477
+ "limits_degrees": [round(math.degrees(info.lower), 4), round(math.degrees(info.upper), 4)],
478
+ }
479
+ )
480
+ objects = []
481
+ for name, body in self.object_ids.items():
482
+ position, orientation = p.getBasePositionAndOrientation(body, physicsClientId=self.client)
483
+ objects.append(
484
+ {
485
+ "name": name,
486
+ "position": [round(float(value), 6) for value in position],
487
+ "orientation": [round(float(value), 6) for value in orientation],
488
+ "graspable": bool(self.object_specs[name].get("graspable")),
489
+ }
490
+ )
491
+ return {
492
+ "ok": True,
493
+ "model": {"id": self.model.id, "name": self.model.name, "dof": self.model.dof},
494
+ "scene": self.scene_id,
495
+ "joints": joint_states,
496
+ "gripper": round(self.gripper, 4),
497
+ "end_effector": self.forward_kinematics(),
498
+ "collisions": self.collisions(),
499
+ "held_object": self.held_object,
500
+ "objects": objects,
501
+ }
502
+
503
+ def joint_positions(self) -> list[list[float]]:
504
+ points = [[0.0, 0.0, 0.0]]
505
+ for info in self.arm_joints:
506
+ state = p.getLinkState(self.robot_id, info.index, computeForwardKinematics=True, physicsClientId=self.client)
507
+ points.append([round(float(value), 6) for value in state[4]])
508
+ points.append([round(value, 6) for value in self.end_effector_position()])
509
+ return points
510
+
511
+ def run_viewer(self) -> None:
512
+ if not self.gui:
513
+ raise RuntimeError("Viewer requires gui=True.")
514
+ p.resetDebugVisualizerCamera(0.9, 40, -28, [0.18, 0, 0.14], physicsClientId=self.client)
515
+ while p.isConnected(self.client):
516
+ self._step(1)
jolly/core/scenes.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+
5
+ from jolly.core.errors import ConfigurationError
6
+
7
+
8
+ SCENES: dict[str, dict[str, object]] = {
9
+ "empty": {
10
+ "description": "A clear floor for kinematics and motion checks.",
11
+ "objects": [],
12
+ },
13
+ "blocks": {
14
+ "description": "Three graspable blocks and two marked target pads.",
15
+ "objects": [
16
+ {"name": "red_block", "kind": "box", "position": [0.30, -0.16, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.92, 0.18, 0.16, 1.0], "mass": 0.08, "graspable": True},
17
+ {"name": "blue_block", "kind": "box", "position": [0.32, 0.00, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.12, 0.45, 0.95, 1.0], "mass": 0.08, "graspable": True},
18
+ {"name": "green_block", "kind": "box", "position": [0.30, 0.16, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.16, 0.72, 0.28, 1.0], "mass": 0.08, "graspable": True},
19
+ {"name": "left_target", "kind": "box", "position": [0.16, -0.27, 0.006], "size": [0.12, 0.12, 0.012], "color": [0.96, 0.68, 0.12, 0.45], "mass": 0.0, "graspable": False},
20
+ {"name": "right_target", "kind": "box", "position": [0.16, 0.27, 0.006], "size": [0.12, 0.12, 0.012], "color": [0.68, 0.22, 0.92, 0.45], "mass": 0.0, "graspable": False},
21
+ ],
22
+ },
23
+ "shelf": {
24
+ "description": "A block must move from the floor onto a low shelf.",
25
+ "objects": [
26
+ {"name": "cargo", "kind": "box", "position": [0.28, -0.16, 0.03], "size": [0.06, 0.06, 0.06], "color": [0.15, 0.68, 0.92, 1.0], "mass": 0.10, "graspable": True},
27
+ {"name": "shelf", "kind": "box", "position": [0.28, 0.18, 0.12], "size": [0.22, 0.16, 0.025], "color": [0.52, 0.34, 0.18, 1.0], "mass": 0.0, "graspable": False},
28
+ {"name": "shelf_back", "kind": "box", "position": [0.39, 0.18, 0.21], "size": [0.025, 0.16, 0.20], "color": [0.42, 0.27, 0.14, 1.0], "mass": 0.0, "graspable": False},
29
+ ],
30
+ },
31
+ "obstacles": {
32
+ "description": "Two barriers create a collision-avoidance reaching task.",
33
+ "objects": [
34
+ {"name": "goal", "kind": "box", "position": [0.38, 0.00, 0.025], "size": [0.05, 0.05, 0.05], "color": [0.18, 0.82, 0.34, 1.0], "mass": 0.05, "graspable": True},
35
+ {"name": "barrier_left", "kind": "box", "position": [0.22, -0.09, 0.11], "size": [0.05, 0.09, 0.22], "color": [0.85, 0.22, 0.18, 1.0], "mass": 0.0, "graspable": False},
36
+ {"name": "barrier_right", "kind": "box", "position": [0.22, 0.09, 0.11], "size": [0.05, 0.09, 0.22], "color": [0.85, 0.22, 0.18, 1.0], "mass": 0.0, "graspable": False},
37
+ ],
38
+ },
39
+ }
40
+
41
+
42
+ def get_scene(scene_id: str) -> dict[str, object]:
43
+ try:
44
+ scene = deepcopy(SCENES[scene_id])
45
+ except KeyError as exc:
46
+ available = ", ".join(sorted(SCENES))
47
+ raise ConfigurationError(f"Unknown scene '{scene_id}'. Available: {available}") from exc
48
+ scene["id"] = scene_id
49
+ return scene
50
+
51
+
52
+ def list_scenes() -> list[dict[str, object]]:
53
+ return [
54
+ {"id": key, "description": str(SCENES[key]["description"]), "object_count": len(SCENES[key]["objects"])}
55
+ for key in sorted(SCENES)
56
+ ]