Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module placevr

go 1.22
42 changes: 30 additions & 12 deletions main.go
Original file line number Diff line number Diff line change
@@ -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())
}
108 changes: 108 additions & 0 deletions placevr/capture.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions placevr/capture_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}