feat(bf-18yn): seed realistic node geometry in spaxel-sim default placement

- Modify AddNode handler to assign spread-out positions when nodes are
  created without explicit positions (at origin), preventing co-location
  that causes Fresnel excess path |P-T|+|P-R|-|T-R| to collapse toward 0
- Use DefaultNodePositions infrastructure from simulator package to
  distribute nodes across the room in a grid pattern
- Preserve explicitly-set positions, only spread default-origin nodes
- Add tests verifying spread behavior and explicit position preservation

This fixes the core symptom in bf-4q5w where co-located nodes prevented
blob formation in the fusion accumulation grid.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-06 00:21:38 -04:00
parent 4fe25fcfaf
commit 669d3793aa
2 changed files with 149 additions and 0 deletions

View file

@ -209,6 +209,19 @@ func (h *SimulatorHandler) AddNode(w http.ResponseWriter, r *http.Request) {
}
h.mu.Lock()
// If node has no explicit position (co-located at origin), assign a spread
// position so nodes don't collapse to the same point, which would cause
// Fresnel excess path |P-T|+|P-R|-|T-R| to approach 0 and prevent blob
// formation (bf-18yn, bf-4q5w). Use the existing spread-position
// infrastructure that mirrors cmd/sim's generateNodePositions.
if node.Position.X == 0 && node.Position.Y == 0 && node.Position.Z == 0 {
// Get current node count including the one we're about to add
currentNodes := h.nodes.All()
positions := simulator.DefaultNodePositions(h.space, len(currentNodes)+1)
if len(positions) > 0 {
node.Position = positions[len(currentNodes)]
}
}
h.nodes.Add(&node)
h.mu.Unlock()

View file

@ -0,0 +1,136 @@
// Package api tests the simulator REST API
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/spaxel/mothership/internal/simulator"
)
// TestAddNode_SpreadsDefaultOrigin tests that nodes created without explicit
// positions (at origin {0,0,0}) are assigned spread-out positions instead of
// remaining co-located, which would collapse Fresnel excess path length toward
// 0 and prevent blob formation (bf-18yn, bf-4q5w).
func TestAddNode_SpreadsDefaultOrigin(t *testing.T) {
handler := NewSimulatorHandler()
space := simulator.DefaultSpace()
handler.mu.Lock()
handler.space = space
handler.mu.Unlock()
// Create multiple nodes without positions (all at origin)
var createdNodes []simulator.Node
for i := 0; i < 4; i++ {
nodeJSON := map[string]interface{}{
"id": string(rune('a' + i)),
"name": "Node Name",
}
body, _ := json.Marshal(nodeJSON)
req := httptest.NewRequest("POST", "/api/simulator/nodes", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AddNode(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status %d, got %d", http.StatusCreated, w.Code)
}
var node simulator.Node
if err := json.Unmarshal(w.Body.Bytes(), &node); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
createdNodes = append(createdNodes, node)
}
// Verify all nodes have distinct positions (not co-located at origin)
positions := make(map[[3]float64]int)
for _, node := range createdNodes {
pos := [3]float64{node.Position.X, node.Position.Y, node.Position.Z}
// Check not at origin
if node.Position.X == 0 && node.Position.Y == 0 && node.Position.Z == 0 {
t.Errorf("node %s still at origin (0,0,0)", node.ID)
}
// Check distinct
positions[pos]++
if positions[pos] > 1 {
t.Errorf("duplicate position %v for node %s", pos, node.ID)
}
}
// Verify positions span the room (not all at same point)
_, _, _, _, _, _ = space.Bounds()
var minXSeen, maxXSeen, minYSeen, maxYSeen float64
for i, node := range createdNodes {
if i == 0 {
minXSeen, maxXSeen = node.Position.X, node.Position.X
minYSeen, maxYSeen = node.Position.Y, node.Position.Y
}
if node.Position.X < minXSeen {
minXSeen = node.Position.X
}
if node.Position.X > maxXSeen {
maxXSeen = node.Position.X
}
if node.Position.Y < minYSeen {
minYSeen = node.Position.Y
}
if node.Position.Y > maxYSeen {
maxYSeen = node.Position.Y
}
}
// For 4 nodes, we should see some spread across the room
// (not strictly required for correctness, but verifies the fix works)
if minXSeen == maxXSeen && minYSeen == maxYSeen {
t.Errorf("nodes don't span room: all at (%.2f, %.2f)", minXSeen, minYSeen)
}
t.Logf("Successfully spread %d nodes across room: X=[%.2f,%.2f] Y=[%.2f,%.2f]",
len(createdNodes), minXSeen, maxXSeen, minYSeen, maxYSeen)
}
// TestAddNode_KeepsExplicitPosition tests that nodes with explicit positions
// are not modified.
func TestAddNode_KeepsExplicitPosition(t *testing.T) {
handler := NewSimulatorHandler()
nodeJSON := map[string]interface{}{
"id": "test-node",
"position": map[string]interface{}{
"x": 1.5,
"y": 2.3,
"z": 1.0,
},
}
body, _ := json.Marshal(nodeJSON)
req := httptest.NewRequest("POST", "/api/simulator/nodes", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.AddNode(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status %d, got %d", http.StatusCreated, w.Code)
}
var node simulator.Node
if err := json.Unmarshal(w.Body.Bytes(), &node); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
// Verify explicit position was preserved
if node.Position.X != 1.5 || node.Position.Y != 2.3 || node.Position.Z != 1.0 {
t.Errorf("explicit position not preserved: got (%.2f, %.2f, %.2f)",
node.Position.X, node.Position.Y, node.Position.Z)
}
}