Powered by AppSignal & Oban Pro

Object3D Gaussian Splat Viewer

livebooks/object3d_exploration.livemd

Object3D Gaussian Splat Viewer

Mix.install([
  {:kino, "~> 0.14"},
  {:kino_vega_lite, "~> 0.1"}
])

Overview

This notebook documents and explores Sensocto’s Object3D Viewer feature, which enables synchronized 3D Gaussian splat viewing across multiple participants in rooms and the lobby.

Key Capabilities

  • Synchronized Viewing: All participants see the same 3D object and camera position
  • Controller System: One user controls the view, others follow
  • Playlist Support: Queue multiple 3D objects for sequential viewing
  • Camera Presets: Objects can have default camera positions
  • P2P Sync: State synchronizes via Automerge CRDT over Iroh gossip

Architecture Overview

architecture = """
┌─────────────────────────────────────────────────────────────────┐
│                        Browser (Client)                          │
├─────────────────────────────────────────────────────────────────┤
│  Object3DPlayerHook (JS)                                        │
│  ├── GaussianSplats3D Viewer (WebGL)                           │
│  ├── Camera Controls (OrbitControls)                           │
│  └── Event Handlers (camera_moved, loading_*, viewer_ready)    │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ Phoenix LiveView
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Object3DPlayerComponent                       │
│  ├── Playlist UI (drag-to-reorder)                              │
│  ├── Controller Assignment UI                                   │
│  └── Camera Control Buttons                                     │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ GenServer Calls
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Object3DPlayerServer                          │
│  ├── State: current_item, controller, camera_position          │
│  ├── Playlist Navigation (next/previous/view_item)             │
│  └── PubSub Broadcasts                                          │
└─────────────────────────────────────────────────────────────────┘
                              │
              ┌───────────────┴───────────────┐
              │                               │
              ▼                               ▼
┌─────────────────────────┐     ┌─────────────────────────┐
│      Phoenix PubSub      │     │    RoomStateBridge      │
│  (Same-server sync)      │     │  (P2P CRDT sync)        │
└─────────────────────────┘     └─────────────────────────┘
                                              │
                                              ▼
                                ┌─────────────────────────┐
                                │    RoomStateCRDT        │
                                │  (Automerge + Iroh)     │
                                └─────────────────────────┘
"""

Kino.Markdown.new("```\n#{architecture}\n```")

Controller System

The viewer uses a controller/follower model for synchronized viewing:

controller_states = [
  %{
    state: "No Controller",
    description: "Anyone can navigate freely",
    camera_sync: "Disabled",
    who_can_control: "Everyone"
  },
  %{
    state: "Controller Assigned",
    description: "One user controls the view",
    camera_sync: "Controller → Followers",
    who_can_control: "Only Controller"
  },
  %{
    state: "Controller Released",
    description: "Returns to free navigation",
    camera_sync: "Disabled",
    who_can_control: "Everyone"
  }
]

Kino.DataTable.new(controller_states)

Control Flow Logic

defmodule ControllerLogic do
  @doc """
  Determines if a user can control the 3D viewer.
  """
  def can_control?(controller_user_id, current_user_id) do
    cond do
      # No controller assigned - anyone can control
      is_nil(controller_user_id) -> true
      # User is the controller
      controller_user_id == current_user_id -> true
      # Someone else is the controller
      true -> false
    end
  end

  @doc """
  Simulates the control state machine.
  """
  def transition(current_controller, action, user_id) do
    case {current_controller, action} do
      {nil, :take_control} ->
        {:ok, user_id, "#{user_id} is now the controller"}

      {^user_id, :release_control} ->
        {:ok, nil, "Control released, free navigation enabled"}

      {other, :take_control} when not is_nil(other) ->
        {:error, "Cannot take control - #{other} is currently controlling"}

      {other, :release_control} when other != user_id ->
        {:error, "Cannot release - you are not the controller"}

      _ ->
        {:error, "Invalid action"}
    end
  end
end

# Interactive controller simulation
user_input = Kino.Input.text("Your User ID", default: "user_123")
action_input = Kino.Input.select("Action", [
  take_control: "Take Control",
  release_control: "Release Control"
])
current_controller_input = Kino.Input.text("Current Controller (leave empty for none)", default: "")

Kino.Layout.grid([user_input, current_controller_input, action_input], columns: 3)
user_id = Kino.Input.read(user_input)
action = Kino.Input.read(action_input)
current = Kino.Input.read(current_controller_input)
current_controller = if current == "", do: nil, else: current

result = ControllerLogic.transition(current_controller, action, user_id)

