Currently, I have a POPX advection sim working well with a horizontal Slamtec RPLiDAR for interactivity. At the moment, it’s a one-to-one mapping that responds when a person is inside the bounds of the sim which is projected onto a ceiling/floor. I’m hoping to add a larger interactivity zone, wherein approaching the projection causes interactivity in the edge regions of the sim (example shown in the photo below).
The logic looks like: Horizontal RPLiDAR > Instanced Positional Data > Blob Track TOP > Info DAT > Touch Out & In > U & V values are driving a Transform POP that’s receiving a Point POP. Multi-User input is set up via a Merge POP that is bringing multiple Point POPs together before they enter the POPX ‘Flow operator’ that is at the core of the sim.
Imagine in the image below, that the fluidy sim area is a floor projection, and the black area around it is the bounds of a region where people are tracked via a horizontal S2P. How could I have someone walking in the black region bounded by the green polygon, and have it affect the portion of the projected advection sim bounded by the green rectangle?
While trying to get this sorted, my CHOP logic got quite… interesting. I still haven’t been able to find a good way to add these exterior interaction zones that scale down the movement input such that it only affects the edges of the sim, while also transitioning to an unscaled 1:1 movement reaction when a person is directly on top of a majority of the simulation.
I greatly appreciate any advice on this one!
I have since found out that handling everything with python is the easiest route:
Projection bounds inside incoming (interactive-region) normalized UV space
PROJ_U_MIN = 0.30
PROJ_U_MAX = 0.70
PROJ_V_MIN = 0.25
PROJ_V_MAX = 0.75
EDGE = 0.05 # outer 5% frame in fluid sim UV space
ENTER_MARGIN = 0.03 # must be this far inside projection bounds to “fully step on”
EXIT_MARGIN = 0.00 # optional hysteresis for leaving (increase if you want stickier “on projection”)
def remap_with_edge_padding(u, in_min, in_max, edge):
# Left padding → [0, edge]
if u < in_min:
return edge * (u / in_min)
# Right padding → [1-edge, 1]
if u > in_max:
return 1.0 - edge + edge * ((u - in_max) / (1.0 - in_max))
# Projection → [edge, 1-edge]
return edge + (u - in_min) * (1.0 - 2.0 * edge) / (in_max - in_min)
def _inside_projection(u, v, margin):
return (u >= (PROJ_U_MIN + margin) and u <= (PROJ_U_MAX - margin) and
v >= (PROJ_V_MIN + margin) and v <= (PROJ_V_MAX - margin))
def _outside_projection(u, v, margin):
return (u < (PROJ_U_MIN - margin) or u > (PROJ_U_MAX + margin) or
v < (PROJ_V_MIN - margin) or v > (PROJ_V_MAX + margin))
def _snap_to_nearest_edge(u_sim, v_sim, edge):
“”"
Ensures the point stays in the outer frame (<=edge or >=1-edge on at least one axis).
If both u and v are in the interior, snap the closer axis to its nearest edge line.
“”"
inner_min = edge
inner_max = 1.0 - edge
in_u_interior = (u_sim > inner_min and u_sim < inner_max)
in_v_interior = (v_sim > inner_min and v_sim < inner_max)
# Already in the frame -> keep it
if not (in_u_interior and in_v_interior):
return u_sim, v_sim
# Both interior: snap whichever axis is closer to an edge of the interior window
du = min(u_sim - inner_min, inner_max - u_sim)
dv = min(v_sim - inner_min, inner_max - v_sim)
if du <= dv:
u_sim = inner_min if u_sim < 0.5 else inner_max
else:
v_sim = inner_min if v_sim < 0.5 else inner_max
return u_sim, v_sim
def onBlobTrack(blobTrackTop, blobs):
table = op('PythonUV')
# Persist per-id "allowed to enter interior" state safely
state = me.fetch('onProjection', {})
new_state = {}
table.clear()
table.appendRow(['id', 'u', 'v'])
for blob in blobs:
blob_id = blob.id
u_in = blob.u
v_in = blob.v
on_proj = state.get(blob_id, False)
# Update state with hysteresis:
# - Only enter "on projection" when fully inside (ENTER_MARGIN)
# - Optionally leave when outside (EXIT_MARGIN)
if on_proj:
if _outside_projection(u_in, v_in, EXIT_MARGIN):
on_proj = False
else:
if _inside_projection(u_in, v_in, ENTER_MARGIN):
on_proj = True
# Base remap
u_sim = remap_with_edge_padding(u_in, PROJ_U_MIN, PROJ_U_MAX, EDGE)
v_sim = remap_with_edge_padding(v_in, PROJ_V_MIN, PROJ_V_MAX, EDGE)
# If not fully on the projection yet, keep the point in the 5% frame
if not on_proj:
u_sim, v_sim = _snap_to_nearest_edge(u_sim, v_sim, EDGE)
# Defensive clamp
if u_sim < 0.0: u_sim = 0.0
elif u_sim > 1.0: u_sim = 1.0
if v_sim < 0.0: v_sim = 0.0
elif v_sim > 1.0: v_sim = 1.0
table.appendRow([blob_id, u_sim, v_sim])
new_state[blob_id] = on_proj
# Drop state for blobs that are no longer present
me.store('onProjection', new_state)
return
def onBlobStateChange(blobTrackTop, blobs):
return
The Python above is for the Blob Track Callback DAT.
The callback is sending data to a Table DAT called PythonUV.
Then the Python below is setup in an Execute DAT that sends it off to a table called UVdata.
This is all working great in a test setup right now.
UV_SLOTS = 10 # fixed number of output rows (slots)
EMPTY_UV = -0.6 # off-sim default so empty slots don’t hit center
def _find_col_index(table, name):
for c in range(table.numCols):
if str(table[0, c]) == name:
return c
return None
def _read_pythonuv(source):
“”"
Returns dict: {blob_id: (u, v)} from PythonUV.
Assumes PythonUV has a header row containing: id, u, v (order can vary).
“”"
current = {}
if source is None or source.numRows < 2 or source.numCols < 3:
return current
id_c = _find_col_index(source, 'id')
u_c = _find_col_index(source, 'u')
v_c = _find_col_index(source, 'v')
if id_c is None or u_c is None or v_c is None:
return current
for r in range(1, source.numRows):
try:
blob_id = int(float(source[r, id_c]))
u = float(source[r, u_c])
v = float(source[r, v_c])
current[blob_id] = (u, v)
except:
pass
return current
def _ensure_uvdata_fixed(dest):
# Force UVdata to header + UV_SLOTS rows, 4 columns
needed_rows = UV_SLOTS + 1
needed_cols = 4 # slot, id, u, v
if dest.numRows != needed_rows or dest.numCols != needed_cols:
dest.setSize(needed_rows, needed_cols)
dest[0, 0] = 'slot'
dest[0, 1] = 'id'
dest[0, 2] = 'u'
dest[0, 3] = 'v'
for i in range(UV_SLOTS):
dest[i + 1, 0] = i
def _compact_slots(active_ids, order_list):
“”"
Remove inactive ids from the slot order while preserving relative order.
Add new active ids to the end.
Returns compacted list (no gaps).
“”"
compacted =
seen = set()
for bid in order_list:
if bid in active_ids and bid not in seen:
compacted.append(bid)
seen.add(bid)
for bid in active_ids:
if bid not in seen:
compacted.append(bid)
seen.add(bid)
return compacted
def onFrameStart(frame):
source = op('PythonUV')
dest = op('UVdata')
_ensure_uvdata_fixed(dest)
slotOrder = me.fetch('slotOrder', None)
if slotOrder is None:
slotOrder = []
me.store('slotOrder', slotOrder)
current = _read_pythonuv(source)
active_ids = set(current.keys())
# If nothing active, clear table + state
if not active_ids:
for i in range(UV_SLOTS):
dest[i + 1, 1] = ''
dest[i + 1, 2] = EMPTY_UV
dest[i + 1, 3] = EMPTY_UV
me.store('slotOrder', [])
return
# Stable ordering + pack down on departures
slotOrder = _compact_slots(active_ids, slotOrder)
# Capacity limit
if len(slotOrder) > UV_SLOTS:
slotOrder = slotOrder[:UV_SLOTS]
# Set ALL slots to safe off-sim defaults first
for i in range(UV_SLOTS):
dest[i + 1, 1] = ''
dest[i + 1, 2] = EMPTY_UV
dest[i + 1, 3] = EMPTY_UV
# Write out exact values, remapped to [-0.5, 0.5]
for slot in range(len(slotOrder)):
bid = slotOrder[slot]
u, v = current.get(bid, (0.0, 0.0))
# Clamp incoming just in case
if u < 0.0: u = 0.0
elif u > 1.0: u = 1.0
if v < 0.0: v = 0.0
elif v > 1.0: v = 1.0
# Remap [0,1] -> [-0.5, 0.5]
u = u - 0.5
v = v - 0.5
# Clamp output range just in case
if u < -0.5: u = -0.5
elif u > 0.5: u = 0.5
if v < -0.5: v = -0.5
elif v > 0.5: v = 0.5
dest[slot + 1, 1] = bid
dest[slot + 1, 2] = u
dest[slot + 1, 3] = v
me.store('slotOrder', slotOrder)
return
def onFrameEnd(frame):
return
def onExit():
return
def onCook(scriptOp):
return
