From 37856199670c23bd337752efbbda1efe3c3855dc Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 16:54:15 -0400 Subject: [PATCH 1/3] feat(fusion): assert engine node positions at startup (bf-1tsm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bf-6s3d added TestEngine_SeedNodePositions, the host/unit-test half of the node-position invariant (it replays main.go's seeding loop against the engine and asserts NodeCount()/NodePositions() hold distinct, non-(0,0,1) positions). This bead adds the complementary half the NodePositions() docstring (fusion.go:140-159) already advertises: "a logged startup assertion" exercised in the live path. Right after the main.go seeding loop (which calls fusionEngine.SetNodePosition(node.MAC, node.PosX, node.PosY, node.PosZ) per fleet node), the engine now inspects NodePositions() and logs: - n == 0 → INFO "no nodes positioned yet" (valid; IO-1 reaches ready with no nodes attached) - atDefault == n → WARN "all N seeded nodes collapsed to (0,0,1)" (fleet never positioned → degenerate fusion) - otherwise → INFO "N nodes seeded, D distinct, A at (0,0,1)" The (0,0,1) collapse is the failure mode: when no node has been positioned, every node retains the nodes-table schema default (pos_x=0, pos_y=0, pos_z=1) and Fuse emits only degenerate peaks. The assertion surfaces this without blocking startup (the hard gate stays the bf-6s3d unit test). Also gofmt's the file (drive-by): bf-3f6q's "fusion" import sat after "ingestion" instead of alphabetically after "floorplan". go build ./... / go vet ./... / go test ./... all pass; gofmt clean. Co-Authored-By: Claude Bead-Id: bf-1tsm --- mothership/cmd/mothership/main.go | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 0e58ed4..621dc1b 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -43,11 +43,11 @@ import ( "github.com/spaxel/mothership/internal/falldetect" "github.com/spaxel/mothership/internal/fleet" "github.com/spaxel/mothership/internal/floorplan" + "github.com/spaxel/mothership/internal/fusion" guidedtroubleshoot "github.com/spaxel/mothership/internal/guidedtroubleshoot" "github.com/spaxel/mothership/internal/health" featurehelp "github.com/spaxel/mothership/internal/help" "github.com/spaxel/mothership/internal/ingestion" - "github.com/spaxel/mothership/internal/fusion" "github.com/spaxel/mothership/internal/learning" "github.com/spaxel/mothership/internal/loadshed" "github.com/spaxel/mothership/internal/localization" @@ -1061,6 +1061,39 @@ func main() { } } + // bf-1tsm: Startup assertion over fusionEngine.NodePositions() (the + // fusion.go:140-159 docstring notes this startup-assertion use). After the + // seeding loop above, the engine must hold populated, distinct positions — + // NOT every node collapsed to the nodes-table schema default (pos_x=0, + // pos_y=0, pos_z=1 → NodePosition{X:0,Y:0,Z:1}). A fleet where every seeded + // node still sits at that default was never positioned: all nodes are + // co-located, so Fuse emits only degenerate peaks and cannot localize. + // This logs the assertion result but does NOT block startup, because an + // empty or freshly-onboarded fleet is a valid state (IO-1: the server + // reaches ready with no nodes positioned). The hard regression gate for + // the invariant is TestEngine_SeedNodePositions (bf-6s3d). + { + type posKey struct{ x, y, z float64 } + positions := fusionEngine.NodePositions() + atDefault := 0 + seen := make(map[posKey]struct{}, len(positions)) + for _, p := range positions { + if p.X == 0 && p.Y == 0 && p.Z == 1 { + atDefault++ + } + seen[posKey{p.X, p.Y, p.Z}] = struct{}{} + } + switch n := len(positions); { + case n == 0: + log.Printf("[INFO] 3D fusion engine node-position assertion: no nodes positioned yet") + case atDefault == n: + log.Printf("[WARN] 3D fusion engine node-position assertion FAILED: all %d seeded nodes collapsed to (0,0,1) — position nodes in the dashboard before fusion can localize", n) + default: + log.Printf("[INFO] 3D fusion engine node-position assertion OK: %d nodes seeded, %d distinct positions, %d at (0,0,1) default", + n, len(seen), atDefault) + } + } + // Start the self-improving localization system selfImprovingLocalizer.Start() log.Printf("[INFO] Self-improving localization started (room: %.1fx%.1fm, interval: %v)", From a17bdd2c8c9f4dc31aaecfbe47b96ab9b3b29a5b Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 17:31:40 -0400 Subject: [PATCH 2/3] feat(fusion): propagate runtime node positions to the fusion engine (bf-3p6g) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward node 3D position changes to the blob-producing fusion engine so a node moved at runtime does not keep a stale engine position. The fleet manager gains a nodePositionSink — the write-side mirror of the read-side SetNodePositionAccessor used by diagnostics and weather.go — wired to fusionEngine.SetNodePosition at startup. It fires from both position paths: PATCH /api/nodes/{mac}/position (handler.updateNodePosition → ForwardNodePosition) and node connect/register (OnNodeConnected forwards the registry position). Tests assert the engine's stored position for a MAC changes after a PATCH with new coordinates and is seeded on connect. Co-Authored-By: Claude --- mothership/cmd/mothership/main.go | 12 +++ mothership/internal/fleet/handler.go | 4 + mothership/internal/fleet/handler_test.go | 103 ++++++++++++++++++++++ mothership/internal/fleet/manager.go | 46 ++++++++++ 4 files changed, 165 insertions(+) diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 621dc1b..b2774dd 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -1410,6 +1410,18 @@ func main() { // Legacy fleet manager (kept for basic operations) fleetMgr := fleet.NewManager(fleetReg) + // bf-3p6g: Forward runtime node position changes to the blob-producing 3D + // fusion engine so a node moved at runtime does not stay at its stale engine + // position. The sink fires from both PATCH /api/nodes/{mac}/position + // (fleet.Handler.updateNodePosition → ForwardNodePosition) and node + // connect/register (Manager.OnNodeConnected), converging the two position + // paths on one accessor — the write-side mirror of the read-side + // SetNodePositionAccessor used by diagnostics and weather.go. fusionEngine + // is constructed above (Phase 6, bf-3f6q) and captured by this closure. + fleetMgr.SetNodePositionSink(func(mac string, x, y, z float64) { + fusionEngine.SetNodePosition(mac, x, y, z) + }) + // Phase 5: Multi-notifier broadcasts node events to legacy manager, healer, and self-heal manager multiNotify := newMultiNotifier(fleetMgr, fleetHealer, selfHealManager) ingestSrv.SetFleetNotifier(multiNotify) diff --git a/mothership/internal/fleet/handler.go b/mothership/internal/fleet/handler.go index 8355261..7220e31 100644 --- a/mothership/internal/fleet/handler.go +++ b/mothership/internal/fleet/handler.go @@ -393,6 +393,10 @@ func (h *Handler) updateNodePosition(w http.ResponseWriter, r *http.Request) { return } h.mgr.BroadcastRegistry() + // bf-3p6g: Forward the new position to the blob-producing fusion engine so + // a node moved at runtime does not stay at its stale engine position (the + // engine's node registry is the geometry Fuse localizes against). + h.mgr.ForwardNodePosition(mac, req.X, req.Y, req.Z) w.WriteHeader(http.StatusNoContent) } diff --git a/mothership/internal/fleet/handler_test.go b/mothership/internal/fleet/handler_test.go index 46c9d40..cd9bbc3 100644 --- a/mothership/internal/fleet/handler_test.go +++ b/mothership/internal/fleet/handler_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/spaxel/mothership/internal/fusion" ) // mockNodeIdentifier is a mock implementation of NodeIdentifier for testing. @@ -1425,6 +1426,108 @@ func TestHandlerUpdateNodePosition(t *testing.T) { } } +// engineNodePosition returns the fusion engine's stored position for mac, or +// nil if the engine holds no position for it. The engine exposes its node +// registry only via NodePositions() (a snapshot slice), so the helper scans. +func engineNodePosition(e *fusion.Engine, mac string) *fusion.NodePosition { + for _, p := range e.NodePositions() { + if p.MAC == mac { + cp := p + return &cp + } + } + return nil +} + +// patchNodePosition issues a PUT /api/nodes/{mac}/position through the handler +// and fails the test if the handler does not return 204 No Content. +func patchNodePosition(t *testing.T, h *Handler, mac, body string) { + t.Helper() + req := httptest.NewRequest("PUT", "/api/nodes/"+mac+"/position", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rctx := chi.NewRouteContext() + rctx.URLParams.Add("mac", mac) + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + w := httptest.NewRecorder() + h.updateNodePosition(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("updateNodePosition status = %v, want %d (body=%q)", w.Code, http.StatusNoContent, w.Body.String()) + } +} + +// TestHandlerUpdateNodePositionForwardsToEngine (bf-3p6g) verifies that a +// PATCH /api/nodes/{mac}/position flows through the handler and the manager's +// node position sink into the blob-producing fusion engine, and that the +// position the engine stores for that MAC changes to the new coordinates. +func TestHandlerUpdateNodePositionForwardsToEngine(t *testing.T) { + const mac = "AA:BB:CC:DD:EE:FF" + reg := newTestRegistry(t) + reg.UpsertNode(mac, "1.0.0", "ESP32-S3") + reg.SetNodeRole(mac, "rx") + reg.SetNodePosition(mac, 0, 0, 0) + + engine := fusion.NewEngine(nil) + mgr := NewManager(reg) + mgr.SetNodePositionSink(func(m string, x, y, z float64) { + engine.SetNodePosition(m, x, y, z) + }) + h := &Handler{mgr: mgr} + + // Sanity: engine holds no position for this MAC before any PATCH. + if p := engineNodePosition(engine, mac); p != nil { + t.Fatalf("engine should hold no position before PATCH, got (%v,%v,%v)", p.X, p.Y, p.Z) + } + + // First PATCH seeds the engine position. + patchNodePosition(t, h, mac, `{"x": 1.5, "y": 2.5, "z": 3.5}`) + p := engineNodePosition(engine, mac) + if p == nil { + t.Fatal("PATCH did not forward node position to fusion engine") + } + if p.X != 1.5 || p.Y != 2.5 || p.Z != 3.5 { + t.Errorf("engine position after PATCH = (%v,%v,%v), want (1.5,2.5,3.5)", p.X, p.Y, p.Z) + } + + // Second PATCH with new coordinates: the stored engine position must change. + patchNodePosition(t, h, mac, `{"x": 7.0, "y": 8.0, "z": 9.0}`) + p = engineNodePosition(engine, mac) + if p == nil { + t.Fatal("engine lost position after second PATCH") + } + if p.X != 7.0 || p.Y != 8.0 || p.Z != 9.0 { + t.Errorf("engine position after second PATCH = (%v,%v,%v), want (7,8,9)", p.X, p.Y, p.Z) + } +} + +// TestManagerOnNodeConnectedForwardsPositionToEngine (bf-3p6g) verifies that a +// node connect/register event pushes the node's current registry position into +// the fusion engine, so a node positioned (or moved) while offline does not +// keep a stale engine position when it reconnects. +func TestManagerOnNodeConnectedForwardsPositionToEngine(t *testing.T) { + const mac = "AA:BB:CC:DD:EE:FF" + reg := newTestRegistry(t) + // Pre-position the node in the registry (e.g. placed while offline), then + // connect it; the manager must forward the registry position to the engine. + reg.UpsertNode(mac, "1.0.0", "ESP32-S3") + reg.SetNodePosition(mac, 4.0, 5.0, 6.0) + + engine := fusion.NewEngine(nil) + mgr := NewManager(reg) + mgr.SetNodePositionSink(func(m string, x, y, z float64) { + engine.SetNodePosition(m, x, y, z) + }) + + mgr.OnNodeConnected(mac, "1.0.0", "ESP32-S3") + + p := engineNodePosition(engine, mac) + if p == nil { + t.Fatal("OnNodeConnected did not forward node position to fusion engine") + } + if p.X != 4.0 || p.Y != 5.0 || p.Z != 6.0 { + t.Errorf("engine position after connect = (%v,%v,%v), want (4,5,6)", p.X, p.Y, p.Z) + } +} + // ─── Fleet page specific tests ───────────────────────────────────────────────────── // TestFleetTableRendering verifies that the fleet table renders correctly with 4 nodes diff --git a/mothership/internal/fleet/manager.go b/mothership/internal/fleet/manager.go index 8cb5107..0af5037 100644 --- a/mothership/internal/fleet/manager.go +++ b/mothership/internal/fleet/manager.go @@ -108,6 +108,16 @@ type Manager struct { // Collision detection and adaptive re-stagger collisionDetector *CollisionDetector collisionCheckTick time.Duration + + // nodePositionSink, when set, forwards a node's 3D world position to the + // blob-producing fusion engine (internal/fusion.Engine) whenever a node is + // positioned at runtime: from a PATCH /api/nodes/{mac}/position request + // (Handler.updateNodePosition → ForwardNodePosition) or a node + // connect/register event (OnNodeConnected). This is the write-side mirror + // of the read-side SetNodePositionAccessor pattern used by diagnostics and + // weather.go. Without it, a node moved at runtime keeps its stale engine + // position and Fuse localizes against the wrong geometry (bf-3p6g). + nodePositionSink func(mac string, x, y, z float64) } // NewManager creates a fleet manager backed by registry. @@ -144,6 +154,31 @@ func (m *Manager) SetBroadcaster(b RegistryBroadcaster) { m.mu.Unlock() } +// SetNodePositionSink wires the sink that receives node position updates to +// forward to the blob-producing fusion engine. Injected at startup (main.go) +// — see nodePositionSink. Mirrors the read-side SetNodePositionAccessor used +// by diagnostics/weather.go. +func (m *Manager) SetNodePositionSink(fn func(mac string, x, y, z float64)) { + m.mu.Lock() + m.nodePositionSink = fn + m.mu.Unlock() +} + +// ForwardNodePosition pushes a node's position to the registered sink (if any). +// Called by the REST handler after the registry has accepted a new position +// (PATCH /api/nodes/{mac}/position), and by OnNodeConnected on node +// connect/register, so the fusion engine's node registry stays in sync with +// the fleet registry and a node moved at runtime does not keep a stale engine +// position (bf-3p6g). +func (m *Manager) ForwardNodePosition(mac string, x, y, z float64) { + m.mu.RLock() + sink := m.nodePositionSink + m.mu.RUnlock() + if sink != nil { + sink(mac, x, y, z) + } +} + // OnNodeConnected is called when a node completes its hello handshake. // It persists the node, assigns a role, and broadcasts updated state. func (m *Manager) OnNodeConnected(mac, firmware, chip string) { @@ -162,6 +197,17 @@ func (m *Manager) OnNodeConnected(mac, firmware, chip string) { m.applyRoleAndConfig(mac, role) m.broadcastRegistry() + + // bf-3p6g: Push the node's current registry position to the fusion engine + // on connect/register so a node positioned (or moved) while offline does + // not keep a stale engine position. The fleet registry is the source of + // truth for positions; the engine holds a mirror that Fuse reads each + // tick. On first connect the position is the schema default until the user + // places the node, which is still forwarded (it matches the registry). + if x, y, z, ok := m.registry.GetNodePosition(mac); ok { + m.ForwardNodePosition(mac, x, y, z) + } + log.Printf("[INFO] fleet: node %s joined as %s", mac, role) } From 1f3e39a6c5f2d9ac01fe1008376876b7a76c94a4 Mon Sep 17 00:00:00 2001 From: jedarden Date: Fri, 3 Jul 2026 18:18:39 -0400 Subject: [PATCH 3/3] feat(simulator): add shared DefaultNodePositions helper for spread node geometry (bf-3fr6) --- mothership/internal/simulator/node.go | 65 +++++++++ .../internal/simulator/node_positions_test.go | 130 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 mothership/internal/simulator/node_positions_test.go diff --git a/mothership/internal/simulator/node.go b/mothership/internal/simulator/node.go index 5393bd8..40d47ca 100644 --- a/mothership/internal/simulator/node.go +++ b/mothership/internal/simulator/node.go @@ -265,6 +265,71 @@ func SuggestedNodes(s *Space, count int) *NodeSet { return ns } +// DefaultNodePositions returns count distinct, in-bounds node positions spread +// across the bounding box of space. It is the shared default-geometry source for +// simulator/spaxel-sim node seeding: distributing nodes across the room (rather +// than letting every node sit at the DB default of (0,0,1)) keeps the Fresnel +// excess path |P-T|+|P-R|-|T-R| non-degenerate so the 3D fusion engine can +// actually form blobs instead of collapsing toward zero (core symptom in bf-4q5w). +// +// The placement strategy mirrors cmd/sim's generateNodePositions: a single node +// at the room center, two on a diagonal, and three or more on an expanding +// square grid. For count >= 3 the grid always spans at least two columns and two +// rows, guaranteeing both X and Y range across the room, and every grid cell is +// distinct so no two nodes co-locate. Z alternates between a low and high +// band to provide height diversity (mixed-height placement improves fusion). +func DefaultNodePositions(s *Space, count int) []Point { + if count <= 0 { + return []Point{} + } + + minX, minY, minZ, maxX, maxY, maxZ := s.Bounds() + midX := (minX + maxX) / 2 + midY := (minY + maxY) / 2 + midZ := (minZ + maxZ) / 2 + + // Single node: center of the room. + if count == 1 { + return []Point{{X: midX, Y: midY, Z: midZ}} + } + + // Two nodes: opposite corners so both X and Y span the room. + if count == 2 { + return []Point{ + {X: minX, Y: minY, Z: maxZ}, + {X: maxX, Y: maxY, Z: maxZ}, + } + } + + // Three or more: row-major fill of a ceil(sqrt(count)) x ceil(sqrt(count)) + // grid. Because count >= 3, gridSize >= 2 and the last cell lands on + // row >= 1, so the grid touches two columns and two rows — spanning both + // axes — while every cell maps to a distinct floor position. + gridSize := int(math.Ceil(math.Sqrt(float64(count)))) + denom := float64(gridSize - 1) // >= 1 since gridSize >= 2 here + + lowZ := minZ + (maxZ-minZ)*0.25 + highZ := minZ + (maxZ-minZ)*0.75 + + positions := make([]Point, 0, count) + for i := 0; i < count; i++ { + col := i % gridSize + row := i / gridSize + // Alternate Z by cell parity for mixed-height diversity. + z := lowZ + if (row+col)%2 != 0 { + z = highZ + } + positions = append(positions, Point{ + X: minX + float64(col)*(maxX-minX)/denom, + Y: minY + float64(row)*(maxY-minY)/denom, + Z: z, + }) + } + + return positions +} + // GenerateAllLinks creates all possible links between nodes in the set. // For TX/RX or TX_RX nodes, this creates bidirectional links. // For passive radar (AP nodes), creates links from AP to each RX node. diff --git a/mothership/internal/simulator/node_positions_test.go b/mothership/internal/simulator/node_positions_test.go new file mode 100644 index 0000000..8253a82 --- /dev/null +++ b/mothership/internal/simulator/node_positions_test.go @@ -0,0 +1,130 @@ +package simulator + +import ( + "math" + "strconv" + "testing" +) + +// TestDefaultNodePositions_CountZeroAndNegative asserts that non-positive +// counts return an empty (non-nil) slice rather than panicking. +func TestDefaultNodePositions_CountZeroAndNegative(t *testing.T) { + for _, count := range []int{0, -1, -5} { + got := DefaultNodePositions(DefaultSpace(), count) + if len(got) != 0 { + t.Errorf("count=%d: expected empty slice, got %d positions", count, len(got)) + } + } +} + +// TestDefaultNodePositions_CountAndBounds asserts the helper returns exactly +// count positions, all within the space's bounding box, across a range of fleet +// sizes — including a space with a non-zero origin. +func TestDefaultNodePositions_CountAndBounds(t *testing.T) { + spaces := map[string]*Space{ + "default": DefaultSpace(), // 6x5x2.5 at origin + "offset": { + ID: "offset", + Rooms: []Room{{ + ID: "r", Name: "R", + MinX: 10, MinY: -3, MinZ: 1, + MaxX: 16, MaxY: 2, MaxZ: 3.5, + }}, + }, + } + + for name, space := range spaces { + minX, minY, minZ, maxX, maxY, maxZ := space.Bounds() + for _, count := range []int{1, 2, 3, 4, 5, 6, 8, 9, 16, 25} { + t.Run(name+"/"+strconv.Itoa(count), func(t *testing.T) { + got := DefaultNodePositions(space, count) + if len(got) != count { + t.Fatalf("expected %d positions, got %d", count, len(got)) + } + for i, p := range got { + if p.X < minX-tol || p.X > maxX+tol || + p.Y < minY-tol || p.Y > maxY+tol || + p.Z < minZ-tol || p.Z > maxZ+tol { + t.Errorf("node %d %v out of bounds min=(%v,%v,%v) max=(%v,%v,%v)", + i, p, minX, minY, minZ, maxX, maxY, maxZ) + } + } + }) + } + } +} + +// TestDefaultNodePositions_Distinct asserts that no two returned nodes are +// co-located: every pairwise distance is strictly greater than zero. This is +// the core non-degeneracy guarantee the helper exists to provide. +func TestDefaultNodePositions_Distinct(t *testing.T) { + space := DefaultSpace() + for _, count := range []int{1, 2, 3, 4, 5, 8, 16, 25} { + t.Run(strconv.Itoa(count), func(t *testing.T) { + got := DefaultNodePositions(space, count) + for i := 0; i < len(got); i++ { + for j := i + 1; j < len(got); j++ { + d := got[i].Distance(got[j]) + if d <= 0 { + t.Errorf("count=%d: nodes %d and %d co-located at %v (dist=%v)", + count, i, j, got[i], d) + } + } + } + }) + } +} + +// TestDefaultNodePositions_SpansRoom asserts that for count >= 2 the positions +// span the room on both X and Y: the min and max coordinates differ on each +// axis. A degenerate line or single-point cluster would fail this. +func TestDefaultNodePositions_SpansRoom(t *testing.T) { + space := DefaultSpace() + minX, minY, _, maxX, maxY, _ := space.Bounds() + width := maxX - minX + depth := maxY - minY + + for _, count := range []int{2, 3, 4, 5, 6, 8, 9, 16, 25} { + t.Run(strconv.Itoa(count), func(t *testing.T) { + got := DefaultNodePositions(space, count) + if len(got) < 2 { + t.Fatalf("expected >=2 positions, got %d", len(got)) + } + + minPX, maxPX := got[0].X, got[0].X + minPY, maxPY := got[0].Y, got[0].Y + for _, p := range got[1:] { + minPX = math.Min(minPX, p.X) + maxPX = math.Max(maxPX, p.X) + minPY = math.Min(minPY, p.Y) + maxPY = math.Max(maxPY, p.Y) + } + + // Span must be a meaningful fraction of the room, not just an + // epsilon. Require it to cover at least half of each axis. + if (maxPX-minPX) < width*0.5 { + t.Errorf("count=%d: X span %v < half width %v", count, maxPX-minPX, width*0.5) + } + if (maxPY-minPY) < depth*0.5 { + t.Errorf("count=%d: Y span %v < half depth %v", count, maxPY-minPY, depth*0.5) + } + }) + } +} + +// TestDefaultNodePositions_SingleNode is the degenerate case: one node sits at +// the room center (no spanning is possible with a single point). +func TestDefaultNodePositions_SingleNode(t *testing.T) { + space := DefaultSpace() + got := DefaultNodePositions(space, 1) + if len(got) != 1 { + t.Fatalf("expected 1 position, got %d", len(got)) + } + minX, minY, minZ, maxX, maxY, maxZ := space.Bounds() + want := Point{X: (minX + maxX) / 2, Y: (minY + maxY) / 2, Z: (minZ + maxZ) / 2} + if got[0] != want { + t.Errorf("expected center %v, got %v", want, got[0]) + } +} + +const tol = 1e-9