case result do
  {:ok, new_controller, message} ->
    """
    ## Result: Success

    **Message:** #{message}

    **New Controller:** #{new_controller || "None (free navigation)"}
    """
  {:error, message} ->
    """
    ## Result: Error

    **Message:** #{message}
    """
end
|> Kino.Markdown.new()

Camera Position & Target

The 3D viewer uses two vectors to define the camera view:

camera_info = [
  %{
    property: "Camera Position",
    description: "Where the camera is located in 3D space",
    format: "{x, y, z}",
    default: "{0, 0, 5}",
    example: "5 units back on Z axis"
  },
  %{
    property: "Camera Target",
    description: "Where the camera is looking at",
    format: "{x, y, z}",
    default: "{0, 0, 0}",
    example: "Origin point"
  },
  %{
    property: "Camera Up",
    description: "Which direction is 'up' for the camera",
    format: "{x, y, z}",
    default: "{0, -1, 0}",
    example: "Negative Y is up (Gaussian splat convention)"
  }
]

Kino.DataTable.new(camera_info)

Camera Preset Parser

Items can have camera presets stored as comma-separated strings:

defmodule CameraPresetParser do
  @doc """
  Parses a camera preset string like "1.5, 2.0, 3.5" into a map.
  """
  def parse_xyz(nil, default), do: default
  def parse_xyz("", default), do: default

  def parse_xyz(str, default) when is_binary(str) do
    case String.split(str, ",") do
      [x, y, z] ->
        %{
          x: parse_float(x, default.x),
          y: parse_float(y, default.y),
          z: parse_float(z, default.z)
        }
      _ ->
        default
    end
  end

  defp parse_float(str, default) do
    case Float.parse(String.trim(str)) do
      {val, _} -> val
      :error -> default
    end
  end

  @doc """
  Formats a position map back to a preset string.
  """
  def format_xyz(%{x: x, y: y, z: z}) do
    "#{x}, #{y}, #{z}"
  end
end

# Interactive preset parser
preset_input = Kino.Input.text("Camera Preset String", default: "2.5, 1.0, 8.0")
Kino.render(preset_input)
preset_str = Kino.Input.read(preset_input)
default = %{x: 0, y: 0, z: 5}
parsed = CameraPresetParser.parse_xyz(preset_str, default)

"""
## Parsed Camera Position

| Axis | Value |
|------|-------|
| X | #{parsed.x} |
| Y | #{parsed.y} |
| Z | #{parsed.z} |

**Re-formatted:** `#{CameraPresetParser.format_xyz(parsed)}`
"""
|> Kino.Markdown.new()

Camera Position Visualization

# Generate sample camera positions for visualization
camera_positions = [
  %{name: "Default", x: 0, y: 0, z: 5, type: "position"},
  %{name: "Top View", x: 0, y: 10, z: 0, type: "position"},
  %{name: "Side View", x: 10, y: 0, z: 0, type: "position"},
  %{name: "Angled", x: 5, y: 5, z: 5, type: "position"},
  %{name: "Origin", x: 0, y: 0, z: 0, type: "target"}
]

VegaLite.new(width: 400, height: 400, title: "Camera Positions (Top-Down View)")
|> VegaLite.data_from_values(camera_positions)
|> VegaLite.mark(:point, size: 200)
|> VegaLite.encode_field(:x, "x", type: :quantitative, title: "X Position")
|> VegaLite.encode_field(:y, "z", type: :quantitative, title: "Z Position")
|> VegaLite.encode_field(:color, "type",
  type: :nominal,
  scale: %{domain: ["position", "target"], range: ["#3b82f6", "#ef4444"]}
)
|> VegaLite.encode_field(:shape, "type", type: :nominal)
|> VegaLite.encode_field(:tooltip, "name", type: :nominal)

PubSub Topics

The Object3D system uses Phoenix PubSub for real-time synchronization:

pubsub_topics = [
  %{
    topic: "object3d:lobby",
    scope: "Global",
    events: "Item changes, camera sync, controller changes",
    subscribers: "LobbyLive"
  },
  %{
    topic: "object3d:{room_id}",
    scope: "Per-Room",
    events: "Item changes, camera sync, controller changes",
    subscribers: "RoomShowLive"
  }
]

Kino.DataTable.new(pubsub_topics)

PubSub Events

