Merge remote-tracking branch 'origin/main'
Bead-Id: bf-38e9
This commit is contained in:
commit
3ba6f25fab
6 changed files with 394 additions and 1 deletions
|
|
@ -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)",
|
||||
|
|
@ -1377,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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
130
mothership/internal/simulator/node_positions_test.go
Normal file
130
mothership/internal/simulator/node_positions_test.go
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue