0d32f32db0
Замкнутый контур "поток -> 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>
767 lines
39 KiB
Python
767 lines
39 KiB
Python
"""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)
|