Сортировочная ячейка Isaac Sim: CV-пайплайн и меши товаров

Замкнутый контур "поток -> CV -> механика": товары идут по конвейеру с шагом 700 мм,
класс определяется стереопайплайном во время движения, пушер и плуг реагируют физически.

Состав:
* control_test/ - ячейка и CV. run_sorting_cv.py + cv_worker.py (два процесса, потому что
  torch внутри Isaac роняет сцену), cell.py (физика лент, плуга, пушера), measure_plane.py
  (замер габаритов), README.md и .memory.md с замерами, проблемами и ловушками
* robozon_sorter/ - модули симуляции, scripts/ - утилиты, scene/ - сцены
* assets/ - меши товаров, плуг, объекты Objaverse

Бейзлайн CV: DEFOM-Stereo vitl, вход 480, iters 24, кроп зоны осмотра, без сегментации.
На потоке 700 мм - классы 8/9, габариты MAE 32.8 мм, 469 мс на товар при такте 700 мс.

Веса моделей (4.5 ГБ) и пропсы конвейера NVIDIA (274 МБ) не включены - источники и
команды скачивания в MODELS.md. Выход прогонов (captures/, runtime/) не включён:
воспроизводится.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dasha_f
2026-08-01 13:07:24 +00:00
parent 6ce460378a
commit 0d32f32db0
342 changed files with 18000 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Simulation side: scene loading, cell mechanics, the self-running feeder.
Submodules are imported lazily - importing them here eagerly creates a cycle, because
mechanics imports names from scene while the package is still initialising.
"""
+91
View File
@@ -0,0 +1,91 @@
"""Through-beams across each lane entry: did the item actually get onto its lane, and at
what blade angle and sweep rate.
The delivery number alone cannot tune the plow. An item that ends on the floor and one that
never left the belt both score zero, but they need opposite corrections - the first was
pushed too hard, the second not hard enough. A beam at the lane entry separates them: it
fires the moment the item crosses onto the lane, so a run yields, per item,
crossed yes/no - did the push reach the lane at all
angle deg - where the blade was at the crossing
rate deg/s - how fast it was sweeping at that instant
speed m/s - how fast the item was going as it crossed
which is what the sweep rate is tuned against. A rate that crosses every item but at high
speed is throwing them; one that crosses none is too slow.
The beams are real `raycast_closest` queries, like the gate before the pusher, placed
**along the lane entry line** rather than across the belt - the item is travelling sideways
here, so the beam has to lie along the direction it is leaving.
"""
from __future__ import annotations
from .. import config as C
# Beams sit just inside each lane entry, spanning the lane's width in X, so anything pushed
# across breaks one. Y is the entry edge after scripts/move_lanes_inboard.py.
# Beams sit ON each lane, not at its entry line, so a break means "this item is riding the
# lane" rather than "this item touched the boundary". Each is an origin + direction + length,
# because lane C is laid at 45 deg and cannot be described by a y value the way B can.
#
# lane B perpendicular, x -7.03..-6.57, y -2.38..-0.38 -> beam across it at y = -0.80
# lane C 45 deg, near edge y = x + 7.637 -> beam across it at y = +0.90,
# where the lane occupies roughly x -7.6..-6.7
BEAMS = {
# lane C: straight run, belt x[-10.00,-8.00] y[-0.45,0.00]; beam across it at x = -8.60
"lane_C": dict(o=(-8.60, -0.50, C.BELT_Z + 0.03), d=(0.0, 1.0, 0.0), L=0.55),
# lane B: 45 deg band from (-7.84,0.16) to (-9.25,1.57); beam across it 0.7 m in,
# so its direction is the lane's perpendicular (0.707, 0.707), not a world axis.
"lane_B": dict(o=(-8.53, 0.46, C.BELT_Z + 0.03), d=(0.7071, 0.7071, 0.0), L=0.55),
}
ITEMS_PREFIX = "/World/Items/"
class LaneBeams:
"""crossing detector at each lane entry"""
def __init__(self, stage, cell, plow=None):
self.stage = stage
self.cell = cell
self.plow = plow
self.crossings: dict[str, dict] = {} # item -> first crossing record
self._t = 0.0
from omni.physx import get_physx_scene_query_interface
self._q = get_physx_scene_query_interface()
def tick(self, dt):
self._t += dt
def _hit(self, b):
"""name of whatever breaks this beam, else None"""
h = self._q.raycast_closest(list(b["o"]), list(b["d"]), b["L"])
if not h or not h.get("hit"):
return None
path = str(h.get("rigidBody") or h.get("collision") or "")
if not path.startswith(ITEMS_PREFIX):
return None
return path[len(ITEMS_PREFIX):].split("/")[0] or None
def poll(self, rate=None):
"""call each physics step; records the first crossing of each item"""
for lane, b in BEAMS.items():
name = self._hit(b)
if name is None or name in self.crossings:
continue
try:
v = self.cell._rp[name].get_velocities()[0].numpy()[0]
speed = float((v[0] ** 2 + v[1] ** 2 + v[2] ** 2) ** 0.5)
except Exception:
speed = 0.0
self.crossings[name] = dict(
item=name, lane=lane, t=round(self._t, 3),
angle=round(self.plow.angle, 1) if self.plow else None,
commanded=round(self.plow.commanded, 1) if self.plow else None,
rate=None if rate is None else round(rate, 1),
speed=round(speed, 2))
def crossed(self, name):
return name in self.crossings
def report(self):
return list(self.crossings.values())
+168
View File
@@ -0,0 +1,168 @@
"""Runtime side of the cell: releasing goods, the laser gate and the pusher stroke.
Two hard-won rules are encoded here and should not be "simplified" away:
1. During simulation, read poses from RigidPrim.get_world_poses(). BBoxCache / XformCache
return the AUTHORED transform, so a moving item looks frozen and every gate misfires.
2. The blade retracts only when (a) the pushed item has cleared the belt and (b) nothing
else is inside the blade's footprint. Retracting blindly sweeps the blade back through
the next item and knocks it over.
"""
from __future__ import annotations
import numpy as np
from pxr import Gf, UsdGeom
from .. import config as C
from .scene import BLADE as BLADE_PATH, ITEMS_ROOT, BLADE_PARENT_Y
class Cell:
def __init__(self, stage, items):
self.stage = stage
self.items = list(items)
self._blade_op = self._blade_translate_op()
self._blade_base = self._blade_op.Get()
from isaacsim.core.experimental.prims import RigidPrim
self._rp = {n: RigidPrim(paths=[f"{ITEMS_ROOT}/{n}"]) for n in self.items}
from omni.physx import get_physx_scene_query_interface
self._query = get_physx_scene_query_interface()
self.blade_to(C.BLADE_HOME_Y)
# -- blade --------------------------------------------------------------
def _blade_translate_op(self):
prim = self.stage.GetPrimAtPath(BLADE_PATH)
for op in UsdGeom.Xformable(prim).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
return op
raise RuntimeError(f"{BLADE_PATH} has no translate op to drive")
def blade_to(self, y):
"""y is a WORLD coordinate; the op lives in the diverter's frame"""
b = self._blade_base
self._blade_op.Set(Gf.Vec3d(b[0], y - BLADE_PARENT_Y, b[2]))
async def stroke(self, app, out=True, speed=None):
"""sweep the blade at a commanded m/s; fine steps keep the contact impulse sane.
Above ~2.5 m/s the kinematic blade throws goods off the line."""
speed = min(speed or C.PUSHER_SPEED, C.PUSHER_MAX_SAFE)
a, b = (C.BLADE_HOME_Y, C.BLADE_OUT_Y) if out else (C.BLADE_OUT_Y, C.BLADE_HOME_Y)
dt = 1.0 / 60.0
steps = max(4, int(round(abs(b - a) / max(speed * dt, 1e-6))))
for i in range(steps + 1):
self.blade_to(a + (b - a) * i / steps)
await app.update_app_async(steps=1)
return steps * dt
# -- item state ---------------------------------------------------------
def pose(self, name):
return self._rp[name].get_world_poses()[0].numpy()[0]
def place(self, name, pos):
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
for op in UsdGeom.Xformable(prim).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
op.Set(Gf.Vec3d(*pos))
return
if op.GetOpType() == UsdGeom.XformOp.TypeTransform:
M = Gf.Matrix4d(op.Get())
M.SetTranslateOnly(Gf.Vec3d(*pos))
op.Set(M)
return
def _underside_gap(self, name):
"""distance from the prim origin down to its lowest point, so it can be seated"""
cache = UsdGeom.BBoxCache(0, ["default", "render"], useExtentsHint=True)
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
r = cache.ComputeWorldBound(prim).ComputeAlignedRange()
if r.IsEmpty():
return 0.0
origin = UsdGeom.Xformable(prim).ComputeLocalToWorldTransform(0).ExtractTranslation()
return origin[2] - r.GetMin()[2]
def park(self, name, index=0):
self.place(name, (9.0 + 1.2 * index, 5.0, 0.4))
def park_all(self):
"""park everything AND freeze it, so the queue does not fall out of the world.
Parked items are ordinary dynamic bodies sitting off to the side at y ~ +5, where
there is no floor under them - so the whole undispatched queue free-falls for the
entire run. Measured: parked stock at z = -665 after a minute and the stage bound
reaching z = -20438, which also wrecks every "frame the whole scene" camera because
the scene is suddenly 20 km tall. Freezing them costs nothing and they are woken in
`release`, which clears the flag before placing the item on the belt.
"""
from pxr import UsdPhysics
for i, n in enumerate(self.items):
self.park(n, i)
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{n}")
if prim.IsValid() and prim.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
def _thaw(self, name):
"""let a parked item fall under gravity again, just before it is released"""
from pxr import UsdPhysics
prim = self.stage.GetPrimAtPath(f"{ITEMS_ROOT}/{name}")
if prim.IsValid() and prim.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
def release(self, name, y=0.0):
self._thaw(name)
self.place(name, (C.SPAWN_X, y, C.BELT_Z + 0.005))
self.place(name, (C.SPAWN_X, y, C.BELT_Z + self._underside_gap(name) + 0.008))
# -- laser gate ---------------------------------------------------------
def laser(self):
"""name of whatever breaks the beam, else None. A real raycast, not a coordinate test."""
hit = self._query.raycast_closest(
[C.GATE_X, C.BEAM_Y0, C.BEAM_Z], [0.0, 1.0, 0.0], C.BEAM_Y1 - C.BEAM_Y0)
if not hit or not hit.get("hit"):
return None
path = str(hit.get("rigidBody") or hit.get("collision") or "")
for n in self.items:
if f"{ITEMS_ROOT}/{n}" in path:
return n
return None
def blade_path_busy(self, exclude):
for n in self.items:
if n == exclude:
continue
p = self.pose(n)
if (C.BLADE_X0 - 0.12 < p[0] < C.BLADE_X1 + 0.12) and (-0.30 < p[1] < 0.45):
return n
return None
async def divert(self, app, name, speed=None, max_wait_s=1.5):
"""full push cycle with both retract interlocks"""
dt = 1.0 / 60.0
t = await self.stroke(app, out=True, speed=speed)
for _ in range(int(max_wait_s / dt)): # item off the main line
if self.pose(name)[1] > 0.50:
break
await app.update_app_async(steps=1)
t += dt
held = 0.0
for _ in range(int(max_wait_s / dt)): # path clear for the return
if self.blade_path_busy(name) is None:
break
await app.update_app_async(steps=1)
t += dt
held += dt
t += await self.stroke(app, out=False, speed=speed)
return t, held
# -- outcome ------------------------------------------------------------
def where(self, name):
"""bin / branch / line-end / line, from the simulated pose"""
p = self.pose(name)
if C.BIN_X0 < p[0] < C.BIN_X1 and C.BIN_Y0 < p[1] < C.BIN_Y1 and p[2] < C.BIN_LIP_Z:
return "bin"
if p[2] < C.BELT_Z - 0.35: # dropped off the end of the run
return "line-end"
if p[1] > 0.5:
return "branch"
if p[0] < C.MAIN_X0 + 0.35: # the run stops at MAIN_X0, not beyond it
return "line-end"
return "line"
+211
View File
@@ -0,0 +1,211 @@
"""The plow diverter (``DiverterEnd``) - the blade at the far end of the main run.
Mechanically it is the opposite of the pusher. The pusher is a kinematic slab shoved
across the belt from script; the plow is a **dynamic arm on a revolute joint driven by an
angular force drive**, so it is compliant - it yields on contact instead of teleporting
through cargo. That is why this module commands a drive target rather than writing a
transform the way ``mechanics.Cell.blade_to`` does.
Two consequences of that choice, both load-bearing:
1. **A drive target is a request, not a position.** The arm arrives when the solver gets it
there, and USD drive writes reach PhysX with a lag (about a second in the worst case
measured on the pusher's prismatic joint). Never assume the blade is where you last
commanded it - read :meth:`Plow.angle`, which measures the arm's actual pose.
2. **Rate is not free.** The authored graph swings 30 deg in 7 ms (72 rad/s). At that rate
the blade is an impulse and throws goods off the line. :meth:`Plow.step_toward` ramps the
target at ``config.PLOW_RATE`` instead, which is what makes the motion sortable.
The scene keeps its authored ``DiverterAnimGraph``, so pressing Play alone makes the cell
demonstrate itself. ``plow_cell.prepare(..., script_control=True)`` switches that graph off;
until it is off, the graph rewrites the drive target every tick and fights this module.
"""
from __future__ import annotations
import math
from pxr import UsdGeom, UsdPhysics
from .. import config as C
def _yaw_deg(quat) -> float:
"""Z rotation of a [w, x, y, z] quaternion, in degrees"""
w, x, y, z = (float(v) for v in quat)
return math.degrees(math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z)))
class Plow:
"""Angular control of the plow arm.
``angle`` is measured from the arm's rest pose, so it is signed the same way as the
authored throw: positive swings one way into the lane, negative the other.
"""
def __init__(self, stage, hinge_path: str | None = None, arm_path: str | None = None,
base_path: str | None = None, kinematic: bool = True):
self.stage = stage
self.hinge_path = hinge_path or C.PLOW_HINGE
self.arm_path = arm_path or C.PLOW_ARM
hinge = stage.GetPrimAtPath(self.hinge_path)
if not hinge.IsValid():
raise RuntimeError(
f"{self.hinge_path} missing - plow_cell.usd is the scene with the plow; "
"sorter.usd carries the pusher only")
self.drive = UsdPhysics.DriveAPI(hinge, "angular")
if not self.drive:
raise RuntimeError(f"{self.hinge_path} has no angular drive to command")
from isaacsim.core.experimental.prims import RigidPrim
self._arm = RigidPrim(paths=[self.arm_path])
# Reference the angle to the *base*, not to whatever pose the arm happened to hold
# when this object was built. The base is kinematic and both bodies read yaw +180
# at rest, so `yaw(arm) - yaw(base)` is the true joint angle and reads 0 at rest.
#
# Taking a snapshot instead was wrong and hid every other plow fault: a run that
# started with the arm left at -30 from the previous run reported "commanded 0.0 ->
# reached +30.47" and "commanded +30.0 -> reached -34.56", which looks like a
# broken drive rather than a broken measurement.
self._base = RigidPrim(paths=[base_path or C.PLOW_BASE])
self.commanded = 0.0
# Kinematic mode: rotate the arm directly instead of asking a force drive to hold
# an angle. The compliant drive was tuned three times (120000 / 3000 / 300) and
# never held its target - at 3000 it rang between +-21.4 deg at 99 deg/s, faster
# than the 76.4 deg/s ramp commanding it, which is the drive moving the arm rather
# than the command. A kinematic arm turned at the ramp rate goes exactly where it
# is put, which is what the pusher blade has always done.
#
# It gives up compliance, so the arm no longer yields on contact. That is safe here
# only because the tip speed matches the belt (0.8 m/s): the blade leans goods over
# at their own speed rather than batting them. MAX_DEPENETRATION still caps how
# violently PhysX may separate a deep overlap.
self._rot_op = None
if kinematic:
self._rot_op = self._ensure_rot_op()
def _ensure_rot_op(self):
"""the arm's own rotateZ op, created if the authored prim has none.
The hinge sits at the arm's origin (localPos0 = localPos1 = 0), so turning the arm
about its own Z reproduces the joint exactly.
"""
xf = UsdGeom.Xformable(self.stage.GetPrimAtPath(self.arm_path))
for op in xf.GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeRotateZ:
return op
return xf.AddRotateZOp()
# -- state --------------------------------------------------------------
def _yaw_of(self, prim) -> float:
"""world yaw from the SIMULATED pose (never XformCache: during simulation that
returns the authored transform and the arm looks frozen)"""
return _yaw_deg(prim.get_world_poses()[1].numpy()[0])
@property
def angle(self) -> float:
"""true joint angle in degrees: the arm's yaw relative to the base it hinges on"""
d = self._yaw_of(self._arm) - self._yaw_of(self._base)
return (d + 180.0) % 360.0 - 180.0
def at(self, deg: float, tol: float = 1.0) -> bool:
return abs(self.angle - deg) <= tol
# -- command ------------------------------------------------------------
def target(self, deg: float, velocity: float | None = None) -> float:
"""command the drive; the value is clamped inside the joint's own limit"""
deg = max(-C.PLOW_LIMIT, min(C.PLOW_LIMIT, float(deg)))
if self._rot_op is not None: # kinematic: put the arm there
self._rot_op.Set(float(deg))
else: # compliant: ask the drive to get there
self.drive.GetTargetPositionAttr().Set(deg)
if velocity is not None:
self.drive.GetTargetVelocityAttr().Set(float(velocity))
self.commanded = deg
return deg
def home(self) -> float:
"""centre the blade, out of the lane"""
return self.target(0.0, velocity=0.0)
def gains(self, stiffness=None, damping=None, max_force=None):
"""re-apply the authored gains, or override them for an experiment"""
self.drive.GetStiffnessAttr().Set(float(
C.PLOW_STIFFNESS if stiffness is None else stiffness))
self.drive.GetDampingAttr().Set(float(
C.PLOW_DAMPING if damping is None else damping))
self.drive.GetMaxForceAttr().Set(float(
C.PLOW_MAX_FORCE if max_force is None else max_force))
# -- motion -------------------------------------------------------------
def step_toward(self, deg: float, dt: float, rate: float | None = None) -> bool:
"""advance the commanded target one physics step toward `deg`.
Ramping the target is what keeps the blade sortable: commanding the endpoint
outright makes the solver deliver it as an impulse. Returns True once the command
has reached `deg` - the arm itself follows a little later, so gate on
:meth:`at` if you need the blade physically there.
"""
rate = C.PLOW_RATE if rate is None else rate
step = rate * dt
delta = deg - self.commanded
if abs(delta) <= step:
self.target(deg, velocity=0.0)
return True
self.target(self.commanded + math.copysign(step, delta),
velocity=math.copysign(rate, delta))
return False
async def swing(self, app, deg: float, rate: float | None = None,
settle_s: float = 0.5, dt: float = 1.0 / 60.0) -> float:
"""drive the blade to `deg` and wait for the arm to actually arrive"""
rate = C.PLOW_RATE if rate is None else rate
t = 0.0
while not self.step_toward(deg, dt, rate):
await app.update_app_async(steps=1)
t += dt
for _ in range(int(settle_s / dt)): # the arm lags the command
if self.at(deg):
break
await app.update_app_async(steps=1)
t += dt
return t
async def divert(self, app, side: float = 1.0, dwell_s: float | None = None,
rate: float | None = None) -> float:
"""full cycle: swing into the lane, hold, return to centre"""
dwell_s = C.PLOW_HOLD if dwell_s is None else dwell_s
deg = math.copysign(C.PLOW_SWING, side)
t = await self.swing(app, deg, rate)
for _ in range(int(dwell_s / (1.0 / 60.0))):
await app.update_app_async(steps=1)
t += 1.0 / 60.0
t += await self.swing(app, 0.0, rate)
return t
# -- the authored profile, in Python -------------------------------------
@staticmethod
def authored_profile(t: float, rate: float | None = None) -> tuple[float, float]:
"""(target_deg, target_deg_per_s) of the scene's own OmniGraph loop at time `t`.
Reimplemented so the demo motion is available from code. `rate` defaults to
``config.PLOW_RATE`` rather than the authored 4125 deg/s; pass
``config.PLOW_RATE_AUTHORED`` to reproduce the scene exactly, impulse and all.
"""
rate = C.PLOW_RATE if rate is None else rate
a = C.PLOW_SWING
hold, ts = C.PLOW_HOLD, a / rate
segs = [(hold, 0.0, 0.0), (ts, a, rate), (hold, a, 0.0), (ts, 0.0, -rate),
(hold, 0.0, 0.0), (ts, -a, -rate), (hold, -a, 0.0), (ts, 0.0, rate),
(hold, 0.0, 0.0)]
period = sum(s[0] for s in segs)
c, t0, pos0 = t % period, 0.0, 0.0
for dur, pos1, vel in segs:
if c <= t0 + dur + 1e-9:
u = 0.0 if dur <= 0 else (c - t0) / dur
pos = pos0 + (pos1 - pos0) * u if abs(vel) > 1e-6 else pos1
return float(pos), float(vel)
t0, pos0 = t0 + dur, pos1
return 0.0, 0.0
+293
View File
@@ -0,0 +1,293 @@
"""Loads scene/plow_cell.usd - the bare mechanical cell: conveyors, the Y-split pusher and
the plow, with no camera portal, no laser gate and no item library.
This is the transfer of the authored 90_degree.usd build (see scripts/build_plow_cell.py).
It is deliberately the *mechanics only*: cameras, speed scenarios and laser sensors are
added on top of it later, and keeping them out means the belts and the plow can be brought
up and watched without a vision stack attached.
Two ways to run it:
* **as authored** - open the scene and press Play. The scene's own ``DiverterAnimGraph``
script node sweeps the pusher and the plow on a fixed loop. Nothing else is needed; this
is what the file looks like when it was built.
* **under script control** - ``prepare(stage, script_control=True)`` switches that graph
off and hands the plow to :class:`sim.plow.Plow`. The graph has to go: it rewrites the
drive targets every tick and would overwrite anything Python commands.
The belts are driven the same way as in the sorter scene - explicit
``PhysxSurfaceVelocityAPI`` on kinematic slabs - because the authored ``ConveyorBeltGraph``
nodes carry no speed of their own and only fight the explicit setting.
"""
from __future__ import annotations
from pathlib import Path
from pxr import Gf, PhysxSchema, Usd, UsdGeom, UsdPhysics, UsdShade
from .. import config as C
from . import scene as _scene
SCENE = C.ROOT / "scene" / "plow_cell.usd"
# Same belt topology as the sorter scene - sorter.usd was exported from the same build.
BELTS = _scene.BELTS
BRANCH = _scene.BRANCH
ANIM_GRAPH = "/World/Diverters/DiverterAnimGraph"
GRIP_MATERIAL = "/World/PlowCell/M_beltPhysics"
# In the authored build this second ConveyorTrack_01 at stage root was a leftover duplicate
# and `prepare()` switched it off. `scripts/place_plow_lanes.py` then moved it out to -Y and
# made it **the plow's -Y sorting lane**, so switching it off now removes half the sorter and
# everything the plow deflects that way drops through the gap. It stays active by default;
# `deactivate_stray=True` is kept only for opening the pre-lanes scene.
STRAY_TRACK = "/ConveyorTrack_01"
def drive_belt(stage, path, world_dir, speed, grip_path=GRIP_MATERIAL):
"""carry goods along `world_dir` (a WORLD direction), whatever the belt's own frame is.
`surfaceVelocity` is expressed in the body's **local** frame, and this build does not
lay every track the same way round: measured on the authored scene, local +X maps to
ConveyorTrack, _02, _03, _05 -> world +X
ConveyorTrack_04 -> world -X (the run through the plow)
ConveyorTrack_03/Belt_01 -> world -Y (the branch)
/ConveyorTrack_01 -> world -Y (plow lane, -Y side)
/World/ConveyorTrack_01 -> world (-0.71, +0.71) (plow lane, +Y side, 45 deg)
So a hard-coded sign is right for four belts and backwards for the fifth. Driving
ConveyorTrack_04 backwards is what made goods stop dead at x = -6.0: they arrive moving
-X, meet a belt pushing +X, and balance on the transfer jittering in place. It reads
exactly like a blocked junction, which is the wrong thing to go and fix.
Resolve the axis instead of assuming it.
"""
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
return None
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI.Apply(prim)
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
world = Gf.Vec3d(*world_dir)
world = world / (world.GetLength() or 1.0)
M = UsdGeom.XformCache().GetLocalToWorldTransform(prim)
local = M.GetInverse().TransformDir(world)
n = local.GetLength() or 1.0
local = local / n # unit direction in the body's own frame
# Scale the MAGNITUDE by what one local unit is worth in world, not by 1. A track with
# a non-unit scale shrinks the velocity on its way back out: ConveyorTrack_04 carries
# scale (0.5, 1, 1), so a local 0.8 came out as 0.40 m/s in world - the main run was
# feeding the fork at half the speed the branches were pulling away at, and goods hung
# on the boundary with nothing behind them. Direction was right; only the magnitude was
# wrong, which is why checking the sign alone missed it twice.
per_unit = M.TransformDir(local).GetLength() or 1.0
local = Gf.Vec3f(*(local * (speed / per_unit)))
PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim)
PhysxSchema.PhysxSurfaceVelocityAPI(prim).CreateSurfaceVelocityAttr().Set(local)
grip = stage.GetPrimAtPath(grip_path)
if grip.IsValid():
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(UsdShade.Material(grip),
bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return tuple(round(v, 3) for v in local)
def configure_belts(stage, speed=None):
"""drive every belt of the plow cell by its intended WORLD direction"""
speed = speed if speed is not None else C.BELT_SPEED
grip = stage.GetPrimAtPath(GRIP_MATERIAL)
if not grip.IsValid():
grip = stage.DefinePrim(GRIP_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(grip)
pm.CreateStaticFrictionAttr().Set(1.1)
pm.CreateDynamicFrictionAttr().Set(0.95)
pm.CreateRestitutionAttr().Set(0.02)
driven = {}
for path in BELTS: # the whole main run travels -X
v = drive_belt(stage, path, (-1, 0, 0), speed)
if v:
driven[path] = v
v = drive_belt(stage, BRANCH, (0, 1, 0), speed) # the pusher's branch, toward the bin
if v:
driven[BRANCH] = v
for track in ("ConveyorTrack", "ConveyorTrack_01", "ConveyorTrack_02",
"ConveyorTrack_03", "ConveyorTrack_04", "ConveyorTrack_05"):
for graph in (f"/World/{track}/ConveyorBeltGraph",
f"/World/{track}/ConveyorBeltGraph_01"):
g = stage.GetPrimAtPath(graph)
if g.IsValid():
g.SetActive(False)
return driven
def open_scene(usd_path: str | Path | None = None):
import omni.usd
path = str(usd_path or SCENE)
if not Path(path).exists():
raise FileNotFoundError(
f"{path} not found. Build it with scripts/build_plow_cell.py; the conveyor art "
"it references lives in assets/conveyors/ - run scripts/fetch_assets.py if that "
"folder is empty."
)
omni.usd.get_context().open_stage(path)
return omni.usd.get_context().get_stage()
def _friction_material(stage, path, static_f, dynamic_f, bind_to=(), restitution=0.0):
"""author a physics material and bind it, physics-purpose, to the given prims.
Binding is `strongerThanDescendants` so it beats the belt grip material that
configure_belts() puts on the same belt - the plow section wants to be slippery even
though every carrying section wants to grip.
"""
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
prim = stage.DefinePrim(path, "Material")
m = UsdPhysics.MaterialAPI.Apply(prim)
m.CreateStaticFrictionAttr().Set(float(static_f))
m.CreateDynamicFrictionAttr().Set(float(dynamic_f))
m.CreateRestitutionAttr().Set(float(restitution))
mat = UsdShade.Material(prim)
bound = []
for target in bind_to:
t = stage.GetPrimAtPath(target)
if not t.IsValid():
continue
api = UsdShade.MaterialBindingAPI.Apply(t)
api.Bind(mat, bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
bound.append(target)
return bound
def configure_plow(stage, script_control: bool = True, kinematic_arm: bool = True):
"""make the plow controllable and put its arm at rest.
The arm is a dynamic body with gravity disabled, held only by the hinge drive, so a
scene that opens with a stale target has the blade already leaning into the lane.
"""
hinge = stage.GetPrimAtPath(C.PLOW_HINGE)
if not hinge.IsValid():
raise RuntimeError(f"{C.PLOW_HINGE} missing - is this plow_cell.usd?")
if script_control:
graph = stage.GetPrimAtPath(ANIM_GRAPH)
if graph.IsValid():
graph.SetActive(False)
drive = UsdPhysics.DriveAPI(hinge, "angular")
if drive:
drive.GetTargetPositionAttr().Set(0.0)
drive.GetTargetVelocityAttr().Set(0.0)
base = stage.GetPrimAtPath(C.PLOW_BASE)
if base.IsValid() and base.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI(base).CreateKinematicEnabledAttr().Set(True)
# The arm is thin and sweeps into cargo, so it penetrates deeply in a single step.
# Uncapped, PhysX separates that overlap at whatever speed it likes and the item leaves
# the cell at several m/s. Cap the separation and give the arm the solver iterations to
# resolve the contact properly instead.
# A plough leads goods across only if they can slide - along the blade, and sideways
# over the belt. Both surfaces are given friction here; see config for the measurement
# that made it necessary (goods piled against the blade and stopped).
_friction_material(stage, "/World/PlowCell/M_bladeFace", *C.PLOW_BLADE_FRICTION,
bind_to=[C.PLOW_ARM])
_friction_material(stage, "/World/PlowCell/M_plowSection", *C.PLOW_SECTION_FRICTION,
bind_to=C.PLOW_SECTION_PLATES)
# The pedestal is a WALL across the belt: measured x[-7.02,-6.98] y[-0.54,+0.54]
# z[+1.72,+2.56], against a belt of y[-0.45,+0.45] - it spans the full width and stands
# 780 mm proud of the deck, with collision on. Goods arrive at the full 0.80 m/s, hit it
# at x = -6.98 and stop, whatever the blade is doing and wherever they have been nudged
# to. That is the "does not move on after being displaced" symptom, and it is not the
# arm: the arm is 180 mm wide and lies along the flow.
#
# The pedestal is structure, not a working surface - only the blade should ever touch
# cargo, and the blade carries its own collider. Its collision is switched off.
for base_prim in Usd.PrimRange(stage.GetPrimAtPath(C.PLOW_BASE)):
a = base_prim.GetAttribute("physics:collisionEnabled")
if a:
a.Set(False)
elif base_prim.HasAPI(UsdPhysics.CollisionAPI):
UsdPhysics.CollisionAPI(base_prim).CreateCollisionEnabledAttr().Set(False)
# The pedestal is authored with `physics:approximation = "convexHull"`. A convex hull is
# the smallest convex volume enclosing every vertex, so every opening in the frame is
# filled in: what looks like a gantry you can see through is, to PhysX, a solid brick -
# measured y[-0.54,+0.54] z[+1.72,+2.56] against a belt of y[-0.45,+0.45]. Goods arrive
# at the full 0.80 m/s, hit it at x = -6.98 and stop, wherever they have been nudged to.
# Transparency is a shader property and has nothing to do with it.
#
# Switching the approximation to the mesh itself keeps the frame in the simulation as
# real structure - its posts still collide - while the opening becomes a genuine
# opening. Triangle-mesh colliders are legal here because the pedestal is kinematic.
# The conveyor line itself is untouched.
for base_prim in Usd.PrimRange(stage.GetPrimAtPath(C.PLOW_BASE)):
if base_prim.HasAPI(UsdPhysics.MeshCollisionAPI):
UsdPhysics.MeshCollisionAPI(base_prim).CreateApproximationAttr().Set("none")
arm = stage.GetPrimAtPath(C.PLOW_ARM)
if arm.IsValid():
px = PhysxSchema.PhysxRigidBodyAPI.Apply(arm)
px.CreateMaxDepenetrationVelocityAttr().Set(C.MAX_DEPENETRATION)
px.CreateSolverPositionIterationCountAttr().Set(32)
px.CreateSolverVelocityIterationCountAttr().Set(8)
if kinematic_arm:
# Turn the arm directly instead of asking the force drive to hold an angle.
# The drive was tuned three times and never held: at stiffness 3000 the arm
# rang between +-21.4 deg at 99 deg/s, faster than the 76.4 deg/s ramp that was
# commanding it. Kinematic, it goes exactly where sim/plow.py puts it.
UsdPhysics.RigidBodyAPI(arm).CreateKinematicEnabledAttr().Set(True)
# CCD is invalid on a body that is ever kinematic - PhysX errors on it.
px.CreateEnableCCDAttr().Set(False)
# The hinge has to go too, or it drags the arm back toward its own drive target
# every step while the script writes the transform somewhere else - the same
# fight that made the pusher blade jitter for a whole run.
hinge.GetAttribute("physics:jointEnabled").Set(False)
else:
px.CreateEnableCCDAttr().Set(True)
return drive is not None
def deactivate_stray(stage):
prim = stage.GetPrimAtPath(STRAY_TRACK)
if prim.IsValid() and prim.IsActive():
prim.SetActive(False)
return True
return False
def prepare(stage, belt_speed=None, script_control: bool = True,
deactivate_stray_track: bool = False, kinematic_arm: bool = True):
"""everything the authored scene needs before the belts and the plow will run"""
_scene.configure_physics(stage)
belts = configure_belts(stage, belt_speed)
stray = deactivate_stray(stage) if deactivate_stray_track else False
plow = configure_plow(stage, script_control, kinematic_arm)
# The Y-split blade is moved by writing its transform (mechanics.Cell.blade_to). Its
# authored PhysicsPrismaticJoint has to be switched off first or the two fight: the
# joint drags the blade back toward its own drive target every step while the script
# writes it somewhere else, and the blade jitters back and forth for the whole run -
# including long after the last class-D item has gone by. Only the sorter scene used
# to do this; the plow cell needs it just as much.
_scene.configure_pusher(stage)
return dict(script_control=script_control, plow_ready=plow, stray_deactivated=stray,
belts=belts,
belt_speed=C.BELT_SPEED if belt_speed is None else belt_speed)
def load(usd_path=None, belt_speed=None, script_control: bool = True):
stage = open_scene(usd_path)
return stage, prepare(stage, belt_speed, script_control)
+484
View File
@@ -0,0 +1,484 @@
"""Runtime setup for scene/plow_cell_90_45_test.usd - the plow cell with the 90-degree
corner exit (ConveyorTrack_06) replacing plow_cell.usd's 45-degree lane.
Topology differences from plow_cell.usd, all measured on the live stage (not assumed):
* ConveyorTrack_01 is now part of the MAIN RUN (local +X -> world -X) instead of being
the plow's own lane - it is what carries class C onward to its container.
* ConveyorTrack_06 is new: a 90-degree corner that carries class B out to +Y.
* config.PLOW_PRESET needs no change: B=-16 deg was measured driving items to +Y (onto
ConveyorTrack_06 -> container B), C=+16 deg to -Y (onto ConveyorTrack_01 ->
container C) - the same signs plow_sort.py already uses for the old layout.
Two bugs fixed here for good, both cost a session each to find:
* `prim.SetActive(False)` on a ConveyorBeltGraph/DiverterAnimGraph does NOT stop an
already-instantiated OmniGraph exec - it keeps writing zero into surfaceVelocity (or
the plow's drive target) every tick regardless of the prim's active state. The graph
node has to be REMOVED (`stage.RemovePrim`), not deactivated.
* The plow's corner decks (PlowCornerDeck_B/C, PlowTransition_B/C) are static plates:
an item that slides off the belt onto one, under only the sideways push the plow gave
it, loses its drive the instant it clears the belt and stops dead on the plate -
exactly plow_sort.py's "touches and then just sits there" symptom. They have to be
driven too, toward whichever real belt segment is physically next - by MEASURED
position, not by the deck's own name: PlowCornerDeck_B in this build sits on the
geometric path toward container C, not container B.
"""
from __future__ import annotations
from pxr import Gf, Usd, UsdGeom, UsdLux, UsdPhysics, UsdShade
from .. import config as C
from . import scene as _scene
from .plow_cell import GRIP_MATERIAL, configure_plow, drive_belt
SCENE = C.ROOT / "scene" / "plow_cell_90_45_test.usd"
# _scene.BELTS (5: ConveyorTrack, _02, _03, _04, _01) is the SORTER scene's list and does
# not cover this cell at all - it is missing ConveyorTrack_05, the entry segment items are
# actually spawned onto (x 0..+2, the first belt in the run). Driven the same -X way as the
# rest of the main run below. ConveyorTrack_06 (the 90-degree corner) is NOT in this list -
# it needs a different world direction (0,+1,0) and is driven separately in configure_belts.
BELTS = _scene.BELTS + ["/World/ConveyorTrack_05/Belt"]
TRACKS = ("ConveyorTrack", "ConveyorTrack_01", "ConveyorTrack_02", "ConveyorTrack_03",
"ConveyorTrack_04", "ConveyorTrack_05", "ConveyorTrack_06")
# Belt top z=1.781 everywhere on the main run; ConveyorTrack_05 is the line's entry, local
# +X -> world +X (the only segment laid that way - everything else is world -X already).
ENTRY_BELT = "/World/ConveyorTrack_05/Belt"
ENTRY_X, ENTRY_Y = 1.80, 0.0 # near the +X (upstream) end of ConveyorTrack_05's 0..+2 span
GROUND_Z = C.FLOOR_Z # 0.0 - matches the sorter scene's own floor constant
GROUND_PATH = "/World/_Ground"
LIGHT_PATH = "/Environment/_BrightFill"
# Deck -> unit world direction aiming at the CENTRE of the real belt it physically feeds
# into. Computed from UsdGeom.BBoxCache on the live stage, not guessed from the deck's
# name - the names are stale (see module docstring). Re-derive if the scene is re-laid.
DECK_DIR = {
"/World/PlowTransition_B": (-0.9995, 0.0309, 0.0), # feeds ConveyorTrack_01 (class C)
"/World/PlowCornerDeck_B": (-0.9716, 0.2367, 0.0), # feeds ConveyorTrack_01 (class C)
"/World/PlowTransition_C": (-0.9945, -0.1047, 0.0), # feeds ConveyorTrack_06 (class B)
"/World/PlowCornerDeck_C": (-0.9995, -0.0302, 0.0), # feeds ConveyorTrack_06 (class B)
}
PUSHER_GEOM = "/World/Diverters/DiverterY_Split/Pusher/Geom"
# Footprint along the belt. The authored blade was 1200 mm - a near-wall - and 500 mm was
# the requested replacement, but 500 mm is provably too narrow for THIS belt speed:
# * momentum transfer falls off with blade speed (measured dy: 1.3 m/s -> 0.17..0.22 m,
# 1.8 m/s -> 0.01..0.08 m), because a transform-driven kinematic blade shoves by
# depenetration rather than by carrying - so the stroke wants to be SLOW;
# * a slow stroke (0.82 m at 1.3 m/s = 0.63 s) needs 0.63 m of blade to stay in contact
# at 1 m/s belt speed, but 500 mm only gives 0.50 s, so the item slid off the trailing
# edge halfway through and left with a third of the needed displacement.
# 800 mm satisfies both (0.80 s of contact for a 0.63 s stroke) and is still a third
# shorter than the 1200 mm original.
PUSHER_X_MM = 800.0
def resize_pusher_blade(stage, x_mm=PUSHER_X_MM):
"""the authored blade is a Cube scaled (1.2, 0.06, 0.3) - 1200 mm along the belt
(X), a near-wall rather than a paddle. Only the X (along-belt) scale changes; Y
(cross-belt thickness) and Z (height) are load-bearing as measured elsewhere and
stay put. Idempotent: re-reads and re-derives from whatever scale is currently there."""
prim = stage.GetPrimAtPath(PUSHER_GEOM)
if not prim.IsValid():
return None
xf = UsdGeom.Xformable(prim)
for op in xf.GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeScale:
s = op.Get()
op.Set(Gf.Vec3f(x_mm / 1000.0, s[1], s[2]))
return (x_mm / 1000.0, s[1], s[2])
return None
PUSHER_GRIP_MATERIAL = "/World/_PusherGrip"
def grip_pusher_blade(stage, static_f=1.1, dynamic_f=0.95):
"""the blade face is bound to /World/Diverters/DiverterMaterial (static/dynamic
friction 0.12/0.08) - deliberately slick for the PLOW's blade (config.PLOW_BLADE_
FRICTION, so goods slide along its edge instead of piling up), but the pusher shares
that same authored material and inherits the slickness for free. Measured on an
isolated item: it picks up a brief lateral velocity spike on contact and then the
blade sweeps clean past it - a flick, not a carry (0.42 m commanded stroke, item ends
up 0.05 m over). A high-friction grip material, bound stronger-than-descendants same
as the belts' own grip, is what a real pusher gate needs: it should carry the item
with it, not glance off."""
prim = stage.GetPrimAtPath(PUSHER_GEOM)
if not prim.IsValid():
return None
grip = stage.GetPrimAtPath(PUSHER_GRIP_MATERIAL)
if not grip.IsValid():
grip = stage.DefinePrim(PUSHER_GRIP_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(grip)
pm.CreateStaticFrictionAttr().Set(static_f)
pm.CreateDynamicFrictionAttr().Set(dynamic_f)
pm.CreateRestitutionAttr().Set(0.0)
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(UsdShade.Material(grip), bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return (static_f, dynamic_f)
PUSHER_XFORM = "/World/Diverters/DiverterY_Split/Pusher"
PUSHER_CLEARANCE = 0.002 # target gap between the blade's bottom edge and the belt top
def seat_pusher_blade(stage, clearance=PUSHER_CLEARANCE):
"""scene.py's configure_pusher() seats the blade at a hardcoded local z=-0.135,
which measured 14 mm above the belt (1.795 vs belt top 1.781) - fine for the boxy
items it was tuned on, but taller than `plate` (9 mm) or `pen` (5 mm), which pass
clean underneath no matter how the sweep speed/friction is tuned. Lower it to a
small measured clearance above the belt instead of trusting the hardcoded offset."""
blade = stage.GetPrimAtPath(PUSHER_XFORM)
belt = stage.GetPrimAtPath("/World/ConveyorTrack_03/Belt")
if not blade.IsValid() or not belt.IsValid():
return None
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
blade_bottom = bbc.ComputeWorldBound(blade).ComputeAlignedRange().GetMin()[2]
belt_top = bbc.ComputeWorldBound(belt).ComputeAlignedRange().GetMax()[2]
drop = (blade_bottom - belt_top) - clearance
if drop <= 0:
return blade_bottom, belt_top, 0.0
for op in UsdGeom.Xformable(blade).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
v = op.Get()
op.Set(Gf.Vec3d(v[0], v[1], v[2] - drop))
return blade_bottom, belt_top, drop
return None
def _kill_stale_graphs(stage):
"""remove (not deactivate) every ConveyorBeltGraph and the DiverterAnimGraph - see
module docstring. Safe to call more than once; RemovePrim on a missing path is a no-op
check via IsValid() first."""
killed = []
for track in TRACKS:
for graph in (f"/World/{track}/ConveyorBeltGraph", f"/World/{track}/ConveyorBeltGraph_01"):
p = stage.GetPrimAtPath(graph)
if p.IsValid():
stage.RemovePrim(p.GetPath())
killed.append(graph)
p = stage.GetPrimAtPath("/World/Diverters/DiverterAnimGraph")
if p.IsValid():
stage.RemovePrim(p.GetPath())
killed.append("/World/Diverters/DiverterAnimGraph")
return killed
def add_ground_and_light(stage):
"""this bare mechanical cell (see module docstring: no camera portal, no laser gate,
no item library) also ships with no ground plane and a single DistantLight - fine for
a dry mechanics smoke test, useless for watching goods over WebRTC: anything that
overshoots a belt or a container (the pusher has thrown items tens of metres in this
same cell before) free-falls forever and the scene reads as half-lit. A big static
collider under the whole cell plus a bright DomeLight fix both, idempotently."""
ground = stage.GetPrimAtPath(GROUND_PATH)
if not ground.IsValid():
cube = UsdGeom.Cube.Define(stage, GROUND_PATH)
cube.CreateSizeAttr().Set(1.0) # unit cube, half-extent 0.5 before scale
xf = UsdGeom.Xformable(cube.GetPrim())
# covers x -15..+25 (both the conveyor/container area AND the item park slots
# off at x 9..21), y -8..+10, top surface at GROUND_Z
xf.AddTranslateOp().Set(Gf.Vec3d(5.0, 1.0, GROUND_Z - 0.5))
xf.AddScaleOp().Set(Gf.Vec3f(40.0, 18.0, 1.0))
prim = cube.GetPrim()
UsdPhysics.CollisionAPI.Apply(prim)
ground = prim
UsdGeom.Imageable(ground).MakeVisible()
light = stage.GetPrimAtPath(LIGHT_PATH)
if not light.IsValid():
dome = UsdLux.DomeLight.Define(stage, LIGHT_PATH)
dome.CreateIntensityAttr().Set(2500.0)
dome.CreateColorAttr().Set(Gf.Vec3f(1.0, 1.0, 1.0))
light = dome.GetPrim()
UsdGeom.Imageable(light).MakeVisible()
return dict(ground=str(ground.GetPath()), light=str(light.GetPath()))
RAIL_PATH = "/World/_Rails"
# Straight transport-only segments where NOTHING is ever meant to leave sideways.
# ConveyorTrack_04 was already excluded (the plow deflects goods clear off its edge onto
# the junction decks). Measured live and fixed here: ConveyorTrack_03 (the pusher shoves
# goods off ITS +Y edge onto the branch), ConveyorTrack_06 and ConveyorTrack_01 (the
# plow's own two deflection targets) all got the same treatment as _04 - and each grew a
# rail directly across its own intended entry/exit, which is exactly the pile-up seen at
# the plow and the "pusher pushes but the item just stays on the belt" symptom: the pusher
# WAS working (an isolated single-item test got it 97% of the way to the branch) - it was
# arriving at a wall this module had just built.
RAIL_BELTS = ("/World/ConveyorTrack_05/Belt", "/World/ConveyorTrack/Belt",
"/World/ConveyorTrack_02/Belt")
RAIL_HEIGHT = 0.08 # low guard, enough to stop a bounce/overshoot, not a wall
def add_side_rails(stage):
"""low invisible guards along the long edges of straight runs, so a jostled item
rolls back onto the belt instead of pitching off into open air (measured happening -
the pusher alone has thrown items metres off the line before). Computed from each
belt's OWN live bbox, not hand-picked numbers - segments are laid at different
orientations and a constant y +-0.45 is wrong on at least one of them."""
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(), [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
root = stage.GetPrimAtPath(RAIL_PATH)
if not root.IsValid():
UsdGeom.Xform.Define(stage, RAIL_PATH)
built = []
for belt in RAIL_BELTS:
prim = stage.GetPrimAtPath(belt)
if not prim.IsValid():
continue
r = bbc.ComputeWorldBound(prim).ComputeAlignedRange()
mn, mx = r.GetMin(), r.GetMax()
dx, dy = mx[0] - mn[0], mx[1] - mn[1]
top = mx[2]
long_axis_x = dx >= dy # which local axis is the belt's length vs its width
safe_name = belt.replace("/", "_")
for side, edge in ((0, mn), (1, mx)):
path = f"{RAIL_PATH}/{safe_name}_{side}"
if stage.GetPrimAtPath(path).IsValid():
built.append(path)
continue
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
if long_axis_x:
cx, hx = (mn[0] + mx[0]) / 2.0, dx / 2.0 + 0.05
cy = edge[1]
sx, sy = hx * 2.0, 0.02
else:
cx = edge[0]
cy, hy = (mn[1] + mx[1]) / 2.0, dy / 2.0 + 0.05
sx, sy = 0.02, hy * 2.0
xf.AddTranslateOp().Set(Gf.Vec3d(cx, cy, top + RAIL_HEIGHT / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(sx, sy, RAIL_HEIGHT))
UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
UsdGeom.Imageable(cube.GetPrim()).MakeInvisible()
built.append(path)
return built
def _ensure_grip_material(stage):
"""drive_belt()'s default grip_path (plow_cell.GRIP_MATERIAL, /World/PlowCell/
M_beltPhysics) is only ever CREATED inside plow_cell.configure_belts() - this module
calls drive_belt() directly and never that function, so the material prim never
existed, `grip.IsValid()` was False on every single call, and every deck/belt driven
here kept whatever friction it already had (or nothing) instead of getting bound to
the intended high-grip surface. The main belts happened to already carry their own
per-track authored material (0.9/0.9) and looked fine by accident; the plow-junction
decks have no such authored material and were the ones left exposed."""
grip = stage.GetPrimAtPath(GRIP_MATERIAL)
if not grip.IsValid():
grip = stage.DefinePrim(GRIP_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(grip)
pm.CreateStaticFrictionAttr().Set(1.1)
pm.CreateDynamicFrictionAttr().Set(0.95)
pm.CreateRestitutionAttr().Set(0.02)
return grip
def regrip_decks(stage, static_f=1.1, dynamic_f=0.95):
"""configure_plow() runs after configure_belts() and rebinds the transition plates
(PlowTransition_B/C) to /World/PlowCell/M_plowSection - a deliberately slippery
material (0.7/0.6, config.PLOW_SECTION_FRICTION) by original design, so the plow's
blade can slide an item across rather than have the plate fight it. This module also
tries to conveyor-DRIVE those same plates (DECK_DIR), which needs grip, not slip - the
two designs are in direct conflict, and 'strongerThanDescendants' meant the slippery
one always won. Measured effect: items sitting on a plate that is moving under them
but barely dragging them - the multi-second "stuck" crawl on the kinematics log.
PlowCornerDeck_B/C had no material bound at all (checked live) for the same reason as
_ensure_grip_material above. Re-bind all four, stronger again, after configure_plow."""
grip = _ensure_grip_material(stage)
mat = UsdShade.Material(grip)
bound = []
for path in DECK_DIR:
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
continue
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(mat, bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
bound.append(path)
return bound
# The conveyor ART prim of each track (SM_ConveyorBelt_*) carries its own collider, and
# that includes the blue SIDE RAILS running the full length of the track. At a plow/pusher
# station the rails have to be cut away on the discharge side - goods leave the belt
# sideways there by design. plow_sort.py documents this exactly ("Left in place they simply
# stop everything at the lane entry, which is what 'nothing reaches the bins' looked like")
# and provides open_junction() for it; this module never called it, so ConveyorTrack_04's
# shell (y -0.58..+0.58, collision on) stood as a wall right where class-B goods are pushed
# out - measured: B items deflected correctly to y~+0.48 then sat there for 55-58 s.
# Only the decorative shell loses its collider; every Belt keeps its own, so goods still
# ride on a real surface and cannot fall through.
JUNCTION_SHELLS = (
"/World/ConveyorTrack_04/SM_ConveyorBelt_A06_02", # the run through the plow
"/World/ConveyorTrack_04/SM_ConveyorBelt_A06_Decal_02",
"/World/ConveyorTrack_01/SM_ConveyorBelt_A06_02", # class-C lane
"/World/ConveyorTrack_01/SM_ConveyorBelt_A06_Decal_02",
"/World/ConveyorTrack_06/SM_ConveyorBelt_A03", # class-B lane (90 deg corner)
"/World/ConveyorTrack_06/SM_ConveyorBelt_A03_Decal",
"/World/ConveyorTrack_03/SM_ConveyorBelt_A21_02", # the pusher's own discharge
"/World/ConveyorTrack_03/SM_ConveyorBelt_A21_Decal_02",
)
def open_junction(stage):
"""drop the decorative shell colliders at the plow and pusher discharge points"""
opened = []
for path in JUNCTION_SHELLS:
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
continue
attr = prim.GetAttribute("physics:collisionEnabled")
if not attr:
attr = UsdPhysics.CollisionAPI.Apply(prim).CreateCollisionEnabledAttr()
attr.Set(False)
opened.append(path)
return opened
PUSH_SECTION_MATERIAL = "/World/_PushSectionSlip"
def slip_pusher_section(stage, static_f=0.30, dynamic_f=0.25):
"""lower the friction of the belt the pusher discharges from.
The grip material this module binds to every belt (1.1/0.95) is right for carrying
goods along the line, but at the pusher it is the thing the blade has to fight: a
0.6 kg item on mu=0.95 resists lateral motion with ~5.3 N, and the measured result was
the blade sweeping its full 0.82 m stroke while the item slid only 0.15-0.22 m across
it - a slip, not a transfer. The project's own plow code solves the same problem the
same way (config.PLOW_SECTION_FRICTION 0.70/0.60 on the transition plates, and 0.05/
0.04 on the blade face) so goods can slide sideways off the belt.
Applied to ConveyorTrack_03/Belt only - the pusher's own discharge section. Its
surfaceVelocity still carries items along the line; 0.30/0.25 is ample for that at
1 m/s while letting the blade drive them across.
"""
prim = stage.GetPrimAtPath("/World/ConveyorTrack_03/Belt")
if not prim.IsValid():
return None
mat_prim = stage.GetPrimAtPath(PUSH_SECTION_MATERIAL)
if not mat_prim.IsValid():
mat_prim = stage.DefinePrim(PUSH_SECTION_MATERIAL, "Material")
pm = UsdPhysics.MaterialAPI.Apply(mat_prim)
pm.CreateStaticFrictionAttr().Set(static_f)
pm.CreateDynamicFrictionAttr().Set(dynamic_f)
pm.CreateRestitutionAttr().Set(0.0)
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(UsdShade.Material(mat_prim),
bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return (static_f, dynamic_f)
def configure_belts(stage, speed=None):
"""drive all 7 main belts plus the 4 static plow-junction decks, each by its
measured world direction. Must run AFTER _kill_stale_graphs - otherwise the graphs
zero the velocity this sets a few physics steps after play()."""
speed = speed if speed is not None else C.BELT_SPEED
_ensure_grip_material(stage)
driven = {}
for path in BELTS:
v = drive_belt(stage, path, (-1, 0, 0), speed)
if v:
driven[path] = v
# NOT pure +Y. ConveyorTrack_06's belt spans x -8.97..-8.00, y +0.03..+1.05, and
# container_B sits at x -9.26..-8.36, y +1.07..+1.87. A class-B item is deflected onto
# _06 near its +X edge (~x -8.05); driving straight +Y then walks it up the belt at
# CONSTANT x and it falls off the far edge at x~-8.02 - 0.34 m short of the container's
# near wall. Measured exactly that: B items reached y +1.15/+1.28 and dropped to the
# floor at x -8.03/-8.01. Aim the belt diagonally at the container centre instead.
v = drive_belt(stage, "/World/ConveyorTrack_06/Belt", (-0.5447, 0.8386, 0), speed)
if v:
driven["/World/ConveyorTrack_06/Belt"] = v
# the pusher's own branch - carries a pushed D item on from the shove into BinD.
# plow_cell.py's configure_belts() drives this; this module's own list above never
# did, so a pushed item landed on a branch with no belt force and just sat there.
# Same pure-+Y bug as ConveyorTrack_06 had, measured the same way: an item placed on
# Belt_01 at (-4.10,+0.70) rode +Y to y=1.92 at CONSTANT x=-4.10 and fell off the far
# edge - BinD's floor is x -6.21..-4.95, so it missed by 0.85 m. The belt does carry
# (friction 1.1/0.95, |v|=1.0 confirmed); it was simply pointed past the bin. Aim it
# at the BinD floor centre instead.
v = drive_belt(stage, _scene.BRANCH, (-0.6976, 0.7165, 0), speed)
if v:
driven[_scene.BRANCH] = v
for path, direction in DECK_DIR.items():
v = drive_belt(stage, path, direction, speed)
if v:
driven[path] = v
return driven
async def open_scene(usd_path=None):
"""the SYNC `open_stage` + a settle margin, not `open_stage_async` - the async loader
returns while background layer composition is still touching the stage on another
thread, which trips Kit's 'Detected usd threading violation' guard the moment
configure_physics() edits the stage. A live WebRTC stream keeps Hydra populating the
freshly-opened ~360 prims on its own thread well after `is_stage_loading()` clears, so
the margin here is generous on purpose - short margins measured flaky on this scene
while streaming is active."""
import asyncio
import omni.usd
import isaacsim.core.experimental.utils.app as app_utils
path = str(usd_path or SCENE)
omni.usd.get_context().open_stage(path)
await app_utils.update_app_async(steps=120)
await asyncio.sleep(3.0)
await app_utils.update_app_async(steps=60)
return omni.usd.get_context().get_stage()
async def _retrying(fn, *args, tries=12, **kwargs):
"""call fn(*args) with a small settle-and-retry loop.
UsdPhysics/PhysX edits on a just-opened stage race a live WebRTC session's background
Hydra-populate thread: 'Detected usd threading violation' (pxr.Tf.ErrorException,
which derives from BaseException, not Exception, and carries no message in str() - the
diagnostic text is printed separately by Tf's own delegate). It clears within a step
or two once that thread catches up, so each of prepare()'s five sub-calls gets its own
short retry here rather than re-running the whole sequence from the top on every miss.
"""
import asyncio
import isaacsim.core.experimental.utils.app as app_utils
last_exc = None
for attempt in range(tries):
try:
return fn(*args, **kwargs)
except BaseException as exc:
last_exc = exc
await app_utils.update_app_async(steps=60)
await asyncio.sleep(1.0)
raise last_exc
async def prepare(stage, belt_speed=None, script_control: bool = True, kinematic_arm: bool = True):
"""everything the new-topology scene needs before the belts and the plow will run"""
await _retrying(_scene.configure_physics, stage)
killed = await _retrying(_kill_stale_graphs, stage)
belts = await _retrying(configure_belts, stage, belt_speed)
plow = await _retrying(configure_plow, stage, script_control, kinematic_arm)
regripped = await _retrying(regrip_decks, stage)
await _retrying(_scene.configure_pusher, stage)
pusher_dims = await _retrying(resize_pusher_blade, stage)
await _retrying(grip_pusher_blade, stage)
seat = await _retrying(seat_pusher_blade, stage)
# slip_pusher_section() is deliberately NOT called: lowering the pusher belt's
# friction to 0.30/0.25 did not improve the push at all (dy stayed ~0.21 m, the
# same value it holds across every blade speed, width and fire-timing tried) and
# it cost a class-C delivery. Kept above for the record - the ~0.21 m ceiling is
# not a friction problem.
env = await _retrying(add_ground_and_light, stage)
rails = await _retrying(add_side_rails, stage)
opened = await _retrying(open_junction, stage)
return dict(script_control=script_control, plow_ready=plow, belts=belts,
graphs_removed=killed, env=env, pusher_dims=pusher_dims, rails=len(rails),
pusher_seat=seat, decks_regripped=regripped, junction_opened=len(opened),
belt_speed=C.BELT_SPEED if belt_speed is None else belt_speed)
async def load(usd_path=None, belt_speed=None, script_control: bool = True):
stage = await open_scene(usd_path)
return stage, await prepare(stage, belt_speed, script_control)
+153
View File
@@ -0,0 +1,153 @@
"""Contact sensing on the plow blade.
**Why a contact report and not another beam.** The cell already has two through-beams: the
laser gate before the pusher and the arming beam at x = -6.30 that pre-positions the plow.
Both answer "something is about to arrive". Neither can answer "the blade is now touching
*this* item", and that is the question that matters at the plow, because the arm is only
useful while it is actually in contact - before that it is waving at nothing, and after it
the item is already committed to a lane. A beam at the blade would also be broken by the
blade itself as it swings, which is the trap the gate beam at y = -0.24 was placed to dodge.
So the sensor is a **PhysX contact report on the arm body**
(``PhysxSchema.PhysxContactReportAPI``). It fires on the real collision pair, names both
bodies, and needs no extra geometry that could foul the belt. Isaac's
``sensors.experimental.physics.Contact`` wraps the same mechanism with an authored prim and
a threshold; the raw report is used here because the plow needs the *identity* of what it
touched, which is what carries the class through.
**Keeping the class.** Classification happens once, far upstream under the camera portal.
That verdict is stored per item and travels with it:
camera portal ──▶ classes[item] = "B" | "C" | "D"
arming beam ────────▶ pre-position the blade for that class
blade contact ───────▶ CONFIRM against the same stored class, and hold the side while
contact lasts - the item is steered by the class it was given,
not by anything re-derived at the blade
:class:`PlowContact` therefore takes the same ``classes`` mapping the sorter uses, and
reports, per touch: which item, what class it carries, the blade angle at first touch, and
how long contact lasted. A touch whose class is unknown is reported as such rather than
guessed - an unclassified item must not be steered anywhere.
"""
from __future__ import annotations
from pxr import PhysicsSchemaTools, PhysxSchema
from .. import config as C
ITEMS_PREFIX = "/World/Items/"
class PlowContact:
"""PhysX contact reporting on the plow arm, resolved to item + class"""
def __init__(self, stage, classes: dict, arm_path: str | None = None,
plow=None, threshold: float = 0.0):
"""
classes : the SAME dict the sorter steers by - vision writes into it, so the
sensor sees whatever verdict the item is carrying at the moment of touch
plow : optional sim.plow.Plow, so the angle at contact can be recorded
"""
self.stage = stage
self.classes = classes
self.plow = plow
self.arm_path = arm_path or C.PLOW_ARM
prim = stage.GetPrimAtPath(self.arm_path)
if not prim.IsValid():
raise RuntimeError(f"{self.arm_path} missing - is this plow_cell.usd?")
api = PhysxSchema.PhysxContactReportAPI.Apply(prim)
api.CreateThresholdAttr().Set(float(threshold)) # 0 = report every touch
self.touches: dict[str, dict] = {} # item -> first/last touch record
self.in_contact: set[str] = set()
self.events: list[dict] = []
self._t = 0.0
self._sub = None
# -- lifecycle ----------------------------------------------------------
def install(self):
from omni.physx import get_physx_simulation_interface
if self._sub is None:
self._sub = get_physx_simulation_interface(
).subscribe_contact_report_events(self._on_report)
return self
def remove(self):
self._sub = None
def tick(self, dt):
"""advance the sensor's clock; contact reports carry no timestamp of their own"""
self._t += dt
# -- the report ---------------------------------------------------------
def _item_of(self, path: str):
if not path.startswith(ITEMS_PREFIX):
return None
name = path[len(ITEMS_PREFIX):].split("/")[0]
return name or None
def _on_report(self, contact_headers, contact_data):
touching = set()
for h in contact_headers:
a0 = str(PhysicsSchemaTools.intToSdfPath(h.actor0))
a1 = str(PhysicsSchemaTools.intToSdfPath(h.actor1))
if self.arm_path not in (a0, a1):
continue
other = a1 if self.arm_path == a0 else a0
name = self._item_of(other)
if name is None: # the blade also brushes belts and rails
continue
touching.add(name)
self._register(name)
# contact that has ended
for gone in self.in_contact - touching:
rec = self.touches.get(gone)
if rec is not None:
rec["released_t"] = round(self._t, 3)
rec["duration"] = round(self._t - rec["first_t"], 3)
self.in_contact = touching
def _register(self, name):
cls = self.classes.get(name)
angle = round(self.plow.angle, 1) if self.plow is not None else None
rec = self.touches.get(name)
if rec is None:
rec = dict(item=name, cls=cls, classified=cls is not None,
first_t=round(self._t, 3), angle_at_touch=angle,
commanded_at_touch=(round(self.plow.commanded, 1)
if self.plow is not None else None),
angle_min=angle, angle_max=angle,
released_t=None, duration=None, samples=0)
self.touches[name] = rec
self.events.append(dict(t=rec["first_t"], item=name, cls=cls,
angle=angle, kind="touch"))
rec["samples"] += 1
rec["cls"] = cls if cls is not None else rec["cls"]
if angle is not None:
rec["angle_min"] = min(rec["angle_min"], angle)
rec["angle_max"] = max(rec["angle_max"], angle)
# -- what the plow asks it ----------------------------------------------
def is_touching(self, name: str) -> bool:
return name in self.in_contact
def touched(self, name: str) -> bool:
return name in self.touches
def side_for(self, name: str, mapping: dict, swing: float):
"""the angle this item's stored class asks for, or None if it has no class.
Deliberately returns None rather than 0 for an unknown class: 0 is a real command
(drive straight on) and must not double as "no idea".
"""
cls = self.classes.get(name)
if cls is None:
return None
want = mapping.get(cls, "straight")
return {"pos": swing, "neg": -swing}.get(want, 0.0)
def report(self):
return dict(touches=list(self.touches.values()), events=self.events)
+344
View File
@@ -0,0 +1,344 @@
"""Two-way sorting at the plow: each arriving item is steered onto the lane its class
belongs to.
Layout after `scripts/place_plow_lanes.py`:
+Y lane x -7.47..-7.02 y +0.05..+2.05 carries away in +Y
────────────── plow at x=-7.05, 600 mm arm, hinge about Z, +-35 deg
-Y lane x -7.48..-7.03 y -2.05..-0.05 carries away in -Y
Goods reach the plow having already passed the pusher, so class D is gone; what arrives is
B and C, and the plow splits them.
Which sign of the plow angle feeds which lane is **measured, not assumed** - the arm sits
on a prim that carries its own rotateZ=180, and the blade deflects toward the side it
slopes away from, which is easy to get backwards. Call :func:`calibrate` once and it
returns the mapping to hand to :class:`PlowSorter`.
The plow is compliant (angular force drive), so a commanded angle is a request. Everything
here reads `Plow.angle` for the real pose and never assumes the arm arrived.
"""
from __future__ import annotations
from pxr import Gf, PhysxSchema, UsdPhysics, UsdShade
from .. import config as C
from . import plow_cell as _cell
from .plow import Plow
from .plow_contact import PlowContact
LANE_NEG = "/ConveyorTrack_01/Belt" # perpendicular, carries -Y
LANE_POS = "/World/ConveyorTrack_01/Belt" # its mirror, carries +Y
# the arm reaches to x=-6.52; trip the sensor upstream of that so the blade has time to
# take up its angle before the item is on it
SENSE_X = -6.30
SENSE_Y0, SENSE_Y1 = -0.45, 0.45 # full belt width: a narrow gate misses edge-riders
SENSE_Z = C.BELT_Z + 0.025 # where the visible stripe is drawn
SENSE_HEIGHT = 0.40 # the curtain is cast from this high above the belt
SENSE_CLEAR = 0.001 # stops 1 mm short of it: a 2.4 mm watch is still inside
SENSE_RAYS = 181 # 5 mm spacing - narrower than the 6.4 mm `pen`
GATE_WINDOW = 0.15 # no rays are cast unless an item is this close to the line
# lane near edges after scripts/place_plow_lanes.py
LANE_SETTLED_Y = 0.50 # beyond this the item is committed to a lane
# Tray interiors, measured off the walls scripts/place_plow_lanes.py builds, NOT guessed:
# B walls x -7.25 / -6.35, y -3.35 / -2.55 -> centre (-6.80, -2.95)
# C walls x -8.67 / -7.77, y +1.88 / +2.68 -> centre (-8.22, +2.28)
# The earlier values were a wall position rather than a centre, and were out by 0.45-0.50 m.
# That mattered: an item resting exactly in tray C measured |x - cx| = 0.50, which failed the
# `< CONTAINER_R` test, so a correct delivery was scored as a miss.
CONTAINER_B = (-9.54, 1.82) # re-measured after the trays were moved onto the lane exits
CONTAINER_C = (-10.45, -0.225) # stale values here score a correct delivery as a miss
CONTAINER_R = 0.55 # tray half-width is 0.45; a little slack for the resting pose
CONTAINER_LIP_Z = 1.72 # tray floor sits at 1.16, so anything inside is below this
# Where each lane has to carry goods, in WORLD terms: lane B straight out along -Y, lane C
# out toward its tray, which sits off at 45 deg. `_cell.drive_belt` resolves these into each
# belt's own frame - the +Y lane is laid diagonally, so its local X is neither +X nor +Y.
# Directions for the FORK layout (scripts/build_fork_v2.py + the channel split):
# C runs straight on down the line, B branches 45 deg to +Y.
# These were left over from the old T layout and drove both belts the wrong way - goods
# reached the apex, were correctly routed to their side by the blade, and then sat there
# because the branch under them was pulling across or backwards. Same class of fault as
# ConveyorTrack_04 at the start: a direction not recomputed after the geometry moved.
LANE_DIR = {
LANE_POS: (-1.0, 0.0, 0.0), # /World/ConveyorTrack_01 - channel C, straight
LANE_NEG: (-0.7071, 0.7071, 0.0), # /ConveyorTrack_01 - channel B, 45 deg +Y
}
# The decks that bridge the junction were built as **static plates**, and that is where
# goods died. The blade cams an item sideways only while the belt is still driving it into
# the blade; the moment it slides off the driven belt onto a dead plate nothing pushes it
# any more - not the belt, which no longer reaches it, and not the blade, which is holding a
# fixed angle. It stops on the plate, exactly at the belt edge. Every "it touches and then
# just sits there" observation is this.
#
# So the decks are driven too, each toward the lane it feeds. They are Mesh prims, so
# `drive_belt` gives them a kinematic body first.
DECK_DIR = {
"/World/PlowCornerDeck_B": (-0.7071, 0.7071, 0.0),
"/World/PlowCornerDeck_C": (-1.0, 0.0, 0.0),
# the transition plates, extended inboard to |y| = 0.20 by
# scripts/extend_transition_decks.py so they reach the band where the blade lets go
"/World/PlowTransition_B": (-0.7071, 0.7071, 0.0),
"/World/PlowTransition_C": (-1.0, 0.0, 0.0),
}
def configure_lanes(stage, speed=None):
"""drive both plow lanes, and the decks that bridge them to the main run, outward"""
speed = speed if speed is not None else C.BELT_SPEED
driven = []
for path, world_dir in list(LANE_DIR.items()) + list(DECK_DIR.items()):
if _cell.drive_belt(stage, path, world_dir, speed) is not None:
driven.append(path)
return driven
# The conveyor art carries its own collider (SM_ConveyorBelt_*_02, collision=True), and
# that includes the blue side rails. At a plow station the rails are cut away on the
# discharge side - goods have to leave the belt sideways. Left in place they simply stop
# everything at the lane entry, which is what "nothing reaches the bins" looked like.
JUNCTION_SHELLS = [
"/World/ConveyorTrack_04/SM_ConveyorBelt_A06_02", # the run through the plow
"/ConveyorTrack_01/SM_ConveyorBelt_A06_02", # lane B structure
"/World/ConveyorTrack_01/SM_ConveyorBelt_A06_02", # lane C structure
]
def open_junction(stage):
"""drop the shell colliders at the plow so goods can cross onto the lanes.
Only the decorative shell loses its collider; each Belt keeps its own, so goods still
ride on a surface and cannot fall through.
"""
opened = []
for path in JUNCTION_SHELLS:
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
continue
attr = prim.GetAttribute("physics:collisionEnabled")
if not attr:
attr = UsdPhysics.CollisionAPI.Apply(prim).CreateCollisionEnabledAttr()
attr.Set(False)
opened.append(path)
return opened
def keep_lanes_active(stage):
"""`plow_cell.deactivate_stray()` switches the -Y lane off as a duplicate. It is not a
duplicate - it is half the sorter."""
prim = stage.GetPrimAtPath("/ConveyorTrack_01")
if prim.IsValid() and not prim.IsActive():
prim.SetActive(True)
return True
return False
class PlowSorter:
"""steers each arriving item onto the lane its class maps to"""
def __init__(self, stage, cell, classes, mapping, swing=None, sense_x=SENSE_X,
kinematic=True, contact_sensor=True):
"""
cell : mechanics.Cell, for item poses
classes : dict name -> class letter
mapping : dict class letter -> "pos" | "neg" | "straight"
"""
self.stage = stage
self.cell = cell
self.classes = dict(classes)
self.mapping = dict(mapping)
self.swing = C.PLOW_SWING if swing is None else swing
self.sense_x = sense_x
# kinematic by default: the arm is turned directly at PLOW_RATE rather than
# asked to hold an angle, because the force drive never settled (see sim/plow.py)
self.plow = Plow(stage, kinematic=kinematic)
if not kinematic:
self.plow.gains(stiffness=C.PLOW_SORT_STIFFNESS, damping=C.PLOW_SORT_DAMPING,
max_force=C.PLOW_SORT_MAX_FORCE)
self.plow.home()
# Contact sensing on the blade itself. The arming beam upstream says something is
# coming; this says the blade is touching *this* item, and it carries the class the
# item was given at the camera - so the steer is driven by the stored verdict, never
# by anything re-derived at the blade.
self.contact = (PlowContact(stage, self.classes, plow=self.plow).install()
if contact_sensor else None)
self.angle_for = {}
self.decided = {}
self._holding = None
self._latched = None # (item, angle) the blade is committed to
self.returning = False # blade on its way back to centre
self._nudge_left = 0.0 # seconds remaining in the current nudge
self._nudge_angle = 0.0
self.swept = set() # items the gate has already armed for
self._active = None # (item, angle) the blade is holding right now
self.pending = {} # item -> angle, everything armed and not yet past
self.conflicts = set() # items that shared the zone with another class
self.gate_log = [] # what the laser saw, for the run report
self.homed = 0 # times it has finished a return
from omni.physx import get_physx_scene_query_interface
self._query = get_physx_scene_query_interface()
# -- sensing ------------------------------------------------------------
def sensor(self):
"""item crossing the gate, or None - a dense light curtain, armed only when needed.
Two earlier attempts failed and both are worth recording. A single horizontal beam
is blind to flat stock: meshes are 0.49x real size, so `watch` (5 mm real) stands
2.4 mm tall and drove under a beam 25 mm up. A sparse downward curtain fixed the
tall-enough cases but still lost `pen`, which is 6.4 mm wide in scene and slipped
between rays spaced 75 mm apart. An `overlap_box` query would have no blind spot at
all, but it crashed the process outright, so it is not used here.
What works is a curtain dense enough that nothing fits between the rays - 5 mm
spacing against a 6.4 mm minimum width - reaching to 1 mm off the belt so even the
watch is inside it. That many rays every step would be wasteful, so a pose check
gates the gate: unless some item is within GATE_WINDOW of the line, no ray is cast
at all, which is most of the time.
The red stripe at /World/PlowLaserGate marks where it stands. It carries no
collider, so it can never be what the rays hit.
"""
near = False
for name in self.cell.items:
if abs(float(self.cell.pose(name)[0]) - self.sense_x) < GATE_WINDOW:
near = True
break
if not near:
return None
z0 = C.BELT_Z + SENSE_HEIGHT
reach = SENSE_HEIGHT - SENSE_CLEAR
for i in range(SENSE_RAYS):
y = SENSE_Y0 + (SENSE_Y1 - SENSE_Y0) * i / (SENSE_RAYS - 1)
hit = self._query.raycast_closest(
[self.sense_x, y, z0], [0.0, 0.0, -1.0], reach)
if not hit or not hit.get("hit"):
continue
path = str(hit.get("rigidBody") or hit.get("collision") or "")
for name in self.cell.items:
if f"/World/Items/{name}" in path:
return name
return None
def side_for(self, name):
"""+swing / -swing / 0, from the item's class"""
want = self.mapping.get(self.classes.get(name), "straight")
return {"pos": self.swing, "neg": -self.swing}.get(want, 0.0)
def preset_for(self, name):
"""the angle the blade should ALREADY be holding when this item arrives"""
return float(C.PLOW_PRESET.get(self.classes.get(name), 0.0))
# -- per-step -----------------------------------------------------------
def update(self, dt):
"""serve a QUEUE of armed items, always the one nearest the blade.
Holding one item at a time is fine at a 2.5 m pitch and wrong at 700 mm. The gate
sits 1.62 m upstream of the blade's trailing edge, so at 1 m/s an item occupies the
plow for 1.62 s while the next arrives every 0.70 s - one blade, three items in the
zone. The single `_active` slot simply ignored the other two, which is exactly the
"the shift does not fire" symptom: the blade was still committed to someone else.
So every item the gate sees is queued with its angle, and each step the blade serves
whichever queued item is CLOSEST to the blade and not yet past it. That cannot make
one blade sort two items that need opposite angles at the same instant - nothing
can - so those cases are counted in `self.conflicts` and reported, rather than
silently lost.
"""
if self.contact is not None:
self.contact.tick(dt)
for _n in list(self.swept):
if float(self.cell.pose(_n)[0]) > C.PLOW_REARM_X:
self.swept.discard(_n)
self.decided.pop(_n, None)
self.pending.pop(_n, None)
if self.contact is not None:
self.contact.touches.pop(_n, None)
# ---- the laser arms the blade -------------------------------------
seen = self.sensor()
if seen is not None and seen not in self.swept:
ang = self.preset_for(seen)
self.swept.add(seen)
self.decided[seen] = ang
self.gate_log.append(dict(
item=seen, cls=self.classes.get(seen), angle=round(ang, 1),
x=round(float(self.cell.pose(seen)[0]), 3)))
if abs(ang) > 1e-6:
self.pending[seen] = ang
# ---- drop whatever is already past the blade ----------------------
for n in list(self.pending):
if float(self.cell.pose(n)[0]) < C.PLOW_RELEASE_X:
self.pending.pop(n, None)
# ---- serve the one closest to the blade ---------------------------
if self.pending:
nearest = min(self.pending, key=lambda n: abs(float(self.cell.pose(n)[0]) - C.PLOW_X))
ang = self.pending[nearest]
wanted = {self.pending[n] for n in self.pending}
if len(wanted) > 1:
self.conflicts.add(nearest) # two classes in the zone, one blade
self._active = (nearest, ang)
self.plow.step_toward(ang, dt, rate=C.PLOW_SWEEP_RATE)
return
self._active = None
if abs(self.plow.commanded) > 0.5:
self.returning = True
elif self.returning:
self.returning = False
self.homed += 1
self.plow.step_toward(C.PLOW_REST_ANGLE, dt, rate=C.PLOW_SWEEP_RATE)
def lane_of(self, name):
"""where the item ended up: a container, a lane, still on the line, or lost"""
p = self.cell.pose(name)
x, y, z = float(p[0]), float(p[1]), float(p[2])
# The pusher's D bin FIRST. It sits at y +1.27..+2.05, so the `y > LANE_SETTLED_Y`
# test below claims it as "lane_C" and a delivered item is scored as a miss. That
# hid a working pusher: 5 of 11 class-D items in the 25-object run were physically
# in the bin (x -3.7..-4.0, y +1.42..+1.84, z 1.25) and every one was logged as
# lane_C. Order of tests is not cosmetic here.
if C.BIN_X0 < x < C.BIN_X1 and C.BIN_Y0 < y < C.BIN_Y1 and z < C.BIN_LIP_Z:
return "bin"
for tag, (cx, cy) in (("container_B", CONTAINER_B), ("container_C", CONTAINER_C)):
if abs(x - cx) < CONTAINER_R and abs(y - cy) < CONTAINER_R and z < CONTAINER_LIP_Z:
return tag
# FORK layout: lane B is the +Y branch, lane C carries straight on down the run.
# Reversed under the old T, and leaving it reported a correct branch as the other one.
if y > LANE_SETTLED_Y:
return "lane_B"
if x < C.MAIN_X0 and abs(y) < LANE_SETTLED_Y:
return "lane_C"
if z < C.BELT_Z - 0.4:
return "floor"
return "line"
def calibrate_mapping():
"""class -> which way the blade swings, **measured on the running cell**.
The docstring at the top of this module warns that the sign is easy to get backwards,
and the first version had it backwards. Observed with the default mapping: `barrel`,
class C, mapped to "pos" (+30 deg), came to rest at y = -2.44 - the *-Y* lane, which
feeds tray B. So a positive swing deflects toward -Y:
+swing -> -Y lane -> tray B
-swing -> +Y lane -> tray C
Re-measure with a single item and `PlowSorter.lane_of` if the arm or the lanes are ever
re-laid; do not reason it out from the geometry, the arm's parent carries rotateZ=180
and the blade deflects away from the face it slopes toward.
"""
# FORK layout, plow on the apex: C runs straight on and must not be steered at all;
# B is the only class that actuates. A positive swing deflects toward -Y (a property of
# the arm mount, unchanged), and the B branch is at +Y, so B needs a NEGATIVE swing.
# Measured this session: "pos" put bolts_cluster (B) at y -0.155, the wrong side.
return {"B": "neg", "C": "straight", "D": "straight"}
+144
View File
@@ -0,0 +1,144 @@
"""Vision stack on top of the plow cell: infeed belt, item feeder, laser gate and the
CRE-ROI v2b decision that tells the pusher what to divert.
Purely additive. `sim/plow_cell.py` still owns the belts and the plow, and nothing here
edits the authored kinematics — the DiverterAnimGraph, the plow hinge and the pusher's own
drive are left exactly as `plow_cell.prepare()` leaves them.
The layout the scene augmentation produced:
ConveyorTrack_05 x 0.00 .. +2.00 infeed, items are released at x=+1.70
ConveyorTrack_02 x -2.00 .. 0.00 camera portal straddles x=-0.75
ConveyorTrack_03 x -6.00 .. -2.00 laser gate at x=-3.74, pusher at x=-3.90
Belt_01 branch to the bin
Goods run -X at the configured belt speed, so an item is released, measured under the
portal, and reaches the gate about 3.4 s later at 1 m/s.
"""
from __future__ import annotations
import json
from pathlib import Path
from pxr import Gf, PhysxSchema, UsdGeom, UsdPhysics, UsdShade
from .. import config as C
from . import plow_cell as _cell
from . import scene as _scene
# the conveyor added by scripts/add_vision_to_plow_cell.py
INFEED = "/World/ConveyorTrack_05/Belt"
INFEED_TRACK = "/World/ConveyorTrack_05"
INFEED_X0, INFEED_X1 = 0.0, 2.0
# release point: on the infeed belt, clear of its upstream edge so the item settles before
# it reaches the transfer to ConveyorTrack_02
SPAWN_X = 1.70
ITEMS_ROOT = _scene.ITEMS_ROOT
LASER_GATE = "/World/SortingRig/LaserGate"
def configure_infeed(stage, speed=None):
"""drive the added conveyor the same way as the rest of the line.
Its local X is +X in world (unlike the branch, which is rotated), so the surface
velocity is simply -speed on X.
"""
speed = speed if speed is not None else C.BELT_SPEED
prim = stage.GetPrimAtPath(INFEED)
if not prim.IsValid():
raise RuntimeError(
f"{INFEED} missing - run scripts/add_vision_to_plow_cell.py first")
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI.Apply(prim)
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim)
PhysxSchema.PhysxSurfaceVelocityAPI(prim).CreateSurfaceVelocityAttr().Set(
Gf.Vec3f(-speed, 0.0, 0.0))
grip = stage.GetPrimAtPath(_cell.GRIP_MATERIAL)
if grip.IsValid():
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(UsdShade.Material(grip),
bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
# the added track brings its own conveyor graph; it carries no speed and would only
# fight the explicit surface velocity
for suffix in ("", "_01"):
g = stage.GetPrimAtPath(f"{INFEED_TRACK}/ConveyorBeltGraph{suffix}")
if g.IsValid():
g.SetActive(False)
return prim
def load_items(stage, meshes_dir=None):
"""the bundled per-class test meshes, as dynamic rigid bodies parked off the line"""
meshes_dir = Path(meshes_dir or C.MESHES)
manifest = json.loads((meshes_dir / "manifest.json").read_text())
UsdGeom.Xform.Define(stage, ITEMS_ROOT)
items = {}
for i, (name, meta) in enumerate(sorted(manifest.items())):
usd = meshes_dir / f"{name}.usd"
if not usd.exists():
continue
prim = UsdGeom.Xform.Define(stage, f"{ITEMS_ROOT}/{name}").GetPrim()
refs = prim.GetReferences()
refs.ClearReferences()
refs.AddReference(str(usd))
# Meshes flattened out of the working scene bring their own xformOp:translate at
# float precision. ClearXformOpOrder() drops the *order*, not the attribute, so
# adding a fresh double-precision op collides with what is already there and USD
# raises. Match whatever precision the prim already carries.
xf = UsdGeom.Xformable(prim)
xf.ClearXformOpOrder()
park = (9.0 + 1.2 * i, 5.0, 0.4)
# Items exported from the working scene carry translate as float3, and
# ClearXformOpOrder() drops the ORDER but keeps the attribute. AddTranslateOp() then
# warns-as-raises about the precision mismatch (it still succeeds), and a retry hits
# "already exists". Reuse the attribute that is there instead of adding anything.
attr = prim.GetAttribute("xformOp:translate")
if attr:
op = UsdGeom.XformOp(attr)
op.Set(Gf.Vec3f(*park) if str(attr.GetTypeName()) == "float3" else Gf.Vec3d(*park))
xf.SetXformOpOrder([op])
else:
xf.AddTranslateOp().Set(Gf.Vec3d(*park))
UsdPhysics.RigidBodyAPI.Apply(prim)
# exported meshes arrive kinematic and hidden; both make them inert
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr().Set(0.6)
px = PhysxSchema.PhysxRigidBodyAPI.Apply(prim)
px.CreateEnableCCDAttr().Set(True)
px.CreateSolverPositionIterationCountAttr().Set(24)
px.CreateSleepThresholdAttr().Set(0.0) # a settled item must stay draggable
# without this a blade sweeping into the item separates them at whatever speed
# PhysX picks, which fires the item off the line instead of deflecting it
px.CreateMaxDepenetrationVelocityAttr().Set(C.MAX_DEPENETRATION)
UsdGeom.Imageable(prim).MakeVisible()
items[name] = meta
return items
def prepare(stage, belt_speed=None, script_control=True, meshes_dir=None):
"""plow_cell.prepare() plus the infeed belt, the items and the camera housekeeping"""
info = _cell.prepare(stage, belt_speed=belt_speed, script_control=script_control)
configure_infeed(stage, belt_speed)
hidden = _scene.hide_aim_markers(stage)
items = load_items(stage, meshes_dir)
# mechanics.Cell releases at C.SPAWN_X; the plow cell's infeed is shorter than the
# sorter's, so point it at this belt. run.py already sets C.BELT_SPEED the same way.
C.SPAWN_X = SPAWN_X
calib_path = C.CONFIG / "calib.json"
info.update(items=items, aim_markers_hidden=hidden, spawn_x=SPAWN_X,
calib=json.loads(calib_path.read_text()) if calib_path.exists() else None)
return info
def load(usd_path=None, belt_speed=None, script_control=True, meshes_dir=None):
stage = _cell.open_scene(usd_path)
return stage, prepare(stage, belt_speed, script_control, meshes_dir)
+170
View File
@@ -0,0 +1,170 @@
"""Entry point: build the cell, start the belt, run CRE-ROI v2b on every item as it passes
under the stand, and divert class D with the pusher.
./python.sh -m robozon_sorter.sim.run # windowed, watchable
./python.sh -m robozon_sorter.sim.run --headless # batch, prints the log
./python.sh -m robozon_sorter.sim.run --no-vision # mechanics only, uses ground truth
It also runs inside an already-open Isaac Sim: `from robozon_sorter.sim.run import main`.
"""
from __future__ import annotations
import argparse
import json
import sys
def parse_args(argv=None):
p = argparse.ArgumentParser(description="Robozon conveyor sorting cell")
p.add_argument("--headless", action="store_true", help="no window")
p.add_argument("--no-vision", action="store_true",
help="skip CRE-ROI and route on ground truth (mechanics smoke test)")
p.add_argument("--loops", type=int, default=1, help="passes over the test items")
p.add_argument("--speed", type=float, default=None, help="override belt speed, m/s")
p.add_argument("--pusher", type=float, default=None, help="override blade speed, m/s")
p.add_argument("--log", default=None, help="write the run log here as JSON")
return p.parse_args(argv)
async def _run(app_utils, stage, args):
from .. import config as C
from . import scene as S
from .mechanics import Cell
if args.speed:
C.BELT_SPEED = args.speed
built = S.build(stage)
items = built["items"]
print(f"cell built: {len(items)} test items "
f"({sorted({m['zone'] for m in items.values()})})")
vision = None
if not args.no_vision:
from ..cv.pipeline import CreRoiV2b
vision = CreRoiV2b()
vision.attach_cameras()
print("CRE-ROI v2b ready; gate pixels:", vision.gate_px)
await app_utils.update_app_async(steps=40)
cell = Cell(stage, items.keys())
cell.park_all()
await app_utils.update_app_async(steps=15)
import omni.timeline
timeline = omni.timeline.get_timeline_interface()
app_utils.play(commit=True)
await app_utils.update_app_async(steps=20)
order = [n for _ in range(args.loops) for n in sorted(items)]
dt = 1.0 / 60.0
log, active, done = [], [], set()
classified, diverted = set(), set()
nxt, t = 0, 0.0
print(f"\n{'t':>7} event")
while t < 45.0 * args.loops * max(len(order), 1) / 6 and len(done) < len(order):
await app_utils.update_app_async(steps=2)
t += 2 * dt
if nxt < len(order) and (not active or cell.pose(active[-1])[0] < C.SPAWN_X - C.RELEASE_GAP):
name = order[nxt]
cell.release(name)
active.append(name)
nxt += 1
print(f"{t:7.2f} release {name}")
for name in list(active):
x = float(cell.pose(name)[0])
if name not in classified and abs(x - C.CAM_X) < 0.06:
gt = items[name]["zone"]
if vision is not None:
was_playing = timeline.is_playing()
res = vision.measure()
# Replicator's step stops the timeline; resume or the line freezes
if was_playing and not timeline.is_playing():
timeline.play()
await app_utils.update_app_async(steps=2)
pred = res["cls"]
print(f"{t:7.2f} vision {name:18s} pred={pred} gt={gt} "
f"{'ok' if pred == gt else 'MISS'} dims={res['dims']} "
f"K={res['k']:.2f} views={res['views']} cre={res['cre_ms']}ms")
log.append(dict(item=name, gt=gt, **res))
else:
pred = gt
print(f"{t:7.2f} route {name:18s} class={pred} (ground truth)")
log.append(dict(item=name, gt=gt, cls=pred))
cell.pred = getattr(cell, "pred", {})
cell.pred[name] = pred
classified.add(name)
if (name not in diverted and getattr(cell, "pred", {}).get(name) == "D"
and cell.laser() == name):
took, held = await cell.divert(app_utils, name, speed=args.pusher)
diverted.add(name)
msg = f" (retract held {held:.2f}s)" if held > 0.01 else ""
print(f"{t:7.2f} divert {name:18s} cycle {took:.2f}s{msg}")
t += took
place = cell.where(name)
if place in ("bin", "line-end"):
print(f"{t:7.2f} done {name:18s} -> {place}")
for rec in log:
if rec["item"] == name and "outcome" not in rec:
rec["outcome"] = place
active.remove(name)
done.add(name)
app_utils.stop()
await app_utils.update_app_async(steps=15)
cell.blade_to(C.BLADE_HOME_Y)
graded = [r for r in log if "cls" in r and r["cls"] != "?"]
hits = sum(1 for r in graded if r["cls"] == r["gt"])
print(f"\n {len(log)} items, {hits}/{len(graded)} agreed with ground truth")
if vision is not None and graded:
cre = [r["cre_ms"] for r in graded if r.get("cre_ms")]
tot = [r["total_ms"] for r in graded if r.get("total_ms")]
if cre:
print(f" CRE batched {sum(cre)/len(cre):.0f} ms/item, "
f"end-to-end {sum(tot)/len(tot):.0f} ms/item")
routed = [r for r in log if r.get("outcome")]
if routed:
print(" routing: " + ", ".join(f"{r['item']}->{r['outcome']}" for r in routed))
if args.log:
with open(args.log, "w") as fh:
json.dump(log, fh, indent=2)
print(f" log written to {args.log}")
return log
def main(argv=None):
args = parse_args(argv)
try:
import omni.usd
stage = omni.usd.get_context().get_stage()
inside = stage is not None
except Exception:
inside = False
if not inside:
from isaacsim import SimulationApp
app = SimulationApp({"headless": args.headless, "width": 1600, "height": 900})
import omni.usd
import isaacsim.core.experimental.utils.stage as stage_utils
stage_utils.create_new_stage()
stage = omni.usd.get_context().get_stage()
else:
app = None
import asyncio
import isaacsim.core.experimental.utils.app as app_utils
loop = asyncio.get_event_loop()
try:
return loop.run_until_complete(_run(app_utils, stage, args))
finally:
if app is not None:
app.close()
if __name__ == "__main__":
sys.exit(0 if main() is not None else 1)
+207
View File
@@ -0,0 +1,207 @@
"""Loads the real sorting cell (scene/sorter.usd) and applies the runtime configuration
it needs to actually run.
The scene file is the original build - conveyor art, diverters, camera portal, camera
bodies, laser gate and collection bin exactly as authored. Nothing here rebuilds geometry.
What this module does is re-apply the handful of runtime settings that USD does not carry
and that the cell does not work without; each one is documented where it is applied,
because every one of them was a silent failure at some point.
"""
from __future__ import annotations
import json
from pathlib import Path
from pxr import Gf, PhysxSchema, UsdGeom, UsdPhysics, UsdShade
from .. import config as C
SCENE = C.ROOT / "scene" / "sorter.usd"
# --- prim paths in the authored scene -------------------------------------------------
BELTS = ["/World/ConveyorTrack/Belt", "/World/ConveyorTrack_02/Belt",
"/World/ConveyorTrack_03/Belt", "/World/ConveyorTrack_04/Belt",
"/World/ConveyorTrack_01/Belt"]
SPAWN_BELT = "/World/SortingRig/SpawnBelt"
BRANCH = "/World/ConveyorTrack_03/Belt_01" # the branch the pusher feeds
BLADE = "/World/Diverters/DiverterY_Split/Pusher"
PUSHER_JOINT = "/World/Diverters/DiverterY_Split/PusherSlide"
ANIM_GRAPH = "/World/Diverters/DiverterAnimGraph"
CAMERA_BODIES = "/World/CameraBodies"
ITEMS_ROOT = "/World/Items"
RIG = "/RigRS"
# The blade's parent carries this offset; world_y = PARENT_Y + local_y.
BLADE_PARENT_Y = -0.35
def open_scene(usd_path: str | Path | None = None):
"""open sorter.usd into the current context"""
import omni.usd
path = str(usd_path or SCENE)
if not Path(path).exists():
raise FileNotFoundError(
f"{path} not found. The conveyor art it references lives in assets/conveyors/ - "
"run scripts/fetch_assets.py if that folder is empty."
)
omni.usd.get_context().open_stage(path)
return omni.usd.get_context().get_stage()
# --------------------------------------------------------------------- runtime config
def configure_physics(stage):
scene = stage.GetPrimAtPath("/World/PhysicsScene")
if not scene.IsValid():
scene = UsdPhysics.Scene.Define(stage, "/World/PhysicsScene").GetPrim()
UsdPhysics.Scene(scene).CreateGravityMagnitudeAttr().Set(9.81)
px = PhysxSchema.PhysxSceneAPI.Apply(scene)
# 120 Hz is what the cell was tuned and validated at, together with the 2.5 m/s blade.
# Raising it changes the contact response and the pushed item stops landing in the bin,
# so treat this number and PUSHER_SPEED as a matched pair.
px.CreateTimeStepsPerSecondAttr().Set(120)
px.CreateEnableCCDAttr().Set(True)
px.CreateSolverTypeAttr().Set("TGS")
def configure_belts(stage, speed=None, grip_path="/World/SortingRig/M_beltPhysics"):
"""explicit surface velocities; the authored ConveyorBeltGraphs carry no speed and
would only fight these, so they are switched off.
`grip_path` is where the belt friction material is authored. It defaults to a prim
under the sorter's rig; plow_cell.usd has no SortingRig and passes its own path so the
scene does not grow an empty one.
"""
speed = speed if speed is not None else C.BELT_SPEED
grip = stage.GetPrimAtPath(grip_path)
if not grip.IsValid():
grip = stage.DefinePrim(grip_path, "Material")
pm = UsdPhysics.MaterialAPI.Apply(grip)
pm.CreateStaticFrictionAttr().Set(1.1)
pm.CreateDynamicFrictionAttr().Set(0.95)
pm.CreateRestitutionAttr().Set(0.02)
grip_mat = UsdShade.Material(grip)
def drive(path, vel):
prim = stage.GetPrimAtPath(path)
if not prim.IsValid():
return False
if not prim.HasAPI(UsdPhysics.RigidBodyAPI):
UsdPhysics.RigidBodyAPI.Apply(prim)
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(True)
PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim)
PhysxSchema.PhysxSurfaceVelocityAPI(prim).CreateSurfaceVelocityAttr().Set(Gf.Vec3f(*vel))
api = UsdShade.MaterialBindingAPI.Apply(prim)
api.Bind(grip_mat, bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return True
for path in BELTS + [SPAWN_BELT]:
drive(path, (-speed, 0, 0))
# The branch is rotated: its LOCAL X points along world -Y. surfaceVelocity is given
# in the body's local frame, so carrying goods toward the bin (+Y) needs (-speed,0,0).
# Setting the "obvious" (0,+speed,0) drags them sideways and they sit there.
drive(BRANCH, (-speed, 0, 0))
for track in ["ConveyorTrack", "ConveyorTrack_02", "ConveyorTrack_03",
"ConveyorTrack_04", "ConveyorTrack_01"]:
for graph in (f"/World/{track}/ConveyorBeltGraph", f"/World/{track}/ConveyorBeltGraph_01"):
g = stage.GetPrimAtPath(graph)
if g.IsValid():
g.SetActive(False)
def configure_pusher(stage):
"""the blade is driven kinematically from script.
Its authored PhysicsPrismaticJoint is unusable at runtime: USD drive-target writes
reach PhysX about a second late, so the blade never completes its stroke while the
item is still in reach. The joint is disabled and the blade is moved directly.
"""
blade = stage.GetPrimAtPath(BLADE)
if not blade.IsValid():
raise RuntimeError(f"{BLADE} missing - is this the right scene?")
UsdPhysics.RigidBodyAPI(blade).CreateKinematicEnabledAttr().Set(True)
joint = stage.GetPrimAtPath(PUSHER_JOINT)
if joint.IsValid():
joint.GetAttribute("physics:jointEnabled").Set(False)
graph = stage.GetPrimAtPath(ANIM_GRAPH)
if graph.IsValid():
graph.SetActive(False) # otherwise it rewrites the diverter targets every tick
# the blade must sweep through the conveyor rails rather than grind on them
filt = UsdPhysics.FilteredPairsAPI.Apply(blade)
rel = filt.CreateFilteredPairsRel()
have = {str(t) for t in rel.GetTargets()}
for path in BELTS + [SPAWN_BELT, BRANCH, "/World/Diverters/DiverterY_Split/Base"]:
if stage.GetPrimAtPath(path).IsValid() and path not in have:
rel.AddTarget(path)
# seat the blade just over the belt so flat items cannot slip underneath
for op in UsdGeom.Xformable(blade).GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
v = op.Get()
op.Set(Gf.Vec3d(v[0], C.BLADE_HOME_Y - BLADE_PARENT_Y, -0.135))
break
def hide_aim_markers(stage):
"""the camera bodies carry cosmetic aim-ray cones that sit right over the inspection
point; left visible they dominate the frame and segmentation locks onto them."""
n = 0
for rig in ["RealSense_D435", "Orbbec_Gemini305", "Orbbec_Gemini345"]:
p = stage.GetPrimAtPath(f"{CAMERA_BODIES}/{rig}/AimRay")
if p.IsValid():
UsdGeom.Imageable(p).MakeInvisible()
n += 1
return n
def load_test_items(stage, meshes_dir=None):
"""add the bundled per-class test meshes as dynamic rigid bodies"""
meshes_dir = Path(meshes_dir or C.MESHES)
manifest = json.loads((meshes_dir / "manifest.json").read_text())
UsdGeom.Xform.Define(stage, ITEMS_ROOT)
items = {}
for i, (name, meta) in enumerate(sorted(manifest.items())):
usd = meshes_dir / f"{name}.usd"
if not usd.exists():
continue
prim = UsdGeom.Xform.Define(stage, f"{ITEMS_ROOT}/{name}").GetPrim()
refs = prim.GetReferences()
refs.ClearReferences() # idempotent: prepare() may run more than once
refs.AddReference(str(usd))
xf = UsdGeom.Xformable(prim)
xf.ClearXformOpOrder()
xf.AddTranslateOp().Set(Gf.Vec3d(9.0 + 1.2 * i, 5.0, 0.4))
UsdPhysics.RigidBodyAPI.Apply(prim)
# meshes exported from a streaming scene arrive kinematic and hidden - both make
# them inert: kinematic ignores gravity and belt friction, hidden shows nothing
UsdPhysics.RigidBodyAPI(prim).CreateKinematicEnabledAttr().Set(False)
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr().Set(0.6)
px = PhysxSchema.PhysxRigidBodyAPI.Apply(prim)
px.CreateEnableCCDAttr().Set(True)
px.CreateSolverPositionIterationCountAttr().Set(24)
px.CreateSleepThresholdAttr().Set(0.0) # a settled item must still be draggable
UsdGeom.Imageable(prim).MakeVisible()
items[name] = meta
return items
def prepare(stage, belt_speed=None, meshes_dir=None):
"""everything the authored scene needs before it will run"""
configure_physics(stage)
configure_belts(stage, belt_speed)
configure_pusher(stage)
hidden = hide_aim_markers(stage)
items = load_test_items(stage, meshes_dir)
calib = json.loads((C.CONFIG / "calib.json").read_text())
return dict(items=items, calib=calib, aim_markers_hidden=hidden)
def load(usd_path=None, belt_speed=None, meshes_dir=None):
stage = open_scene(usd_path)
return stage, prepare(stage, belt_speed, meshes_dir)
+135
View File
@@ -0,0 +1,135 @@
"""Self-running item feeder: press Play and goods appear on the infeed belt one at a time,
spaced by a fixed pitch along the belt.
It hooks a PhysX step callback rather than living in an outer async loop, so the scene runs
on its own from the Play button - no driver script has to be babysitting it. The same
callback also drives the laser gate and the pusher when `route` is enabled.
Pitch is measured along the belt between consecutive items, so the release condition is
simply "the last one released has travelled PITCH from the spawn point".
"""
from __future__ import annotations
from .. import config as C
class AutoFeeder:
def __init__(self, cell, order=None, pitch=None, loop=False,
route=None, on_event=None):
"""
cell : mechanics.Cell
order : release order; defaults to every loaded item
pitch : metres between consecutive items along the belt
route : dict name -> class; when given, class D is diverted by the pusher
on_event : optional callback(kind, name, payload) for logging
"""
self.cell = cell
self.order = list(order or cell.items)
self.pitch = pitch if pitch is not None else C.RELEASE_GAP
self.loop = loop
self.route = route or {}
self.on_event = on_event
self._sub = None
self.reset()
def reset(self):
self.next_index = 0
self.active = []
self.released = []
self.diverted = set()
self.finished = {}
self._busy = False # a push cycle owns the blade until it completes
self._cycle = None
# ------------------------------------------------------------------ install
def install(self):
"""subscribe to the physics step; from here on the cell runs itself on Play"""
from omni.physx import get_physx_interface
if self._sub is None:
self._sub = get_physx_interface().subscribe_physics_step_events(self._on_step)
return self
def remove(self):
self._sub = None
def _emit(self, kind, name, payload=None):
if self.on_event:
self.on_event(kind, name, payload or {})
# ------------------------------------------------------------------ per step
def _on_step(self, dt):
try:
self._release_due()
self._service_gate(dt)
self._retire()
except Exception as exc: # never let a callback kill the sim
self._emit("error", "", {"exc": repr(exc)})
def _release_due(self):
if self._busy or self.next_index >= len(self.order):
if self.loop and self.next_index >= len(self.order) and not self.active:
self.next_index = 0
return
if self.active:
travelled = C.SPAWN_X - float(self.cell.pose(self.active[-1])[0])
if travelled < self.pitch:
return
name = self.order[self.next_index]
self.cell.release(name)
self.active.append(name)
self.released.append(name)
self.next_index += 1
self._emit("release", name, {"pitch": self.pitch})
def _service_gate(self, dt):
"""laser gate -> pusher, as a small state machine so it spans several steps"""
if self._cycle is not None:
self._step_cycle(dt)
return
for name in list(self.active):
if name in self.diverted or self.route.get(name) != "D":
continue
if self.cell.laser() == name:
self._cycle = dict(name=name, phase="extend", t=0.0,
y=C.BLADE_HOME_Y, held=0.0)
self._busy = True
self._emit("gate", name, {})
return
def _step_cycle(self, dt):
c = self._cycle
name = c["name"]
speed = C.PUSHER_SPEED
if c["phase"] == "extend":
c["y"] = min(C.BLADE_OUT_Y, c["y"] + speed * dt)
self.cell.blade_to(c["y"])
if c["y"] >= C.BLADE_OUT_Y - 1e-6:
c["phase"] = "clear"
elif c["phase"] == "clear":
c["t"] += dt
if float(self.cell.pose(name)[1]) > 0.50 or c["t"] > 1.5:
c["phase"] = "wait"
c["t"] = 0.0
elif c["phase"] == "wait":
# do not sweep the blade back through whatever has already arrived
busy = self.cell.blade_path_busy(name)
c["t"] += dt
if busy is None or c["t"] > 1.5:
c["held"] = c["t"]
c["phase"] = "retract"
elif c["phase"] == "retract":
c["y"] = max(C.BLADE_HOME_Y, c["y"] - speed * dt)
self.cell.blade_to(c["y"])
if c["y"] <= C.BLADE_HOME_Y + 1e-6:
self.diverted.add(name)
self._busy = False
self._cycle = None
self._emit("divert", name, {"held": round(c["held"], 3)})
def _retire(self):
for name in list(self.active):
place = self.cell.where(name)
if place in ("bin", "line-end"):
self.finished[name] = place
self.active.remove(name)
self._emit("done", name, {"where": place})
+132
View File
@@ -0,0 +1,132 @@
"""Floor and lighting for the plow cell.
The authored scene has one distant light and no floor at all: goods that miss a tray fall
for kilometres (traces from the first sorting runs end at z = -20000), which makes "dropped"
and "thrown across the room" look identical in a log and gives the eye nothing to judge the
cell against. A floor turns both into something you can see and measure.
The floor is a **static collider** - no rigid body - so it costs nothing to simulate and
catches anything that leaves the line at the height a real floor would.
Lighting presets exist because the vision stack is measured under them. They are the same
three the earlier flow evaluations used, so results stay comparable:
bright dome 1800 + strong key easy case, high contrast on the belt
dim dome 350 + weak key near the sensor's noise floor
harsh dome 120 + hard low key long shadows, specular blowout on the rails
`apply_lighting(stage, "dim")` swaps a preset without touching anything else, so a run can
sweep them. Every light this module makes lives under /World/CellLighting; authored lights
elsewhere are dimmed rather than deleted, so the scene file stays as built.
"""
from __future__ import annotations
from pxr import Gf, Sdf, UsdGeom, UsdLux, UsdPhysics, UsdShade
from .. import config as C
FLOOR = "/World/CellFloor"
LIGHTS = "/World/CellLighting"
# dome intensity, key (distant) intensity, key rotation XYZ, dome colour
PRESETS = {
"bright": dict(dome=1800.0, key=3000.0, angle=(-45.0, 20.0, 0.0),
tint=(1.0, 1.0, 1.0)),
"dim": dict(dome=350.0, key=600.0, angle=(-50.0, -25.0, 0.0),
tint=(0.92, 0.95, 1.0)),
"harsh": dict(dome=120.0, key=5200.0, angle=(-16.0, 65.0, 0.0),
tint=(1.0, 0.95, 0.86)),
}
DEFAULT_PRESET = "bright"
def add_floor(stage, z=None, size=60.0, colour=(0.22, 0.23, 0.25)):
"""a static floor under the whole cell.
Collision goes on the *child mesh*, with the position on the parent Xform: a Cube that
is both scaled and collided reports the wrong bounds to PhysX and goods drop straight
through it. Cube size is 2.0 so the scale op equals the half-extent.
"""
z = C.FLOOR_Z if z is None else z
xf = UsdGeom.Xform.Define(stage, FLOOR)
ops = UsdGeom.Xformable(xf.GetPrim())
ops.ClearXformOpOrder()
ops.AddTranslateOp().Set(Gf.Vec3d(-3.0, 0.0, z - 0.05))
mesh = UsdGeom.Cube.Define(stage, f"{FLOOR}/Mesh")
mesh.CreateSizeAttr().Set(2.0)
mops = UsdGeom.Xformable(mesh.GetPrim())
mops.ClearXformOpOrder()
mops.AddScaleOp().Set(Gf.Vec3f(size / 2.0, size / 2.0, 0.05))
mesh.CreateDisplayColorAttr().Set([Gf.Vec3f(*colour)])
UsdPhysics.CollisionAPI.Apply(mesh.GetPrim())
mat = UsdPhysics.MaterialAPI.Apply(
stage.DefinePrim(f"{FLOOR}/M_floor", "Material"))
mat.CreateStaticFrictionAttr().Set(0.7)
mat.CreateDynamicFrictionAttr().Set(0.6)
mat.CreateRestitutionAttr().Set(0.0) # a dropped item must not bounce away
api = UsdShade.MaterialBindingAPI.Apply(mesh.GetPrim())
api.Bind(UsdShade.Material(stage.GetPrimAtPath(f"{FLOOR}/M_floor")),
bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return FLOOR
def _dim_authored(stage):
"""turn authored lights down instead of deleting them, so the file stays as built"""
n = 0
for prim in stage.Traverse():
if LIGHTS in str(prim.GetPath()):
continue
a = prim.GetAttribute("inputs:intensity")
if a and a.IsValid() and a.Get() is not None:
a.Set(0.0)
n += 1
return n
def apply_lighting(stage, preset=DEFAULT_PRESET):
"""install (or re-point) the cell's dome + key light to a named preset"""
if preset not in PRESETS:
raise ValueError(f"unknown preset {preset!r}; have {sorted(PRESETS)}")
p = PRESETS[preset]
_dim_authored(stage)
UsdGeom.Xform.Define(stage, LIGHTS)
dome = UsdLux.DomeLight.Define(stage, f"{LIGHTS}/Dome")
dome.CreateIntensityAttr().Set(p["dome"])
dome.CreateColorAttr().Set(Gf.Vec3f(*p["tint"]))
key = UsdLux.DistantLight.Define(stage, f"{LIGHTS}/Key")
key.CreateIntensityAttr().Set(p["key"])
key.CreateAngleAttr().Set(1.5 if preset != "harsh" else 0.3) # harsh = sharp shadows
kops = UsdGeom.Xformable(key.GetPrim())
kops.ClearXformOpOrder()
kops.AddRotateXYZOp().Set(Gf.Vec3f(*p["angle"]))
stage.GetPrimAtPath(LIGHTS).SetCustomDataByKey("preset", preset)
return dict(preset=preset, **p)
def stage_cell(stage, preset=DEFAULT_PRESET, floor=True):
"""floor + lighting in one call"""
out = dict(lighting=apply_lighting(stage, preset))
if floor:
out["floor"] = add_floor(stage)
# Only NOW switch off the scene's own /Environment/defaultLight, and only if the preset
# really did put lights in. Doing it first - as this did briefly - hides the one
# authored light before its replacement exists, so any failure in between leaves the
# stage with NO light at all: the viewport goes black and the only thing still visible
# is the emissive laser stripe. The reason to switch it off at all is that two
# uncoordinated rigs make the exposure visibly swim as RTX re-converges.
from pxr import UsdGeom as _UG
lit = stage.GetPrimAtPath("/World/CellLighting")
dl = stage.GetPrimAtPath("/Environment/defaultLight")
if lit.IsValid() and any(True for _ in lit.GetChildren()) and dl.IsValid():
_UG.Imageable(dl).MakeInvisible()
out["default_light_off"] = True
else:
out["default_light_off"] = False # replacement missing - keep the only light on
return out