"""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)