From 961f1ace1560d402c5a4c51d7beeb513e8ae90d6 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 12:15:10 -0400 Subject: [PATCH 1/2] Move agents straight when the map has no route for them An agent whose move the walking mesh can't route (off the map, onto a box top, or with no map data) held still for the whole page switch and then jumped. It now slides straight from its old spot to its new one, as every other item does. --- lib/page_transition/agent_path.dart | 44 ++------ .../strategy_page_session_provider.dart | 8 +- lib/services/video_export/video_exporter.dart | 1 - test/agent_transition_path_test.dart | 19 ++-- test/navigation_geometry_provider_test.dart | 106 ++++++++++++------ 5 files changed, 91 insertions(+), 87 deletions(-) diff --git a/lib/page_transition/agent_path.dart b/lib/page_transition/agent_path.dart index 6bd9574e9..931405876 100644 --- a/lib/page_transition/agent_path.dart +++ b/lib/page_transition/agent_path.dart @@ -14,28 +14,20 @@ import 'package:icarus/view_cone/vision_geometry.dart'; class AgentTransitionPath { AgentTransitionPath(List points) : points = List.unmodifiable(points), - _cumulativeDistances = _buildCumulativeDistances(points), - _unreachableDestination = null; + _cumulativeDistances = _buildCumulativeDistances(points); - /// Keep an unreachable move at its source until the next page takes over. - /// Inventing a straight route would visibly move the agent through a wall. - AgentTransitionPath.unreachable(Offset start, Offset end) - : points = List.unmodifiable([start]), - _cumulativeDistances = const [0], - _unreachableDestination = end; + /// A move the map has no route for (off the map, onto a box, no map data) + /// goes straight, as every other item does. It may cross a wall; an agent + /// that holds still and then jumps reads as broken. + AgentTransitionPath.straight(Offset start, Offset end) : this([start, end]); final List points; final List _cumulativeDistances; - final Offset? _unreachableDestination; - bool get isReachable => _unreachableDestination == null; double get length => _cumulativeDistances.isEmpty ? 0 : _cumulativeDistances.last; Offset positionAt(double progress) { - if (_unreachableDestination != null) { - return progress >= 1 ? _unreachableDestination : points.first; - } if (points.isEmpty) return Offset.zero; if (points.length == 1 || length <= _epsilon) return points.last; @@ -75,7 +67,6 @@ class AgentTransitionPathPlanner { required List entries, required VisionGeometryMap? geometry, NavigationGeometryMap? navigation, - bool requireNavigation = false, required double startAgentSize, required double endAgentSize, required CoordinateSystem coordinateSystem, @@ -83,20 +74,7 @@ class AgentTransitionPathPlanner { final native = navigation?.geometry ?? geometry?.navigationGeometry; Offset centerFor(Offset position) => position + coordinateSystem.virtualOffsetToWorld(storedAgentAnchor); - if (requireNavigation && native == null) { - // Missing map data cannot establish a safe route. Page navigation still - // finishes, but agents wait for the destination page instead of crossing - // walls along the overlay's default straight-line interpolation. - return { - for (final entry in entries) - if (entry.kind == TransitionKind.move && - entry.visualWidget is PlacedAgentNode) - entry.id: AgentTransitionPath.unreachable( - centerFor(entry.startPos), - centerFor(entry.endPos), - ), - }; - } + // Without map data the overlay moves agents straight. if (geometry == null && native == null) return const {}; final observerHeight = navigation?.observerHeightCm ?? geometry!.observerHeight; @@ -144,7 +122,7 @@ class AgentTransitionPathPlanner { ); return route.isReachable ? AgentTransitionPath(route.points) - : AgentTransitionPath.unreachable(startCenter, endCenter); + : AgentTransitionPath.straight(startCenter, endCenter); } final routeElevation = startElevation == null ? endElevation @@ -165,7 +143,7 @@ class AgentTransitionPathPlanner { } /// Legacy SVG movement fallback for maps without a baked player navmesh. -/// Failed searches never manufacture a route through known collision. +/// A failed search goes straight (see [AgentTransitionPath.straight]). class AgentTransitionPathfinder { const AgentTransitionPathfinder({ this.gridSpacing = 18, @@ -191,14 +169,14 @@ class AgentTransitionPathfinder { final bounds = layer.boundary?.outerGroup.bounds; if (bounds == null || !layer.contains(start) || !layer.contains(end)) { - return AgentTransitionPath.unreachable(start, end); + return AgentTransitionPath.straight(start, end); } final origin = bounds.topLeft; final startNode = _nearestConnectableNode(start, origin, layer); final endNode = _nearestConnectableNode(end, origin, layer); if (startNode == null || endNode == null) { - return AgentTransitionPath.unreachable(start, end); + return AgentTransitionPath.straight(start, end); } final open = <_GridNode>{startNode}; @@ -249,7 +227,7 @@ class AgentTransitionPathfinder { } } - return AgentTransitionPath.unreachable(start, end); + return AgentTransitionPath.straight(start, end); } _GridNode? _nearestConnectableNode( diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart index eae5c72a7..066523a35 100644 --- a/lib/providers/strategy_page_session_provider.dart +++ b/lib/providers/strategy_page_session_provider.dart @@ -504,10 +504,10 @@ class StrategyPageSessionNotifier extends Notifier { VisionGeometryMap? transitionGeometry; NavigationGeometryMap? transitionNavigation; final map = ref.read(mapProvider).currentMap; - final requireNavigation = ref.read(worldGeometryEnabledProvider(map)); + final usesNavigationMesh = ref.read(worldGeometryEnabledProvider(map)); if (needsAgentRouting) { try { - if (requireNavigation) { + if (usesNavigationMesh) { transitionNavigation = await ref.read(navigationGeometryProvider(map).future); } else { @@ -515,8 +515,7 @@ class StrategyPageSessionNotifier extends Notifier { await ref.read(viewConeGeometryProvider(map).future); } } on Object { - // The provider reports the failure. Required native routes hold at - // their sources, so changing pages cannot invent movement through walls. + // The provider reports the failure. Agents then move straight. } } @@ -541,7 +540,6 @@ class StrategyPageSessionNotifier extends Notifier { entries: entries, geometry: transitionGeometry, navigation: transitionNavigation, - requireNavigation: requireNavigation, startAgentSize: startSettings.agentSize, endAgentSize: endSettings.agentSize, coordinateSystem: CoordinateSystem.instance, diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index ef05c839a..a89d4c236 100644 --- a/lib/services/video_export/video_exporter.dart +++ b/lib/services/video_export/video_exporter.dart @@ -221,7 +221,6 @@ class VideoExporter { entries: entries, geometry: geometry, navigation: navigation, - requireNavigation: requireNavigation, startAgentSize: page.settings.agentSize, endAgentSize: nextPage.settings.agentSize, coordinateSystem: CoordinateSystem.instance, diff --git a/test/agent_transition_path_test.dart b/test/agent_transition_path_test.dart index d03162c01..5a95729da 100644 --- a/test/agent_transition_path_test.dart +++ b/test/agent_transition_path_test.dart @@ -22,14 +22,11 @@ void main() { expect(path.positionAt(2), const Offset(8, 9)); }); - test('unreachable moves stay at their source until the destination page', - () { - final path = AgentTransitionPath.unreachable( - const Offset(2, 3), const Offset(8, 9)); - expect(path.isReachable, isFalse); - expect(path.length, 0); - expect(path.positionAt(.5), const Offset(2, 3)); - expect(path.positionAt(.999), const Offset(2, 3)); + test('a move with no route goes straight, never holding then jumping', () { + final path = + AgentTransitionPath.straight(const Offset(2, 3), const Offset(8, 9)); + expect(path.positionAt(0), const Offset(2, 3)); + expect(path.positionAt(.5), const Offset(5, 6)); expect(path.positionAt(1), const Offset(8, 9)); }); }); @@ -84,10 +81,10 @@ void main() { const boundedSearch = AgentTransitionPathfinder( gridSpacing: 10, clearance: 2, maxExpandedNodes: 0); - final unreachable = boundedSearch.findPath( + final unrouted = boundedSearch.findPath( start: const Offset(20, 30), end: const Offset(100, 30), layer: layer); - expect(unreachable.isReachable, isFalse); - expect(unreachable.positionAt(.5), const Offset(20, 30)); + expect(unrouted.points, const [Offset(20, 30), Offset(100, 30)]); + expect(unrouted.positionAt(.5), const Offset(60, 30)); }); test('smoothed paths preserve clearance from wall corners', () { diff --git a/test/navigation_geometry_provider_test.dart b/test/navigation_geometry_provider_test.dart index e3b79ae36..7e216d653 100644 --- a/test/navigation_geometry_provider_test.dart +++ b/test/navigation_geometry_provider_test.dart @@ -171,12 +171,10 @@ void main() { entries: entries, geometry: null, navigation: navigation, - requireNavigation: true, startAgentSize: 0, endAgentSize: 0, coordinateSystem: coordinates)['sova']!; final defense = route(); - expect(defense.isReachable, isTrue, reason: 'defense offset $offset'); expect(defense.points.first, start); expect(defense.points.last, end); expect(defense.points.any((p) => p.dy >= 20), isTrue); @@ -242,12 +240,12 @@ void main() { ], geometry: null, navigation: navigation, - requireNavigation: true, startAgentSize: 0, endAgentSize: 0, coordinateSystem: coordinates, ); - expect(routes['sova']!.isReachable, isTrue); + expect(navigation.geometry.findRoute(start: start, end: end).isReachable, + isTrue); expect(routes['sova']!.points.first, start); expect(routes['sova']!.points.last, end); }); @@ -284,37 +282,71 @@ void main() { throwsA(isA().having((error) => error.message, 'message', 'Invalid world gzip checksum or length.'))); }); - for (final requireNavigation in [false, true]) { - test( - 'missing navigation ${requireNavigation ? 'holds native movement' : 'preserves the legacy fallback'}', - () { - final coordinates = - CoordinateSystem(playAreaSize: const Size(1920, 1080)); - const start = Offset(200, 300), end = Offset(500, 600); - final routes = AgentTransitionPathPlanner.plan( - entries: [ - PageTransitionEntry.move( - from: - PlacedAgent(id: 'sova', type: AgentType.sova, position: start), - to: PlacedAgent(id: 'sova', type: AgentType.sova, position: end), - ) - ], - geometry: null, - requireNavigation: requireNavigation, - startAgentSize: 20, - endAgentSize: 40, - coordinateSystem: coordinates, - ); - if (!requireNavigation) { - expect(routes, isEmpty); - } else { - final path = routes['sova']!; - expect(path.isReachable, isFalse); - expect(path.positionAt(.999), - start + coordinates.virtualOffsetToWorld(storedAgentAnchor)); - expect(path.positionAt(1), - end + coordinates.virtualOffsetToWorld(storedAgentAnchor)); - } - }); - } + test('an agent the map has no route for moves straight (an Ascent box top)', + () async { + final navigation = await loadNavigationGeometry(MapValue.ascent); + final mesh = navigation.geometry; + double? floorAt(Offset point) => mesh.floorHeightAt(point, + preferredElevation: navigation.defaultFloorElevationCm); + Offset centerOf(NavigationTriangle triangle) => + triangle.vertices + .map((vertex) => mesh.vertices[vertex].position) + .reduce((a, b) => a + b) / + 3; + final sizes = {}; + for (final component in mesh.components) { + sizes[component] = (sizes[component] ?? 0) + 1; + } + final mainFloor = + sizes.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + final onFloor = centerOf(mesh.triangles.firstWhere( + (triangle) => mesh.components[triangle.polygon] == mainFloor)); + // A patch the walking mesh doesn't join to the floor around it. + final apart = mesh.triangles.map(centerOf).firstWhere((point) => + mesh + .findRoute( + start: point, + end: onFloor, + startFloorHeight: floorAt(point), + endFloorHeight: floorAt(onFloor)) + .failure == + 'disconnected-floors'); + final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080)); + final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); + final path = AgentTransitionPathPlanner.plan( + entries: [ + PageTransitionEntry.move( + from: PlacedAgent( + id: 'sova', type: AgentType.sova, position: apart - anchor), + to: PlacedAgent( + id: 'sova', type: AgentType.sova, position: onFloor - anchor), + ) + ], + geometry: null, + navigation: navigation, + startAgentSize: 0, + endAgentSize: 0, + coordinateSystem: coordinates, + )['sova']!; + expect(path.positionAt(0), apart); + expect(path.positionAt(.5), Offset.lerp(apart, onFloor, .5)); + expect(path.positionAt(1), onFloor); + }); + test('missing navigation plans no route, so the overlay moves straight', () { + final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080)); + const start = Offset(200, 300), end = Offset(500, 600); + final routes = AgentTransitionPathPlanner.plan( + entries: [ + PageTransitionEntry.move( + from: PlacedAgent(id: 'sova', type: AgentType.sova, position: start), + to: PlacedAgent(id: 'sova', type: AgentType.sova, position: end), + ) + ], + geometry: null, + startAgentSize: 20, + endAgentSize: 40, + coordinateSystem: coordinates, + ); + expect(routes, isEmpty); + }); } From d0c4c61c9e7543de157b27169fb91aed33084512 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 28 Oct 2026 12:34:22 -0400 Subject: [PATCH 2/2] Plan straight center paths when map data is missing An empty plan made the overlay slide between composite top-lefts, so an agent changing form between pages (plain to cone or circle) started off its spot. A center path draws it where it stands. --- lib/page_transition/agent_path.dart | 16 ++++++++++++++-- test/navigation_geometry_provider_test.dart | 8 ++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/lib/page_transition/agent_path.dart b/lib/page_transition/agent_path.dart index 931405876..07f1a3e21 100644 --- a/lib/page_transition/agent_path.dart +++ b/lib/page_transition/agent_path.dart @@ -74,8 +74,20 @@ class AgentTransitionPathPlanner { final native = navigation?.geometry ?? geometry?.navigationGeometry; Offset centerFor(Offset position) => position + coordinateSystem.virtualOffsetToWorld(storedAgentAnchor); - // Without map data the overlay moves agents straight. - if (geometry == null && native == null) return const {}; + // Without map data agents go straight. A path, not an empty plan: the + // overlay draws a path from the agent's center, so an agent that changes + // form between pages (a cone or circle composite) stays where it stands. + if (geometry == null && native == null) { + return { + for (final entry in entries) + if (entry.kind == TransitionKind.move && + entry.visualWidget is PlacedAgentNode) + entry.id: AgentTransitionPath.straight( + centerFor(entry.startPos), + centerFor(entry.endPos), + ), + }; + } final observerHeight = navigation?.observerHeightCm ?? geometry!.observerHeight; final defaultFloor = navigation?.defaultFloorElevationCm ?? diff --git a/test/navigation_geometry_provider_test.dart b/test/navigation_geometry_provider_test.dart index 7e216d653..964f51c41 100644 --- a/test/navigation_geometry_provider_test.dart +++ b/test/navigation_geometry_provider_test.dart @@ -332,7 +332,7 @@ void main() { expect(path.positionAt(.5), Offset.lerp(apart, onFloor, .5)); expect(path.positionAt(1), onFloor); }); - test('missing navigation plans no route, so the overlay moves straight', () { + test('missing navigation moves agents straight between their centers', () { final coordinates = CoordinateSystem(playAreaSize: const Size(1920, 1080)); const start = Offset(200, 300), end = Offset(500, 600); final routes = AgentTransitionPathPlanner.plan( @@ -347,6 +347,10 @@ void main() { endAgentSize: 40, coordinateSystem: coordinates, ); - expect(routes, isEmpty); + final anchor = coordinates.virtualOffsetToWorld(storedAgentAnchor); + final path = routes['sova']!; + expect(path.positionAt(0), start + anchor); + expect(path.positionAt(.5), (start + end) / 2 + anchor); + expect(path.positionAt(1), end + anchor); }); }