events = [
  %{
    event: ":object3d_item_changed",
    payload: "%{item, camera_position, camera_target, timestamp}",
    trigger: "User selects different item or navigates playlist",
    action: "Update viewer with new splat URL and camera"
  },
  %{
    event: ":object3d_camera_synced",
    payload: "%{camera_position, camera_target, user_id, timestamp}",
    trigger: "Controller moves camera (throttled to 200ms)",
    action: "Followers update their camera position"
  },
  %{
    event: ":object3d_controller_changed",
    payload: "%{controller_user_id, controller_user_name}",
    trigger: "User takes or releases control",
    action: "Update UI to show current controller"
  },
  %{
    event: ":object3d_playlist_updated",
    payload: "%{items}",
    trigger: "Item added, removed, or reordered",
    action: "Refresh playlist display"
  }
]

Kino.DataTable.new(events)

Playlist Management

Playlists store ordered collections of 3D objects:

playlist_schema = """
## Playlist Schema

### Object3DPlaylist
| Field | Type | Description |
|-------|------|-------------|
| id | UUID | Primary key |
| room_id | UUID (nullable) | Associated room (nil for lobby) |
| is_lobby | boolean | True if this is the lobby playlist |
| inserted_at | datetime | Creation timestamp |
| updated_at | datetime | Last update timestamp |

### Object3DPlaylistItem
| Field | Type | Description |
|-------|------|-------------|
| id | UUID | Primary key |
| playlist_id | UUID | Parent playlist |
| splat_url | string | URL to .ply or .splat file |
| name | string | Display name |
| description | string | Optional description |
| thumbnail_url | string | Preview image URL |
| source_url | string | Original source link |
| camera_preset_position | string | Default camera position "x,y,z" |
| camera_preset_target | string | Default camera target "x,y,z" |
| position | integer | Order in playlist |
| view_count | integer | Times viewed |
| last_viewed_at | datetime | Last view timestamp |
"""

Kino.Markdown.new(playlist_schema)

Playlist Operations

operations = [
  %{
    operation: "add_to_playlist",
    function: "Object3D.add_to_playlist/3",
    params: "playlist_id, attrs, user_id",
    description: "Adds new item at end of playlist"
  },
  %{
    operation: "remove_from_playlist",
    function: "Object3D.remove_from_playlist/1",
    params: "item_id",
    description: "Removes item and reorders remaining"
  },
  %{
    operation: "reorder_playlist",
    function: "Object3D.reorder_playlist/2",
    params: "playlist_id, item_ids",
    description: "Updates positions based on new order"
  },
  %{
    operation: "get_next_item",
    function: "Object3D.get_next_item/2",
    params: "playlist_id, current_item_id",
    description: "Returns next item or nil at end"
  },
  %{
    operation: "get_previous_item",
    function: "Object3D.get_previous_item/2",
    params: "playlist_id, current_item_id",
    description: "Returns previous item or nil at start"
  },
  %{
    operation: "mark_item_viewed",
    function: "Object3D.mark_item_viewed/1",
    params: "item_id",
    description: "Increments view_count, updates last_viewed_at"
  }
]

Kino.DataTable.new(operations)

JavaScript Hook Events

The Object3DPlayerHook handles bidirectional communication:

js_events = [
  %{
    direction: "Server → Client",
    event: "object3d_sync",
    payload: "{current_item, camera_position, camera_target, controller_user_id}",
    action: "Full state sync, loads new splat if item changed"
  },
  %{
    direction: "Server → Client",
    event: "object3d_camera_sync",
    payload: "{camera_position, camera_target}",
    action: "Updates camera for followers (with grace period)"
  },
  %{
    direction: "Server → Client",
    event: "object3d_reset_camera",
    payload: "{}",
    action: "Resets camera to default position"
  },
  %{
    direction: "Server → Client",
    event: "object3d_center_object",
    payload: "{}",
    action: "Centers camera on object bounding box"
  },
  %{
    direction: "Client → Server",
    event: "camera_moved",
    payload: "{position, target}",
    action: "Controller reports camera movement (throttled)"
  },
  %{
    direction: "Client → Server",
    event: "viewer_ready",
    payload: "{}",
    action: "Viewer initialized, ready to receive splats"
  },
  %{
    direction: "Client → Server",
    event: "loading_started",
    payload: "{url}",
    action: "Shows loading indicator"
  },
  %{
    direction: "Client → Server",
    event: "loading_complete",
    payload: "{url}",
    action: "Hides loading indicator"
  },
  %{
    direction: "Client → Server",
    event: "loading_error",
    payload: "{message, url}",
    action: "Shows error, hides loading"
  }
]

Kino.DataTable.new(js_events)

Camera Sync Throttling

The controller’s camera movements are throttled to prevent network spam:

throttling_config = %{
  camera_sync_throttle_ms: 200,
  grace_period_ms: 500,
  position_delta_threshold: 0.01
}

