@@ -147,6 +147,86 @@ def __repr__(self):
147147 return self .__str__ ()
148148
149149
150+ ###
151+ # Contact capacity helpers
152+ ###
153+
154+ # Conservative heuristics for the fallback allocation path when pair-based
155+ # capacity metadata is unavailable (``model_minimum_contacts == 0``).
156+ _EXPLICIT_CONTACTS_PER_PAIR = 10
157+ _DYNAMIC_CONTACTS_PER_COLLIDABLE = 20
158+
159+
160+ def _cap_world_contacts_at_total (world_max_contacts : list [int ], max_total : int ) -> list [int ]:
161+ """Scale per-world contact budgets down so their sum does not exceed ``max_total``."""
162+ total = sum (world_max_contacts )
163+ if total <= max_total :
164+ return list (world_max_contacts )
165+ if max_total <= 0 :
166+ return [0 ] * len (world_max_contacts )
167+
168+ capped = [0 ] * len (world_max_contacts )
169+ remainders : list [tuple [float , int ]] = []
170+ assigned = 0
171+ for i , count in enumerate (world_max_contacts ):
172+ scaled = count * max_total / total
173+ floor = int (scaled )
174+ capped [i ] = floor
175+ assigned += floor
176+ remainders .append ((scaled - floor , i ))
177+ for _ , i in sorted (remainders , key = lambda item : item [0 ], reverse = True ):
178+ if assigned >= max_total :
179+ break
180+ capped [i ] += 1
181+ assigned += 1
182+ return capped
183+
184+
185+ def _estimate_fallback_world_max_contacts (
186+ model : ModelKamino ,
187+ config : CollisionDetectorConfig ,
188+ ) -> list [int ]:
189+ """Estimate per-world contact capacity from geometry when pair metadata is unavailable."""
190+ num_worlds = model .size .num_worlds
191+ world_max_contacts = [0 ] * num_worlds
192+
193+ if config .broadphase == "explicit" and model .geoms .collidable_pairs is not None :
194+ pairs = model .geoms .collidable_pairs .numpy ()
195+ wid = model .geoms .wid .numpy ()
196+ for pair in pairs :
197+ g0 , g1 = int (pair [0 ]), int (pair [1 ])
198+ world_id = int (wid [g0 ]) if wid [g0 ] >= 0 else int (wid [g1 ])
199+ if 0 <= world_id < num_worlds :
200+ world_max_contacts [world_id ] += _EXPLICIT_CONTACTS_PER_PAIR
201+ else :
202+ wid = model .geoms .wid .numpy ()
203+ group = model .geoms .group .numpy ()
204+ for geom_id in range (len (wid )):
205+ world_id = int (wid [geom_id ])
206+ if 0 <= world_id < num_worlds and group [geom_id ] > 0 :
207+ world_max_contacts [world_id ] += _DYNAMIC_CONTACTS_PER_COLLIDABLE
208+
209+ return world_max_contacts
210+
211+
212+ def _resolve_contact_capacity (
213+ model : ModelKamino ,
214+ config : CollisionDetectorConfig ,
215+ ) -> tuple [int , list [int ]]:
216+ """Resolve model- and per-world contact budgets from geometry and config caps."""
217+ if model .geoms .model_minimum_contacts > 0 :
218+ world_max_contacts = list (model .geoms .world_minimum_contacts )
219+ else :
220+ world_max_contacts = _estimate_fallback_world_max_contacts (model , config )
221+
222+ model_max_contacts = sum (world_max_contacts )
223+ if model_max_contacts > config .max_contacts :
224+ world_max_contacts = _cap_world_contacts_at_total (world_max_contacts , config .max_contacts )
225+ model_max_contacts = sum (world_max_contacts )
226+
227+ return model_max_contacts , world_max_contacts
228+
229+
150230###
151231# Interfaces
152232###
@@ -303,41 +383,22 @@ def finalize(
303383 # Configure the collision detection pipeline type based on the config
304384 self ._pipeline_type = CollisionPipelineType .from_string (self ._config .pipeline )
305385
306- # TODO: FIX THIS SO THAT PER-WORLD MAX IS ACTUALLY BASED ON THE NUM OF COLLIDABLE
307- # GOEMS IN EACH WORLD, INSTEAD OF JUST DIVIDING THE MODEL MAX BY THE NUM WORLDS
308- # For collision pipeline, we don't multiply by per-pair factors since broad phase
309- # discovers pairs dynamically. Users can provide rigid_contact_max explicitly,
310- # otherwise it is estimated from shape count and broad phase mode.
311- if self ._model .geoms .model_minimum_contacts > 0 :
312- self ._model_max_contacts = self ._model .geoms .model_minimum_contacts
313- self ._world_max_contacts = self ._model .geoms .world_minimum_contacts
314- else :
315- # Estimate based on broad phase mode and available information
316- if self ._config .broadphase == "explicit" and self ._model .geoms .collidable_pairs is not None :
317- # For EXPLICIT mode, we know the maximum possible pairs
318- # Estimate ~10 contacts per shape pair (conservative for mesh-mesh contacts)
319- self ._model_max_contacts = max (self ._config .max_contacts , self ._model .geoms .num_collidable_pairs * 10 )
320- else :
321- # For NXN/SAP dynamic broad phase, estimate based on shape count
322- # Assume each shape contacts ~20 others on average (conservative estimate)
323- # This scales much better than O(N²) while still being safe
324- self ._model_max_contacts = max (self ._config .max_contacts , self ._model .geoms .num_collidable * 20 )
325-
326- # Set the world max contacts to be the same for all worlds in the model
327- num_worlds = self ._model .size .num_worlds
328- self ._world_max_contacts = [self ._model_max_contacts // num_worlds ] * num_worlds
329-
330- # Override per-world max contacts if config specifies it.
386+ # Resolve contact capacity.
331387 if self ._config .max_contacts_per_world is not None :
388+ # Use the explicit per-world override when available.
332389 num_worlds = self ._model .size .num_worlds
333390 per_world = self ._config .max_contacts_per_world
334391 self ._world_max_contacts = [per_world ] * num_worlds
335392 self ._model_max_contacts = per_world * num_worlds
393+ else :
394+ # Otherwise estimate per world from geometry.
395+ # ``max_contacts`` caps the model total.
396+ self ._model_max_contacts , self ._world_max_contacts = _resolve_contact_capacity (self ._model , self ._config )
336397
337398 # Create the contacts interface which will allocate all contacts data arrays
338399 # NOTE: If internal allocations happen, then they will contain
339400 # the contacts generated by the collision detection pipelines
340- self ._contacts = ContactsKamino (capacity = self ._world_max_contacts , device = self ._device )
401+ self ._contacts = ContactsKamino (capacity = list ( self ._world_max_contacts ) , device = self ._device )
341402
342403 # Proceed with allocations only if the model admits contacts, which
343404 # occurs when collision geometries defined in the builder and model
0 commit comments