From 17cc3e08e8612feede3f3a4ab7810ca18af6d8c5 Mon Sep 17 00:00:00 2001 From: EthanForAi <128460220+EthanForAi@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:40:17 +0800 Subject: [PATCH] Add PlaceVR MVP capture template with 8-shot node limit --- go.mod | 3 ++ main.go | 42 +++++++++++----- placevr/capture.go | 108 ++++++++++++++++++++++++++++++++++++++++ placevr/capture_test.go | 36 ++++++++++++++ 4 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 go.mod create mode 100644 placevr/capture.go create mode 100644 placevr/capture_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..37fd94f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module placevr + +go 1.22 diff --git a/main.go b/main.go index 3563af6..5e0d4d7 100644 --- a/main.go +++ b/main.go @@ -1,22 +1,40 @@ package main import ( + "encoding/json" "fmt" - "math/rand" + "log" "time" -) -func printRandomNumber() { - // Seed with current time to get different values across runs. - r := rand.New(rand.NewSource(time.Now().UnixNano())) - now := time.Now().Format("2006-01-02 15:04:05") - fmt.Printf("[%s] helloworld random: %d\n", now, r.Intn(100)) -} + "placevr/placevr" +) func main() { - // Continuously print a random number every second. - for { - printRandomNumber() - time.Sleep(time.Second) + node := placevr.Node{ + NodeID: "node-demo-1", + Template: placevr.DefaultTemplateV1(), + CreatedAt: time.Now(), + } + + for i := 1; i <= placevr.MaxShotsPerNode; i++ { + if err := node.AddShot(placevr.Shot{ + ShotID: fmt.Sprintf("shot-%02d", i), + FilePath: fmt.Sprintf("sessions/demo/node-1/%02d.jpg", i), + CapturedAt: time.Now(), + }); err != nil { + log.Fatalf("failed to add shot: %v", err) + } } + + if err := node.Validate(); err != nil { + log.Fatalf("invalid node: %v", err) + } + + payload, err := json.MarshalIndent(node, "", " ") + if err != nil { + log.Fatalf("marshal failed: %v", err) + } + + fmt.Println(string(payload)) + fmt.Printf("ready_for_upload=%t\n", node.IsReadyForUpload()) } diff --git a/placevr/capture.go b/placevr/capture.go new file mode 100644 index 0000000..cf73cbb --- /dev/null +++ b/placevr/capture.go @@ -0,0 +1,108 @@ +package placevr + +import ( + "errors" + "fmt" + "time" +) + +const ( + // MaxShotsPerNode enforces the UX constraint for MVP capture flow. + MaxShotsPerNode = 8 +) + +var ( + ErrTooManyShots = fmt.Errorf("node has more than %d shots", MaxShotsPerNode) + ErrTemplateSize = fmt.Errorf("capture template must contain exactly %d guide slots", MaxShotsPerNode) + ErrTemplateOrder = errors.New("capture template order must be continuous starting from 1") + ErrDuplicateGuideSlot = errors.New("capture template contains duplicate order slot") +) + +// GuideSlot defines one guided capture position in a fixed template. +type GuideSlot struct { + Order int `json:"order"` + Yaw float64 `json:"yaw"` + Pitch float64 `json:"pitch"` +} + +// CaptureTemplate is fixed in MVP to improve completion rate and upload predictability. +type CaptureTemplate struct { + ID string `json:"id"` + Slots []GuideSlot `json:"slots"` +} + +func (t CaptureTemplate) Validate() error { + if len(t.Slots) != MaxShotsPerNode { + return ErrTemplateSize + } + + seen := make(map[int]struct{}, MaxShotsPerNode) + for _, slot := range t.Slots { + if _, ok := seen[slot.Order]; ok { + return ErrDuplicateGuideSlot + } + seen[slot.Order] = struct{}{} + } + + for i := 1; i <= MaxShotsPerNode; i++ { + if _, ok := seen[i]; !ok { + return ErrTemplateOrder + } + } + + return nil +} + +// DefaultTemplateV1 gives 8 directions around the user with slight pitch variation. +func DefaultTemplateV1() CaptureTemplate { + return CaptureTemplate{ + ID: "indoor_8shot_v1", + Slots: []GuideSlot{ + {Order: 1, Yaw: 0, Pitch: 0}, + {Order: 2, Yaw: 45, Pitch: 0}, + {Order: 3, Yaw: 90, Pitch: 0}, + {Order: 4, Yaw: 135, Pitch: 0}, + {Order: 5, Yaw: 180, Pitch: 0}, + {Order: 6, Yaw: 225, Pitch: 0}, + {Order: 7, Yaw: 270, Pitch: 0}, + {Order: 8, Yaw: 315, Pitch: -8}, + }, + } +} + +// Shot is one original image captured on the phone. +type Shot struct { + ShotID string `json:"shotId"` + FilePath string `json:"filePath"` + CapturedAt time.Time `json:"capturedAt"` +} + +// Node is the minimum cloud stitching unit. +type Node struct { + NodeID string `json:"nodeId"` + Template CaptureTemplate `json:"template"` + Shots []Shot `json:"shots"` + CreatedAt time.Time `json:"createdAt"` +} + +func (n Node) Validate() error { + if err := n.Template.Validate(); err != nil { + return err + } + if len(n.Shots) > MaxShotsPerNode { + return ErrTooManyShots + } + return nil +} + +func (n Node) IsReadyForUpload() bool { + return len(n.Shots) == MaxShotsPerNode +} + +func (n *Node) AddShot(shot Shot) error { + if len(n.Shots) >= MaxShotsPerNode { + return ErrTooManyShots + } + n.Shots = append(n.Shots, shot) + return nil +} diff --git a/placevr/capture_test.go b/placevr/capture_test.go new file mode 100644 index 0000000..0b9a868 --- /dev/null +++ b/placevr/capture_test.go @@ -0,0 +1,36 @@ +package placevr + +import ( + "testing" + "time" +) + +func TestDefaultTemplateHasEightSlots(t *testing.T) { + tpl := DefaultTemplateV1() + if err := tpl.Validate(); err != nil { + t.Fatalf("template should be valid: %v", err) + } +} + +func TestNodeRejectsMoreThanEightShots(t *testing.T) { + node := Node{ + NodeID: "node-1", + Template: DefaultTemplateV1(), + CreatedAt: time.Now(), + } + + for i := 0; i < MaxShotsPerNode; i++ { + err := node.AddShot(Shot{ShotID: "s", FilePath: "/tmp/s.jpg", CapturedAt: time.Now()}) + if err != nil { + t.Fatalf("shot %d should be accepted: %v", i, err) + } + } + + if !node.IsReadyForUpload() { + t.Fatal("node should be ready after 8 shots") + } + + if err := node.AddShot(Shot{ShotID: "overflow", FilePath: "/tmp/o.jpg", CapturedAt: time.Now()}); err == nil { + t.Fatal("expected overflow error") + } +}