Сортировочная ячейка 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
+766
View File
@@ -0,0 +1,766 @@
"""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
import pathlib
from pxr import Gf, PhysxSchema, Usd, UsdGeom, UsdLux, UsdPhysics, UsdShade
# absolute imports: control_test/cell.py is loaded as a top-level module, not as part of
# the robozon_sorter package it was copied out of. robozon_sorter must be importable -
# see control_test/README.md ("Dependencies").
from robozon_sorter import config as C
from robozon_sorter.sim import scene as _scene
from robozon_sorter.sim.plow_cell import GRIP_MATERIAL, configure_plow, drive_belt
SCENE = pathlib.Path(__file__).resolve().parent / "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 = 500.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
CAPTURE_PARKS = ("/World/CapItems", "/World/CapItems2", "/World/_CapItems")
def clear_capture_parks(stage, parks=CAPTURE_PARKS):
"""Снять коллизию с товаров, оставленных стендом захвата кадров в точке осмотра.
capture_roi.py ставит очередной товар в точку осмотра (-0.750, 0.0, 1.781), снимает
его шестью камерами и в конце прячет вызовом MakeInvisible(). НЕВИДИМОСТЬ НЕ УБИРАЕТ
КОЛЛАЙДЕР: после двух прогонов захвата в сцене осталось 18 невидимых, но твёрдых
предметов (/World/CapItems и /World/CapItems2 по девять), все в одной точке на ленте.
Симптом ровно тот, на который жалуются: товар идёт 1.00 м/с и встаёт "посреди
ConveyorTrack_02" - середина этой секции как раз x ~ -1.0, а стена стоит на -0.75.
Проба 60 мм в замере вставала на x = -0.667 и уползала вбок на y = -0.11, обтекая
невидимое препятствие.
Коллизия снимается, а не удаляются премы: кадры в captures/ ссылаются на эти пути,
и стенд замера должен продолжать работать.
"""
off = []
for root in parks:
r = stage.GetPrimAtPath(root)
if not r.IsValid():
continue
for d in Usd.PrimRange(r):
a = d.GetAttribute("physics:collisionEnabled")
if a and a.IsValid():
if a.Get() is not False:
a.Set(False); off.append(str(d.GetPath()))
elif d.HasAPI(UsdPhysics.CollisionAPI):
UsdPhysics.CollisionAPI(d).CreateCollisionEnabledAttr().Set(False)
off.append(str(d.GetPath()))
return off
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
WIDEN_PATH = "/World/_Widen"
LINE_CLEAR = 0.50 # required clear width between the guards, metres
def widen_line(stage, clear=LINE_CLEAR, speed=None):
"""Widen the straight runs to `clear` between guards, without touching the belts.
The conveyor asset's belt is 450 mm wide (rails ended up at y +-0.22), so a parcel
presented across an axis longer than that wedges between the guards and the whole
queue stops behind it - measured with catalogue-scale goods, where the first 455 mm
item jammed at x ~ -0.4 and the following eight piled up nose to tail.
Rather than rescale the conveyor (its surface velocity is authored in LOCAL space and
a non-uniform Y scale would skew the drive direction - the same trap that made the
corner belt drop items), this bolts a driven strip along each edge at exactly the
belt's top height, bound to the SAME grip material and carrying the SAME world-space
velocity, then moves the guards out to the new edge. Friction and drive are unchanged
because they are literally the same material and the same velocity vector.
"""
v_belt = C.BELT_SPEED if speed is None else speed
grip = UsdShade.Material(_ensure_grip_material(stage))
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(),
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
if not stage.GetPrimAtPath(WIDEN_PATH).IsValid():
UsdGeom.Xform.Define(stage, WIDEN_PATH)
xc = UsdGeom.XformCache()
made = []
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()
if (mx[0] - mn[0]) < (mx[1] - mn[1]):
continue # not an X-running straight segment
width = mx[1] - mn[1]
pad = (clear - width) / 2.0
if pad <= 0.001:
continue
# the belt's drive direction in WORLD terms, whatever frame it was authored in
api = PhysxSchema.PhysxSurfaceVelocityAPI(prim)
vloc = api.GetSurfaceVelocityAttr().Get() if prim.HasAPI(
PhysxSchema.PhysxSurfaceVelocityAPI) else None
local = bool(api.GetSurfaceVelocityLocalSpaceAttr().Get()) if vloc else False
if vloc is None:
vw = Gf.Vec3f(-v_belt, 0.0, 0.0)
elif local:
M = xc.GetLocalToWorldTransform(prim)
d = M.TransformDir(Gf.Vec3d(vloc[0], vloc[1], vloc[2]))
n = d.GetLength() or 1.0
vw = Gf.Vec3f(*[float(c) / n * v_belt for c in d])
else:
vw = Gf.Vec3f(*[float(c) for c in vloc])
safe = belt.replace("/", "_")
for side, y_edge, sgn in ((0, mn[1], -1.0), (1, mx[1], +1.0)):
path = f"{WIDEN_PATH}/{safe}_{side}"
if stage.GetPrimAtPath(path).IsValid():
made.append(path)
continue
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
p = cube.GetPrim()
xf = UsdGeom.Xformable(p)
xf.AddTranslateOp().Set(Gf.Vec3d((mn[0] + mx[0]) / 2.0,
y_edge + sgn * pad / 2.0,
mx[2] - 0.02))
xf.AddScaleOp().Set(Gf.Vec3f(mx[0] - mn[0], pad, 0.04))
UsdPhysics.CollisionAPI.Apply(p)
UsdShade.MaterialBindingAPI.Apply(p).Bind(
grip, UsdShade.Tokens.weakerThanDescendants, "physics")
sv = PhysxSchema.PhysxSurfaceVelocityAPI.Apply(p)
sv.CreateSurfaceVelocityEnabledAttr().Set(True)
sv.CreateSurfaceVelocityLocalSpaceAttr().Set(False)
sv.CreateSurfaceAngularVelocityAttr().Set(Gf.Vec3f(0.0, 0.0, 0.0))
sv.CreateSurfaceVelocityAttr().Set(vw)
UsdGeom.Imageable(p).MakeInvisible()
made.append(path)
# the guards were built off the old edge - rebuild them on the new one
rails = stage.GetPrimAtPath(RAIL_PATH)
if rails.IsValid():
stage.RemovePrim(RAIL_PATH)
UsdGeom.Xform.Define(stage, RAIL_PATH)
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()
if (mx[0] - mn[0]) < (mx[1] - mn[1]):
continue
cy = (mn[1] + mx[1]) / 2.0
safe = belt.replace("/", "_")
for side, sgn in ((0, -1.0), (1, +1.0)):
path = f"{RAIL_PATH}/{safe}_{side}"
cube = UsdGeom.Cube.Define(stage, path)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
xf.AddTranslateOp().Set(Gf.Vec3d((mn[0] + mx[0]) / 2.0,
cy + sgn * clear / 2.0,
mx[2] + RAIL_HEIGHT / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(mx[0] - mn[0] + 0.10, 0.02, RAIL_HEIGHT))
UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
UsdGeom.Imageable(cube.GetPrim()).MakeInvisible()
return dict(strips=len(made), clear_mm=round(clear * 1000))
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)
BRIDGE_PATH = "/World/_TransferBridge"
# The plow discharges class-B goods over ConveyorTrack_04's +Y edge (y = +0.45) while they
# are still at x -7.95..-7.32 (the blade's own span). ConveyorTrack_06 - the belt that
# takes them to container B - only starts at x = -8.00, and the authored transition plates
# sit UPSTREAM of the plow at x -7.39..-6.39 (they belong to the old layout). So between
# _04's edge and _06 there is simply no floor at the exact point the plow pushes goods
# across, and they drop through it. Measured: an item placed directly on _06 rides it and
# lands in container B at z=1.236, but the same item arriving via the plow ends up on the
# ground at z~0.00.
#
# This plate bridges that corner. Its top sits 3 mm BELOW the belt surface (1.778 vs
# 1.781) so it clears the plow arm, whose underside measured z=1.78 - a bridge flush with
# the belt would foul the blade.
BRIDGE_X0, BRIDGE_X1 = -8.06, -7.24
BRIDGE_Y0, BRIDGE_Y1 = 0.40, 1.08
BRIDGE_TOP_Z = 1.778
BRIDGE_THICK = 0.03
def add_transfer_bridge(stage, speed=None):
"""floor the _04 -> _06 corner and drive it toward container B"""
speed = speed if speed is not None else C.BELT_SPEED
prim = stage.GetPrimAtPath(BRIDGE_PATH)
if not prim.IsValid():
cube = UsdGeom.Cube.Define(stage, BRIDGE_PATH)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
xf.AddTranslateOp().Set(Gf.Vec3d((BRIDGE_X0 + BRIDGE_X1) / 2.0,
(BRIDGE_Y0 + BRIDGE_Y1) / 2.0,
BRIDGE_TOP_Z - BRIDGE_THICK / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(BRIDGE_X1 - BRIDGE_X0, BRIDGE_Y1 - BRIDGE_Y0, BRIDGE_THICK))
prim = cube.GetPrim()
UsdPhysics.CollisionAPI.Apply(prim)
UsdGeom.Imageable(prim).MakeInvisible()
# carry goods across it toward container B instead of letting them sit on a dead plate
drive_belt(stage, BRIDGE_PATH, (-0.846, 0.532, 0.0), speed)
grip = _ensure_grip_material(stage)
UsdShade.MaterialBindingAPI.Apply(prim).Bind(
UsdShade.Material(grip), bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return (BRIDGE_X0, BRIDGE_X1, BRIDGE_Y0, BRIDGE_Y1, BRIDGE_TOP_Z)
CATCHERS = {
# tray floor footprint -> its top z. Measured off the authored prims.
"/World/PlowContainers/B_Floor": None,
"/World/PlowContainers/C_Floor": None,
"/World/SortingRig/BinD_Floor": None,
}
CATCH_DEPTH = 0.30
def add_container_catchers(stage):
"""thicken the tray floors downward with an invisible slab.
The authored floors are 40 mm thick. Goods arrive off the belt (z 1.781) and land on a
tray floor at z~1.18 - a 0.6 m drop, so ~3.4 m/s, which at the scene's step is ~57 mm
of travel per step against a 40 mm slab: the item can pass straight through between
two steps. Measured exactly that - class-B goods reached container B's footprint
(x -8.58..-9.05, y 1.07..1.40, all inside the tray) and then ended up on the ground at
z~0.00. A single item dropped gently onto the same floor in isolation was caught, which
is the signature of tunnelling rather than a missing collider.
Deepening the collider (not the visible tray) means the item has several steps' worth
of solid to hit, so it cannot pass through. Purely additive: the slab sits BELOW each
existing floor, so nothing that already worked changes.
"""
bbc = UsdGeom.BBoxCache(Usd.TimeCode.Default(),
[UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
made = []
for path in CATCHERS:
src = stage.GetPrimAtPath(path)
if not src.IsValid():
continue
r = bbc.ComputeWorldBound(src).ComputeAlignedRange()
mn, mx = r.GetMin(), r.GetMax()
out = f"/World/_Catch{src.GetName()}"
if stage.GetPrimAtPath(out).IsValid():
made.append(out)
continue
cube = UsdGeom.Cube.Define(stage, out)
cube.CreateSizeAttr().Set(1.0)
xf = UsdGeom.Xformable(cube.GetPrim())
xf.AddTranslateOp().Set(Gf.Vec3d((mn[0] + mx[0]) / 2.0, (mn[1] + mx[1]) / 2.0,
mx[2] - CATCH_DEPTH / 2.0))
xf.AddScaleOp().Set(Gf.Vec3f(mx[0] - mn[0], mx[1] - mn[1], CATCH_DEPTH))
UsdPhysics.CollisionAPI.Apply(cube.GetPrim())
UsdGeom.Imageable(cube.GetPrim()).MakeInvisible()
made.append(out)
return made
CORNER_BELT = "/World/ConveyorTrack_06/Belt"
def drive_corner_belt(stage, path=CORNER_BELT, speed=None):
"""drive the 90-degree corner along the CHORD that stays on its arc.
ConveyorBelt_A03 is curved: sampling the top surface gives a quarter-annulus centred on
(-8.005, 1.042) with radii 0.517..1.018 - not the rectangle its bounding box implies.
The original linear direction (-0.545, +0.839) was too +Y-heavy, so goods cut across
the hollow middle of the annulus and fell through: a traced class-B item dropped at
(-8.49, +0.88), which is r=0.511 from the centre - just inside r_in=0.517.
PhysX's angular surface velocity would be the textbook answer, but it measured inert on
this body (goods crept at ~0.02 m/s in both local and world space), so the drive stays
linear and is instead AIMED so the straight chord never leaves the band. Goods enter at
(-8.05, 0.1), i.e. r=0.943; leaving at the same radius a quarter-turn round is
(-8.948, 1.042), giving direction (-0.69, 0.7238). That chord's midpoint sits at
r=0.683, comfortably inside 0.517..1.018 - the 0.5 m band is wide enough to
swallow the ~0.26 m a 90-degree chord deviates from its arc.
"""
speed = speed if speed is not None else C.BELT_SPEED
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 space, bypassing drive_belt's world->local conversion. That conversion
# normalises the direction *in local space*, which does not preserve the world
# direction when the frame carries a non-uniform scale - and this belt does. The
# symptom was unmistakable: asking for (-0.69, +0.72) drove goods to y = -0.098, i.e.
# the wrong way across the line and into container C.
api = PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim)
api.CreateSurfaceVelocityEnabledAttr().Set(True)
api.CreateSurfaceVelocityLocalSpaceAttr().Set(False)
api.CreateSurfaceAngularVelocityAttr().Set(Gf.Vec3f(0.0, 0.0, 0.0))
v = Gf.Vec3f(-0.69 * speed, 0.7238 * speed, 0.0)
api.CreateSurfaceVelocityAttr().Set(v)
grip = _ensure_grip_material(stage)
UsdShade.MaterialBindingAPI.Apply(prim).Bind(
UsdShade.Material(grip), bindingStrength=UsdShade.Tokens.strongerThanDescendants,
materialPurpose="physics")
return tuple(round(c, 3) for c in v)
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
# ConveyorTrack_06 is a CURVED corner and is driven rotationally instead - see
# drive_corner_belt(). Driving it linearly walked goods off the arc.
# 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)
parked = await _retrying(clear_capture_parks, 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)
widened = await _retrying(widen_line, stage, LINE_CLEAR, belt_speed)
bridge = await _retrying(add_transfer_bridge, stage, belt_speed)
catchers = await _retrying(add_container_catchers, stage)
corner = await _retrying(drive_corner_belt, stage, CORNER_BELT, belt_speed)
opened = await _retrying(open_junction, stage)
return dict(script_control=script_control, plow_ready=plow, belts=belts,
graphs_removed=killed, parks_cleared=len(parked), env=env, pusher_dims=pusher_dims, rails=len(rails),
widened=widened, pusher_seat=seat, decks_regripped=regripped, bridge=bridge, catchers=len(catchers), corner_dir=corner, 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)