"""
## Camera Sync Configuration

| Setting | Value | Purpose |
|---------|-------|---------|
| Throttle Interval | #{throttling_config.camera_sync_throttle_ms}ms | Max sync frequency |
| Grace Period | #{throttling_config.grace_period_ms}ms | Ignore incoming syncs after user action |
| Delta Threshold | #{throttling_config.position_delta_threshold} | Min movement to trigger sync |

### How It Works

1. Controller moves camera
2. JS hook checks if #{throttling_config.camera_sync_throttle_ms}ms passed since last sync
3. Calculates position delta (sum of |x|, |y|, |z| differences)
4. If delta > #{throttling_config.position_delta_threshold}, sends `camera_moved` event
5. Server broadcasts to followers
6. Followers apply position (unless in grace period from own interaction)
"""
|> Kino.Markdown.new()

CRDT Synchronization

Object3D state syncs across peers via Automerge CRDT:

crdt_structure = """
## CRDT Document Structure

{ “room_id”: “uuid”, “object_3d”: {

"splat_url": "https://example.com/object.splat",
"camera_position": {"x": 0, "y": 0, "z": 5},
"camera_target": {"x": 0, "y": 0, "z": 0},
"updated_by": "user-id",
"updated_at": "2024-01-15T10:30:00Z"

} }


### Sync Flow

1. **Local Change** → Object3DPlayerServer broadcasts via PubSub
2. **RoomStateBridge** receives PubSub message
3. **apply_local_object3d_change/2** updates CRDT
4. **RoomStateCRDT.sync_room/1** triggers Iroh gossip
5. **Remote Peers** receive and merge changes
6. **Conflict Resolution** → Last-write-wins for camera position
"""

Kino.Markdown.new(crdt_structure)

File Reference

files = [
  %{
    component: "Object3D Context",
    path: "lib/sensocto/object3d/object3d.ex",
    lines: 399,
    purpose: "CRUD operations for playlists and items"
  },
  %{
    component: "PlayerServer",
    path: "lib/sensocto/object3d/object3d_player_server.ex",
    lines: 479,
    purpose: "GenServer managing synchronized state"
  },
  %{
    component: "PlayerSupervisor",
    path: "lib/sensocto/object3d/object3d_player_supervisor.ex",
    lines: 137,
    purpose: "DynamicSupervisor for player servers"
  },
  %{
    component: "Playlist Schema",
    path: "lib/sensocto/object3d/object3d_playlist.ex",
    lines: 56,
    purpose: "Ecto schema for playlists"
  },
  %{
    component: "PlaylistItem Schema",
    path: "lib/sensocto/object3d/object3d_playlist_item.ex",
    lines: 97,
    purpose: "Ecto schema for playlist items"
  },
  %{
    component: "LiveComponent",
    path: "lib/sensocto_web/live/components/object3d_player_component.ex",
    lines: 628,
    purpose: "UI component with playlist and controls"
  },
  %{
    component: "JS Hook",
    path: "assets/js/hooks.js",
    lines: "~500 (Object3DPlayerHook)",
    purpose: "WebGL viewer and event handling"
  },
  %{
    component: "RoomStateBridge",
    path: "lib/sensocto/iroh/room_state_bridge.ex",
    lines: 340,
    purpose: "CRDT sync bridge"
  },
  %{
    component: "RoomStateCRDT",
    path: "lib/sensocto/iroh/room_state_crdt.ex",
    lines: 637,
    purpose: "Automerge CRDT operations"
  },
  %{
    component: "Migration",
    path: "priv/repo/migrations/20260115200000_create_object3d_playlists.exs",
    lines: 82,
    purpose: "Database schema"
  }
]

Kino.DataTable.new(files)

Live Testing (Connected to App)

When running attached to the Sensocto application node:

# Uncomment to test against live system:

# alias Sensocto.Object3D
# alias Sensocto.Object3D.Object3DPlayerServer

# # Get or create lobby playlist
# {:ok, playlist} = Object3D.get_or_create_lobby_playlist()
# IO.inspect(playlist, label: "Lobby Playlist")

# # List all items
# items = Object3D.get_playlist_items(playlist.id)
# IO.inspect(items, label: "Playlist Items")

# # Get player state
# {:ok, state} = Object3DPlayerServer.get_state(:lobby)
# IO.inspect(state, label: "Player State")

# # Add a test item
# {:ok, item} = Object3D.add_to_playlist(playlist.id, %{
#   splat_url: "https://example.com/test.splat",
#   name: "Test Object"
# }, "test-user")
# IO.inspect(item, label: "New Item")