| |
| """ |
| nima_affordance_graph.py β The 3D Affordance Graph |
| |
| This is the "graphing layer" β a graph data structure representing the |
| room's walkable topology + surface affordances. It drives: |
| - Nima's movement AI (A* pathfinding through the room) |
| - The VFX renderer (where to place + animate the avatar) |
| - Wall enforcement (she literally cannot pass through walls) |
| - Door passages (edges through doorways) |
| - Furniture interaction (sit on couch, lie on bed, jump on cushions) |
| |
| NEUROBIOLOGICAL MAPPING: |
| This is the hippocampal cognitive map + parietal affordance competition: |
| - Place cells (O'Keefe, 2014 Nobel) β graph nodes |
| - Grid cells (Moser & Moser, 2014 Nobel) β spatial layout |
| - Parietal affordance encoding β node action labels |
| - Prefrontal path planning β A* search through the graph |
| |
| The brain doesn't represent space as pixels β it represents it as a |
| graph of places with action possibilities. That's exactly what this is. |
| |
| GRAPH STRUCTURE: |
| Nodes = positions in the room (x, y, z) with affordance labels |
| Edges = valid movements between positions |
| Walls = no edge (impassable) |
| Doors = edges through wall boundaries |
| Furniture = nodes with special affordances (sit, lie, jump) |
| |
| Nima pathfinds via A* on this graph. When she wants to "sit on the |
| couch," she finds: walk to couch β transition to sitting β z rises |
| to couch height β posture animates to seated. |
| """ |
| from __future__ import annotations |
|
|
| import heapq |
| import json |
| import logging |
| import math |
| import time |
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Any, Dict, List, Optional, Set, Tuple |
|
|
| logger = logging.getLogger("NimaGraph") |
|
|
|
|
| class AffordanceType(Enum): |
| """What actions Nima can perform at a location.""" |
| WALK = "walk" |
| STAND = "stand" |
| SIT = "sit" |
| LIE_DOWN = "lie_down" |
| JUMP_ON = "jump_on" |
| REST_HAND = "rest_hand" |
| LEAN_ON = "lean_on" |
| DUCK = "duck" |
| AVOID = "avoid" |
|
|
|
|
| @dataclass |
| class GraphNode: |
| """A node in the affordance graph β a position Nima can be at.""" |
| node_id: str |
| position: Tuple[float, float, float] |
| surface_type: str = "floor" |
| height: float = 0.0 |
| material: str = "hard" |
| affordances: Set[AffordanceType] = field(default_factory=set) |
| is_walkable: bool = True |
| is_doorway: bool = False |
| metadata: Dict[str, Any] = field(default_factory=dict) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "node_id": self.node_id, |
| "position": list(self.position), |
| "surface_type": self.surface_type, |
| "height": self.height, |
| "material": self.material, |
| "affordances": [a.value for a in self.affordances], |
| "is_walkable": self.is_walkable, |
| "is_doorway": self.is_doorway, |
| } |
|
|
|
|
| @dataclass |
| class GraphEdge: |
| """An edge between two nodes β a valid movement.""" |
| from_node: str |
| to_node: str |
| distance: float |
| is_vertical: bool = False |
| transition_type: str = "walk" |
|
|
|
|
| class AffordanceGraph: |
| """ |
| The 3D spatial graph β Nima's cognitive map of the room. |
| |
| This is where Nima "thinks" about space. She doesn't see pixels; |
| she sees a graph of places she can be, with actions she can take |
| at each place. The graph is built from the SyntheticVisionComposite's |
| spatial map, and is updated as Nima moves through the room. |
| """ |
|
|
| def __init__(self, grid_resolution: float = 0.5) -> None: |
| """ |
| Args: |
| grid_resolution: meters between grid nodes (default 0.5m) |
| """ |
| self.resolution = grid_resolution |
| self.nodes: Dict[str, GraphNode] = {} |
| self.edges: Dict[str, List[GraphEdge]] = {} |
| self._room_bounds: Dict[str, float] = {} |
| self._furniture: List[Dict[str, Any]] = [] |
|
|
| def build_from_spatial_map(self, spatial_map: Any) -> None: |
| """ |
| Build the graph from a SyntheticVisionComposite spatial map. |
| |
| The spatial map provides: |
| - room_bounds: the room's physical limits |
| - surfaces: 3D point cloud of walls/floor/furniture |
| - affordances: what actions are possible where |
| """ |
| |
| bounds = getattr(spatial_map, "room_bounds", {}) |
| if not bounds: |
| bounds = {"x_min": 0, "x_max": 4, "y_min": 0, "y_max": 4, "z_min": 0, "z_max": 2.5} |
| self._room_bounds = bounds |
|
|
| |
| self.nodes.clear() |
| self.edges.clear() |
|
|
| |
| x_min, x_max = bounds.get("x_min", 0), bounds.get("x_max", 4) |
| y_min, y_max = bounds.get("y_min", 0), bounds.get("y_max", 4) |
| res = self.resolution |
|
|
| for x in np.arange(x_min, x_max + res, res): |
| for y in np.arange(y_min, y_max + res, res): |
| node_id = self._node_id(x, y, 0.0) |
| node = GraphNode( |
| node_id=node_id, |
| position=(round(float(x), 3), round(float(y), 3), 0.0), |
| surface_type="floor", |
| height=0.0, |
| material="hard", |
| affordances={AffordanceType.WALK, AffordanceType.STAND, AffordanceType.LIE_DOWN}, |
| ) |
| self.nodes[node_id] = node |
|
|
| |
| for x in np.arange(x_min, x_max + res, res): |
| for y, wall_y in [(y_min, y_min), (y_max, y_max)]: |
| node_id = self._node_id(x, wall_y, 0.0) |
| if node_id in self.nodes: |
| self.nodes[node_id].surface_type = "wall" |
| self.nodes[node_id].is_walkable = False |
| self.nodes[node_id].affordances = {AffordanceType.AVOID} |
| for y in np.arange(y_min, y_max + res, res): |
| for x, wall_x in [(x_min, x_min), (x_max, x_max)]: |
| node_id = self._node_id(wall_x, y, 0.0) |
| if node_id in self.nodes: |
| self.nodes[node_id].surface_type = "wall" |
| self.nodes[node_id].is_walkable = False |
| self.nodes[node_id].affordances = {AffordanceType.AVOID} |
|
|
| |
| surfaces = getattr(spatial_map, "surfaces", []) |
| furniture_added = 0 |
| for surface in surfaces: |
| if surface.surface_type == "furniture": |
| self._add_furniture_node(surface) |
| furniture_added += 1 |
|
|
| |
| self._add_doorways() |
|
|
| |
| self._build_edges() |
|
|
| logger.info("[Graph] built: %d nodes, %d edges, %d furniture nodes", |
| len(self.nodes), sum(len(e) for e in self.edges.values()), |
| furniture_added) |
|
|
| def _node_id(self, x: float, y: float, z: float) -> str: |
| """Generate a node ID from coordinates.""" |
| return f"n_{round(x, 2)}_{round(y, 2)}_{round(z, 2)}" |
|
|
| def _add_furniture_node(self, surface: Any) -> None: |
| """Add a furniture surface as a graph node with affordances.""" |
| pos = surface.position |
| node_id = self._node_id(pos[0], pos[1], pos[2]) |
| affordances: Set[AffordanceType] = set() |
| height = surface.height |
|
|
| if height < 0.3: |
| |
| affordances = {AffordanceType.WALK, AffordanceType.STAND} |
| elif height < 0.6: |
| if surface.material == "soft": |
| |
| affordances = {AffordanceType.SIT, AffordanceType.LIE_DOWN, AffordanceType.JUMP_ON} |
| else: |
| |
| affordances = {AffordanceType.SIT, AffordanceType.REST_HAND} |
| elif height < 1.0: |
| |
| affordances = {AffordanceType.REST_HAND, AffordanceType.LEAN_ON} |
| else: |
| |
| affordances = {AffordanceType.AVOID} |
|
|
| node = GraphNode( |
| node_id=node_id, |
| position=(round(pos[0], 3), round(pos[1], 3), round(pos[2], 3)), |
| surface_type="furniture", |
| height=height, |
| material=surface.material, |
| affordances=affordances, |
| is_walkable=height < 0.3, |
| ) |
| self.nodes[node_id] = node |
|
|
| def _add_doorways(self) -> None: |
| """Mark doorway nodes as walkable passages through walls.""" |
| |
| |
| |
| bounds = self._room_bounds |
| door_x = (bounds.get("x_min", 0) + bounds.get("x_max", 4)) / 2 |
| door_y = bounds.get("y_max", 4) |
| door_node_id = self._node_id(door_x, door_y, 0.0) |
| if door_node_id in self.nodes: |
| self.nodes[door_node_id].is_walkable = True |
| self.nodes[door_node_id].is_doorway = True |
| self.nodes[door_node_id].surface_type = "door" |
| self.nodes[door_node_id].affordances = {AffordanceType.WALK, AffordanceType.STAND} |
| logger.debug("[Graph] doorway at %s", door_node_id) |
|
|
| def _build_edges(self) -> None: |
| """Build edges between adjacent walkable nodes.""" |
| res = self.resolution |
| for node_id, node in self.nodes.items(): |
| if not node.is_walkable: |
| continue |
| x, y, z = node.position |
| |
| for dx, dy in [(res, 0), (-res, 0), (0, res), (0, -res)]: |
| neighbor_id = self._node_id(x + dx, y + dy, z) |
| if neighbor_id in self.nodes: |
| neighbor = self.nodes[neighbor_id] |
| if neighbor.is_walkable: |
| edge = GraphEdge( |
| from_node=node_id, |
| to_node=neighbor_id, |
| distance=res, |
| transition_type="walk", |
| ) |
| self.edges.setdefault(node_id, []).append(edge) |
|
|
| |
| for other_id, other in self.nodes.items(): |
| if other_id == node_id: |
| continue |
| if other.surface_type != "furniture": |
| continue |
| ox, oy, oz = other.position |
| |
| horiz_dist = math.sqrt((x - ox)**2 + (y - oy)**2) |
| if horiz_dist < res * 1.5 and oz > z: |
| if AffordanceType.SIT in other.affordances: |
| self.edges.setdefault(node_id, []).append(GraphEdge( |
| from_node=node_id, |
| to_node=other_id, |
| distance=math.sqrt(horiz_dist**2 + (oz - z)**2), |
| is_vertical=True, |
| transition_type="sit_down", |
| )) |
| elif AffordanceType.JUMP_ON in other.affordances: |
| self.edges.setdefault(node_id, []).append(GraphEdge( |
| from_node=node_id, |
| to_node=other_id, |
| distance=math.sqrt(horiz_dist**2 + (oz - z)**2), |
| is_vertical=True, |
| transition_type="jump", |
| )) |
|
|
| def find_path(self, |
| start: Tuple[float, float, float], |
| goal: Tuple[float, float, float], |
| ) -> List[GraphNode]: |
| """ |
| A* pathfinding from start to goal. |
| |
| Returns a list of GraphNodes representing the path, or empty |
| list if no path exists. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| This is prefrontal path planning β the brain's ability to |
| plan a route through space before executing it. Place cells |
| in the hippocampus fire in sequence during planning, |
| "rehearsing" the route. A* does the same computation. |
| """ |
| start_id = self._nearest_node(start) |
| goal_id = self._nearest_node(goal) |
| if start_id is None or goal_id is None: |
| return [] |
|
|
| |
| open_set: List[Tuple[float, str]] = [(0, start_id)] |
| came_from: Dict[str, str] = {} |
| g_score: Dict[str, float] = {start_id: 0} |
| f_score: Dict[str, float] = {start_id: self._heuristic(start_id, goal_id)} |
|
|
| while open_set: |
| _, current_id = heapq.heappop(open_set) |
| if current_id == goal_id: |
| |
| path = [] |
| cid = current_id |
| while cid in came_from: |
| path.append(self.nodes[cid]) |
| cid = came_from[cid] |
| path.append(self.nodes[start_id]) |
| path.reverse() |
| return path |
|
|
| for edge in self.edges.get(current_id, []): |
| neighbor_id = edge.to_node |
| tentative_g = g_score[current_id] + edge.distance |
| if neighbor_id not in g_score or tentative_g < g_score[neighbor_id]: |
| came_from[neighbor_id] = current_id |
| g_score[neighbor_id] = tentative_g |
| f_score[neighbor_id] = tentative_g + self._heuristic(neighbor_id, goal_id) |
| heapq.heappush(open_set, (f_score[neighbor_id], neighbor_id)) |
|
|
| return [] |
|
|
| def _nearest_node(self, pos: Tuple[float, float, float]) -> Optional[str]: |
| """Find the nearest walkable node to a position.""" |
| best_id = None |
| best_dist = float("inf") |
| for node_id, node in self.nodes.items(): |
| if not node.is_walkable: |
| continue |
| dist = math.sqrt( |
| (node.position[0] - pos[0])**2 + |
| (node.position[1] - pos[1])**2 + |
| (node.position[2] - pos[2])**2 |
| ) |
| if dist < best_dist: |
| best_dist = dist |
| best_id = node_id |
| return best_id |
|
|
| def _heuristic(self, a_id: str, b_id: str) -> float: |
| """Euclidean distance heuristic for A*.""" |
| a = self.nodes[a_id].position |
| b = self.nodes[b_id].position |
| return math.sqrt( |
| (a[0] - b[0])**2 + (a[1] - b[1])**2 + (a[2] - b[2])**2 |
| ) |
|
|
| def find_affordance(self, |
| affordance: AffordanceType, |
| from_pos: Tuple[float, float, float], |
| ) -> Optional[GraphNode]: |
| """ |
| Find the nearest node with a specific affordance. |
| E.g., "find the nearest place I can sit." |
| """ |
| best_node = None |
| best_dist = float("inf") |
| for node in self.nodes.values(): |
| if affordance in node.affordances: |
| dist = math.sqrt( |
| (node.position[0] - from_pos[0])**2 + |
| (node.position[1] - from_pos[1])**2 + |
| (node.position[2] - from_pos[2])**2 |
| ) |
| if dist < best_dist: |
| best_dist = dist |
| best_node = node |
| return best_node |
|
|
| def get_node_at(self, x: float, y: float, z: float = 0.0) -> Optional[GraphNode]: |
| """Get the node at a specific position.""" |
| node_id = self._node_id(x, y, z) |
| return self.nodes.get(node_id) |
|
|
| def get_stats(self) -> Dict[str, Any]: |
| return { |
| "total_nodes": len(self.nodes), |
| "walkable_nodes": sum(1 for n in self.nodes.values() if n.is_walkable), |
| "furniture_nodes": sum(1 for n in self.nodes.values() if n.surface_type == "furniture"), |
| "doorway_nodes": sum(1 for n in self.nodes.values() if n.is_doorway), |
| "total_edges": sum(len(e) for e in self.edges.values()), |
| "room_bounds": self._room_bounds, |
| "resolution": self.resolution, |
| } |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| """Serialize the graph for the renderer.""" |
| return { |
| "nodes": {nid: n.to_dict() for nid, n in self.nodes.items()}, |
| "edges": {nid: [{"to": e.to_node, "dist": e.distance, |
| "type": e.transition_type} |
| for e in edges] |
| for nid, edges in self.edges.items()}, |
| "stats": self.get_stats(), |
| } |
|
|
|
|
| |
| import numpy as np |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| logging.basicConfig(level=logging.INFO, |
| format="%(asctime)s [%(levelname)s] %(message)s") |
|
|
| print("=== Nima Affordance Graph β Self Test ===\n") |
|
|
| |
| class FakeSurface: |
| def __init__(self, pos, stype, height, material): |
| self.position = pos |
| self.surface_type = stype |
| self.height = height |
| self.material = material |
|
|
| class FakeSpatialMap: |
| def __init__(self): |
| self.room_bounds = {"x_min": 0, "x_max": 4, "y_min": 0, "y_max": 4, "z_min": 0, "z_max": 2.5} |
| |
| self.surfaces = [ |
| FakeSurface((1.0, 1.0, 0.4), "furniture", 0.4, "soft"), |
| FakeSurface((3.0, 3.0, 0.8), "furniture", 0.8, "rigid"), |
| ] |
|
|
| graph = AffordanceGraph(grid_resolution=0.5) |
| graph.build_from_spatial_map(FakeSpatialMap()) |
|
|
| stats = graph.get_stats() |
| print(f"Graph stats:") |
| print(f" Nodes: {stats['total_nodes']} ({stats['walkable_nodes']} walkable)") |
| print(f" Furniture: {stats['furniture_nodes']}") |
| print(f" Doorways: {stats['doorway_nodes']}") |
| print(f" Edges: {stats['total_edges']}") |
| print() |
|
|
| |
| print("=== Pathfinding test ===") |
| path = graph.find_path((0.5, 0.5, 0.0), (3.5, 3.5, 0.0)) |
| print(f"Path from (0.5, 0.5) to (3.5, 3.5): {len(path)} steps") |
| for node in path[:5]: |
| print(f" {node.node_id} at {node.position} ({node.surface_type})") |
| if len(path) > 5: |
| print(f" ... ({len(path) - 5} more steps)") |
| print() |
|
|
| |
| print("=== Affordance search ===") |
| sit_spot = graph.find_affordance(AffordanceType.SIT, (0.5, 0.5, 0.0)) |
| if sit_spot: |
| print(f"Nearest sit-able spot: {sit_spot.node_id} at {sit_spot.position}") |
| print(f" Material: {sit_spot.material}, Height: {sit_spot.height}m") |
|
|
| jump_spot = graph.find_affordance(AffordanceType.JUMP_ON, (0.5, 0.5, 0.0)) |
| if jump_spot: |
| print(f"Nearest jump-able spot: {jump_spot.node_id} at {jump_spot.position}") |
|
|
| print(f"\n=== Graph self-test PASSED ===") |
|
|