diff --git a/Makefile b/Makefile index bf92187..814e7d4 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ build: $(BINARIES) $(BINARIES): @echo "*** $@" - @cd cmd/$@ && go build $(LDFLAGS) -trimpath -o ../../bin/$@ + @cd cmd/$@ && CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries" go build $(LDFLAGS) -trimpath -o ../../bin/$@ run: @echo "*** $@" @@ -29,7 +29,7 @@ run: test: @echo "*** $@" - @go test ./... + @CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries" go test ./... bench: @echo "*** $@" diff --git a/README.md b/README.md index 95bc201..a5ee539 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,22 @@ # Point Cloud Viewer -![Gopher](images/gopher-small.png) +A simple point cloud viewer built with [Fyne](https://fyne.io/). This repository primarily offers a Fyne widget for viewing point clouds, but it also contains a standalone viewer. The standalone viewer + +Reads PLY, PCD, PTS, and XYZ files and renders them as interactive 3D point clouds with mouse-controlled rotation and zoom. [![Go Reference](https://pkg.go.dev/badge/github.com/borud/pointcloud.svg)](https://pkg.go.dev/github.com/borud/pointcloud) -A simple point cloud viewer built with [Fyne](https://fyne.io/) and OpenGL. Reads PLY, PCD, PTS, and XYZ files and renders them as interactive 3D point clouds with mouse-controlled rotation and zoom. +![Gopher](images/gopher-small.png) + +## Examples + +Viewing 970k point goat scull from from [Artec 3D](https://www.artec3d.com/). + +![Screenshot](images/goat.png) + +Simulated seabed from `_examples/seabed`. -![Screenshot](images/screenshot.png) +![Screenshot](images/seabed.png) ## Features diff --git a/_examples/seabed/README.md b/_examples/seabed/README.md new file mode 100644 index 0000000..df7983d --- /dev/null +++ b/_examples/seabed/README.md @@ -0,0 +1,18 @@ +# seabed + +Procedurally generated seabed terrain with bathymetric depth coloring. + + go run . + +Layered noise produces hills, a trench, scattered rocks, and sand ripples. +Color goes from dark blue in the deep parts to sandy tan at the peaks. +Every regeneration uses a new random seed so you get a different landscape +each time. + +## controls + +- Height -- adjusts terrain relief without regenerating. Crank it down + for a nearly flat ocean floor or up for exaggerated peaks. +- Points -- log-scale slider from 100K to 50M. Set the value and hit + Regenerate to rebuild at the new resolution. +- Regenerate -- new random terrain at the current point count. diff --git a/_examples/seabed/main.go b/_examples/seabed/main.go new file mode 100644 index 0000000..ba55eb9 --- /dev/null +++ b/_examples/seabed/main.go @@ -0,0 +1,349 @@ +// Package main generates a synthetic seabed point cloud and displays it. +// +// The terrain is built from layered Perlin-style noise to simulate +// rolling hills, ridges, a trench, scattered rocks, and sandy ripples. +// Points are colored by depth using a bathymetric palette. +package main + +import ( + "fmt" + "image/color" + "math" + "math/rand/v2" + "sync" + "sync/atomic" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + + "github.com/borud/pointcloud" +) + +const ( + noiseScale = 0.003 // measurement noise amplitude + worldHalf = 9.0 // half-extent of the XY world + + defaultHeight = 0.05 // default height scale — fairly flat + defaultPoints = 160000 + minPoints = 100000 + maxPoints = 50000000 +) + +// minWidthLayout enforces a minimum width on its single child. +type minWidthLayout struct { + minWidth float32 +} + +func newMinWidthLayout(w float32) *minWidthLayout { return &minWidthLayout{minWidth: w} } + +func (l *minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + if len(objects) == 0 { + return fyne.NewSize(l.minWidth, 0) + } + return fyne.NewSize(l.minWidth, objects[0].MinSize().Height) +} + +func (l *minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + for _, o := range objects { + o.Resize(size) + o.Move(fyne.NewPos(0, 0)) + } +} + +// seabedState holds the raw terrain heights so the height slider can +// rescale Z without regenerating the terrain. +type seabedState struct { + mu sync.Mutex + gridSize int + heights []float64 // raw heights, len == gridSize*gridSize + noiseZ []float64 // per-point noise, len == gridSize*gridSize + maxAbsZ float64 // max |height| at generation time +} + +// buildPoints maps raw heights to viewer coordinates. The heightScale +// controls how much of the [-0.9, 0.9] Z range the terrain actually +// occupies — low values produce a flat surface, 1.0 fills the range. +func (s *seabedState) buildPoints(heightScale float64) []pointcloud.Point3D { + s.mu.Lock() + defer s.mu.Unlock() + + n := len(s.heights) + if n == 0 { + return nil + } + gs := s.gridSize + + // Scale Z so that at heightScale=1.0 the full range maps to [-0.9, 0.9]. + // At lower values, the terrain occupies proportionally less Z range. + divisor := s.maxAbsZ + if divisor < 1e-12 { + divisor = 1 + } + + pts := make([]pointcloud.Point3D, n) + for iy := range gs { + for ix := range gs { + x := float64(ix)/float64(gs-1)*1.8 - 0.9 + y := float64(iy)/float64(gs-1)*1.8 - 0.9 + + idx := iy*gs + ix + // Map raw height to [-0.9, 0.9] scaled by heightScale. + zNorm := (s.heights[idx] / divisor) * 0.9 * heightScale + zNorm += s.noiseZ[idx] + + r, g, b := bathyColor(zNorm) + pts[idx] = pointcloud.Point3D{ + X: x, Y: y, Z: clamp(zNorm, -0.9, 0.9), + R: r, G: g, B: b, + HasColor: true, + } + } + } + return pts +} + +func main() { + myApp := app.NewWithID("no.borud.pointcloud.seabed") + myWindow := myApp.NewWindow("Seabed Point Cloud") + + viewer := pointcloud.New( + pointcloud.WithBackgroundColor(color.RGBA{5, 10, 30, 255}), + pointcloud.WithOrientationCube(true), + pointcloud.WithFPS(true), + pointcloud.WithMaxZoomOutFraction(0.25), + ) + viewer.SetUpAxis(pointcloud.ZUp) + + statusLabel := widget.NewLabel("Generating seabed...") + statusLabel.TextStyle = fyne.TextStyle{Monospace: true} + + var state seabedState + var generating atomic.Bool + heightScale := defaultHeight + numPoints := defaultPoints + + // --- Height slider: 0.01 – 1.0 --- + heightLabel := widget.NewLabel(fmt.Sprintf("Height: %.2f", heightScale)) + heightLabel.TextStyle = fyne.TextStyle{Monospace: true} + heightSlider := widget.NewSlider(0.01, 1.0) + heightSlider.Step = 0.01 + heightSlider.Value = heightScale + heightSlider.OnChanged = func(val float64) { + heightScale = val + heightLabel.SetText(fmt.Sprintf("Height: %.2f", val)) + pts := state.buildPoints(heightScale) + if pts != nil { + viewer.SetPointsPreserveView(pts) + } + } + + // --- Points slider: 100k – 50M (logarithmic) --- + logMin := math.Log10(float64(minPoints)) + logMax := math.Log10(float64(maxPoints)) + + pointsLabel := widget.NewLabel(fmt.Sprintf("Points: %s", formatCount(numPoints))) + pointsLabel.TextStyle = fyne.TextStyle{Monospace: true} + pointsSlider := widget.NewSlider(logMin, logMax) + pointsSlider.Step = 0.01 + pointsSlider.Value = math.Log10(float64(numPoints)) + pointsSlider.OnChanged = func(val float64) { + n := int(math.Round(math.Pow(10, val))) + numPoints = n + pointsLabel.SetText(fmt.Sprintf("Points: %s", formatCount(n))) + } + + generate := func() { + if !generating.CompareAndSwap(false, true) { + return + } + n := numPoints + fyne.Do(func() { + statusLabel.SetText(fmt.Sprintf("Generating %s points...", formatCount(n))) + }) + go func() { + defer generating.Store(false) + generateTerrain(&state, n) + pts := state.buildPoints(heightScale) + viewer.SetPoints(pts) + fyne.Do(func() { + statusLabel.SetText(fmt.Sprintf("Seabed — %s points", formatCount(len(pts)))) + }) + }() + } + + regenBtn := widget.NewButton("Regenerate", func() { generate() }) + + fpsCheck := widget.NewCheck("FPS", func(on bool) { + viewer.SetFPSEnabled(on) + }) + fpsCheck.SetChecked(true) + + heightSized := container.New(newMinWidthLayout(250), heightSlider) + pointsSized := container.New(newMinWidthLayout(250), pointsSlider) + + bottomBar := container.NewBorder( + nil, nil, + statusLabel, + container.NewHBox( + fpsCheck, + heightLabel, heightSized, + pointsLabel, pointsSized, + regenBtn, + ), + ) + + content := container.NewBorder( + nil, + container.New(layout.NewCustomPaddedLayout(4, 4, 8, 8), bottomBar), + nil, nil, + viewer, + ) + + myWindow.SetContent(content) + myWindow.Resize(fyne.NewSize(1100, 750)) + + generate() + + myWindow.ShowAndRun() +} + +// formatCount formats a number with K/M suffixes. +func formatCount(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%.0fK", float64(n)/1_000) + default: + return fmt.Sprintf("%d", n) + } +} + +// generateTerrain fills state with raw (unscaled) heights and noise. +func generateTerrain(state *seabedState, numPoints int) { + gs := int(math.Ceil(math.Sqrt(float64(numPoints)))) + if gs < 2 { + gs = 2 + } + total := gs * gs + + seed := rand.Uint64() + rng := rand.New(rand.NewPCG(seed, seed^0xdeadbeef)) + + type octave struct { + freqX, freqY, phase, amp float64 + } + octaves := []octave{ + {1.0, 1.0, rng.Float64() * 2 * math.Pi, 0.30}, + {2.3, 1.8, rng.Float64() * 2 * math.Pi, 0.15}, + {4.7, 5.1, rng.Float64() * 2 * math.Pi, 0.07}, + {9.3, 8.7, rng.Float64() * 2 * math.Pi, 0.03}, + {18.0, 20.0, rng.Float64() * 2 * math.Pi, 0.015}, + } + + type rock struct { + cx, cy, radius, height float64 + } + numRocks := 15 + rng.IntN(20) + rocks := make([]rock, numRocks) + for i := range rocks { + rocks[i] = rock{ + cx: rng.Float64()*16 - 8, + cy: rng.Float64()*16 - 8, + radius: 0.2 + rng.Float64()*0.6, + height: 0.03 + rng.Float64()*0.08, + } + } + + trenchPhase := rng.Float64() * 2 * math.Pi + trenchDir := rng.Float64()*0.6 - 0.3 + + heights := make([]float64, total) + noiseZ := make([]float64, total) + maxAbsZ := 0.0 + + for iy := range gs { + for ix := range gs { + x := float64(ix)/float64(gs-1)*2*worldHalf - worldHalf + y := float64(iy)/float64(gs-1)*2*worldHalf - worldHalf + + z := 0.0 + for _, o := range octaves { + z += o.amp * math.Sin(o.freqX*x*math.Pi/worldHalf+o.phase) * + math.Cos(o.freqY*y*math.Pi/worldHalf+o.phase*1.3) + } + + z += 0.05 * math.Sin(3.1*x*math.Pi/worldHalf+1.7*y*math.Pi/worldHalf+octaves[0].phase) + + trenchCenter := 1.5*math.Sin(2.0*y*math.Pi/(2*worldHalf)+trenchPhase) + trenchDir*y + trenchDist := math.Abs(x - trenchCenter) + trenchWidth := 1.2 + if trenchDist < trenchWidth { + depth := 0.20 * (1.0 - (trenchDist/trenchWidth)*(trenchDist/trenchWidth)) + z -= depth + } + + for _, r := range rocks { + dx := x - r.cx + dy := y - r.cy + d2 := (dx*dx + dy*dy) / (r.radius * r.radius) + if d2 < 9 { + z += r.height * math.Exp(-d2/2) + } + } + + idx := iy*gs + ix + heights[idx] = z + noiseZ[idx] = (rng.Float64() - 0.5) * noiseScale * 2 + + if abs := math.Abs(z); abs > maxAbsZ { + maxAbsZ = abs + } + } + } + + state.mu.Lock() + state.gridSize = gs + state.heights = heights + state.noiseZ = noiseZ + state.maxAbsZ = maxAbsZ + state.mu.Unlock() +} + +// bathyColor maps a normalized depth z in [-0.9, 0.9] to a bathymetric +// color scheme: deep blue -> medium blue -> teal -> sandy tan -> light sand. +func bathyColor(z float64) (r, g, b uint8) { + t := (z + 0.9) / 1.8 + t = clamp(t, 0, 1) + + switch { + case t < 0.2: + f := t / 0.2 + return uint8(15 + 20*f), uint8(10 + 30*f), uint8(60 + 60*f) + case t < 0.4: + f := (t - 0.2) / 0.2 + return uint8(35 + 10*f), uint8(40 + 50*f), uint8(120 + 40*f) + case t < 0.6: + f := (t - 0.4) / 0.2 + return uint8(45 + 30*f), uint8(90 + 60*f), uint8(160 - 20*f) + case t < 0.8: + f := (t - 0.6) / 0.2 + return uint8(75 + 80*f), uint8(150 + 40*f), uint8(140 - 50*f) + default: + f := (t - 0.8) / 0.2 + return uint8(155 + 60*f), uint8(190 + 30*f), uint8(90 + 40*f) + } +} + +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/_examples/streaming/README.md b/_examples/streaming/README.md new file mode 100644 index 0000000..64c8335 --- /dev/null +++ b/_examples/streaming/README.md @@ -0,0 +1,18 @@ +# streaming + +Simulated LIDAR sweep of two objects (a sphere and a box) with point decay. + + go run . + +A beam sweeps 360 degrees from the origin. Points appear where the beam +hits a surface and age out over time. The decay slider (0.1s-10s) controls +how quickly old points disappear -- turn it down and you'll see one object +fade as the beam moves to the other. + +This demonstrates streaming data into the viewer. It keeps a ring buffer of timestamped batches, flatten on each tick, and calls `SetPointsPreserveView()`. + +## files + +- `main.go` -- app setup, update loop, decay slider +- `buffer.go` -- sliding window buffer with time and count eviction +- `generator.go` -- LIDAR sim with ray-sphere/ray-box intersection diff --git a/_examples/streaming/buffer.go b/_examples/streaming/buffer.go new file mode 100644 index 0000000..0be9dd2 --- /dev/null +++ b/_examples/streaming/buffer.go @@ -0,0 +1,106 @@ +package main + +import ( + "sync" + "time" + + "github.com/borud/pointcloud" +) + +type batch struct { + points []pointcloud.Point3D + arrived time.Time +} + +// StreamBuffer maintains a sliding window of point batches with +// time-based and count-based eviction. +type StreamBuffer struct { + mu sync.Mutex + batches []batch + maxPoints int + maxAge time.Duration +} + +// NewStreamBuffer creates a buffer that evicts batches older than maxAge +// and trims oldest batches when total points exceed maxPoints. +func NewStreamBuffer(maxPoints int, maxAge time.Duration) *StreamBuffer { + return &StreamBuffer{ + maxPoints: maxPoints, + maxAge: maxAge, + } +} + +// SetMaxAge updates the maximum batch age. Safe for concurrent use. +func (b *StreamBuffer) SetMaxAge(d time.Duration) { + b.mu.Lock() + b.maxAge = d + b.evict() + b.mu.Unlock() +} + +// Add appends a new batch of points and evicts stale data. +func (b *StreamBuffer) Add(pts []pointcloud.Point3D) { + b.mu.Lock() + defer b.mu.Unlock() + + b.batches = append(b.batches, batch{ + points: pts, + arrived: time.Now(), + }) + b.evict() +} + +func (b *StreamBuffer) evict() { + now := time.Now() + + // Phase 1: remove batches older than maxAge. + cutoff := 0 + for cutoff < len(b.batches) && now.Sub(b.batches[cutoff].arrived) > b.maxAge { + cutoff++ + } + if cutoff > 0 { + b.batches = b.batches[cutoff:] + } + + // Phase 2: remove oldest batches until total points <= maxPoints. + total := 0 + for _, batch := range b.batches { + total += len(batch.points) + } + for len(b.batches) > 0 && total > b.maxPoints { + total -= len(b.batches[0].points) + b.batches = b.batches[1:] + } +} + +// Flatten concatenates all batch points into a single pre-allocated slice. +func (b *StreamBuffer) Flatten() []pointcloud.Point3D { + b.mu.Lock() + defer b.mu.Unlock() + + total := 0 + for _, batch := range b.batches { + total += len(batch.points) + } + + pts := make([]pointcloud.Point3D, 0, total) + for _, batch := range b.batches { + pts = append(pts, batch.points...) + } + return pts +} + +// Stats returns the current buffer state for display. +func (b *StreamBuffer) Stats() (batches, points int, oldestAge time.Duration) { + b.mu.Lock() + defer b.mu.Unlock() + + for _, batch := range b.batches { + points += len(batch.points) + } + batches = len(b.batches) + if batches > 0 { + oldestAge = time.Since(b.batches[0].arrived) + } + return +} diff --git a/_examples/streaming/generator.go b/_examples/streaming/generator.go new file mode 100644 index 0000000..4757362 --- /dev/null +++ b/_examples/streaming/generator.go @@ -0,0 +1,210 @@ +package main + +import ( + "math" + "math/rand/v2" + + "github.com/borud/pointcloud" +) + +// LidarGenerator simulates a LIDAR scanner at the origin that sweeps a +// beam continuously around 360°. Two objects float in space — a sphere +// and a box. Each Generate call advances the sweep and returns points +// only where the beam hits an object surface. Combined with the buffer's +// time-based eviction this produces the effect of one object fading as +// the beam sweeps past it toward the other. +type LidarGenerator struct { + azimuth float64 // current horizontal angle (radians), increases forever +} + +// NewLidarGenerator creates a new LIDAR sweep generator. +func NewLidarGenerator() *LidarGenerator { + return &LidarGenerator{} +} + +// Scene objects — positioned so the sweep spends time on each, then +// crosses empty space where nothing is hit and old points decay. +var ( + // Sphere: center and radius. + sphereCenter = [3]float64{0.45, 0.1, 0.0} + sphereRadius = 0.25 + + // Box: center, half-extents. + boxCenter = [3]float64{-0.35, -0.05, -0.25} + boxHalf = [3]float64{0.15, 0.2, 0.15} +) + +const ( + beamsPerColumn = 32 // vertical resolution + elevMin = -0.6 // vertical sweep range (radians) + elevMax = 0.6 + azStep = 0.008 // azimuth advance per column — slow sweep + maxRange = 1.5 + noiseAmount = 0.004 +) + +// Generate produces up to n points for the next sweep sector. +func (g *LidarGenerator) Generate(n int) []pointcloud.Point3D { + pts := make([]pointcloud.Point3D, 0, n) + + for len(pts) < n { + az := g.azimuth + g.azimuth += azStep + + cosAz := math.Cos(az) + sinAz := math.Sin(az) + + for beam := range beamsPerColumn { + if len(pts) >= n { + break + } + + elev := elevMin + (elevMax-elevMin)*float64(beam)/float64(beamsPerColumn-1) + cosEl := math.Cos(elev) + sinEl := math.Sin(elev) + + // Ray from origin. + dx := cosEl * cosAz + dy := sinEl + dz := cosEl * sinAz + + hit, dist := raycast(dx, dy, dz) + if !hit { + continue + } + + hx := dx*dist + (rand.Float64()-0.5)*noiseAmount + hy := dy*dist + (rand.Float64()-0.5)*noiseAmount + hz := dz*dist + (rand.Float64()-0.5)*noiseAmount + + r, gr, b := objectColor(hx, hy, hz) + pts = append(pts, pointcloud.Point3D{ + X: hx, Y: hy, Z: hz, + R: r, G: gr, B: b, + HasColor: true, + }) + } + } + return pts +} + +// raycast tests a ray from the origin against the sphere and box, +// returning whether it hit and the nearest distance. +func raycast(dx, dy, dz float64) (hit bool, dist float64) { + dist = maxRange + 1 + + if t, ok := raySphere(dx, dy, dz); ok && t < dist { + dist = t + hit = true + } + if t, ok := rayBox(dx, dy, dz); ok && t < dist { + dist = t + hit = true + } + return +} + +// raySphere intersects a ray from origin with the sphere. +func raySphere(dx, dy, dz float64) (float64, bool) { + // Ray: P = t*(dx,dy,dz), Sphere: |P - C|^2 = r^2 + ox := -sphereCenter[0] + oy := -sphereCenter[1] + oz := -sphereCenter[2] + + a := dx*dx + dy*dy + dz*dz + b := 2 * (ox*dx + oy*dy + oz*dz) + c := ox*ox + oy*oy + oz*oz - sphereRadius*sphereRadius + + disc := b*b - 4*a*c + if disc < 0 { + return 0, false + } + + sqrtDisc := math.Sqrt(disc) + t1 := (-b - sqrtDisc) / (2 * a) + t2 := (-b + sqrtDisc) / (2 * a) + + if t1 > 0.01 { + return t1, true + } + if t2 > 0.01 { + return t2, true + } + return 0, false +} + +// rayBox intersects a ray from origin with an axis-aligned box using slab method. +func rayBox(dx, dy, dz float64) (float64, bool) { + dir := [3]float64{dx, dy, dz} + bmin := [3]float64{ + boxCenter[0] - boxHalf[0], + boxCenter[1] - boxHalf[1], + boxCenter[2] - boxHalf[2], + } + bmax := [3]float64{ + boxCenter[0] + boxHalf[0], + boxCenter[1] + boxHalf[1], + boxCenter[2] + boxHalf[2], + } + + tmin := 0.0 + tmax := maxRange + + for i := range 3 { + if math.Abs(dir[i]) < 1e-12 { + // Ray parallel to slab — miss if origin outside. + if 0 < bmin[i] || 0 > bmax[i] { + return 0, false + } + continue + } + invD := 1.0 / dir[i] + t1 := bmin[i] * invD + t2 := bmax[i] * invD + if invD < 0 { + t1, t2 = t2, t1 + } + tmin = math.Max(tmin, t1) + tmax = math.Min(tmax, t2) + if tmin > tmax { + return 0, false + } + } + + if tmin > 0.01 { + return tmin, true + } + return 0, false +} + +// objectColor returns a color based on which object was hit. +// Sphere gets a warm orange/yellow gradient, box gets a cool blue/cyan. +func objectColor(x, y, z float64) (r, g, b uint8) { + // Check if point is closer to sphere or box. + sdx := x - sphereCenter[0] + sdy := y - sphereCenter[1] + sdz := z - sphereCenter[2] + sphereDist := math.Sqrt(sdx*sdx + sdy*sdy + sdz*sdz) + + if sphereDist < sphereRadius+0.05 { + // Sphere: warm gradient based on surface normal (latitude). + ny := sdy / sphereRadius + t := (ny + 1) / 2 // 0 at bottom, 1 at top + return uint8(200 + 55*t), uint8(120 + 100*t), uint8(30 + 40*t) + } + + // Box: cool gradient based on height. + t := (y - (boxCenter[1] - boxHalf[1])) / (2 * boxHalf[1]) + t = clamp(t, 0, 1) + return uint8(40 + 60*t), uint8(140 + 80*t), uint8(200 + 55*t) +} + +func clamp(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/_examples/streaming/main.go b/_examples/streaming/main.go new file mode 100644 index 0000000..9ef5822 --- /dev/null +++ b/_examples/streaming/main.go @@ -0,0 +1,135 @@ +// Package main demonstrates streaming point cloud data to the viewer widget. +// +// It maintains a sliding window buffer of timestamped batches and calls +// SetPointsPreserveView on each update — the idiomatic pattern for live data. +package main + +import ( + "context" + "fmt" + "image/color" + "time" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/app" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" + + "github.com/borud/pointcloud" +) + +// minWidthLayout is a Fyne layout that enforces a minimum width on its +// single child while using the child's natural height. +type minWidthLayout struct { + minWidth float32 +} + +func newMinWidthLayout(minWidth float32) *minWidthLayout { + return &minWidthLayout{minWidth: minWidth} +} + +func (l *minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + if len(objects) == 0 { + return fyne.NewSize(l.minWidth, 0) + } + return fyne.NewSize(l.minWidth, objects[0].MinSize().Height) +} + +func (l *minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + for _, o := range objects { + o.Resize(size) + o.Move(fyne.NewPos(0, 0)) + } +} + +const ( + batchSize = 500 + updateRate = 100 * time.Millisecond + maxPoints = 50_000 + defaultDecay = 4.0 // seconds +) + +func main() { + myApp := app.NewWithID("no.borud.pointcloud.streaming") + myWindow := myApp.NewWindow("Streaming Point Cloud Demo") + + viewer := pointcloud.New( + pointcloud.WithBackgroundColor(color.RGBA{15, 15, 25, 255}), + pointcloud.WithOrientationCube(true), + pointcloud.WithFPS(true), + pointcloud.WithMaxZoomOutFraction(0.25), + ) + + statusLabel := widget.NewLabel("Starting...") + statusLabel.TextStyle = fyne.TextStyle{Monospace: true} + + buf := NewStreamBuffer(maxPoints, time.Duration(defaultDecay*float64(time.Second))) + gen := NewLidarGenerator() + + // Decay slider: 0.1s – 10s. + decayLabel := widget.NewLabel(fmt.Sprintf("Decay: %.1fs", defaultDecay)) + decayLabel.TextStyle = fyne.TextStyle{Monospace: true} + decaySlider := widget.NewSlider(0.1, 10.0) + decaySlider.Step = 0.1 + decaySlider.Value = defaultDecay + decaySlider.OnChanged = func(val float64) { + decayLabel.SetText(fmt.Sprintf("Decay: %.1fs", val)) + buf.SetMaxAge(time.Duration(val * float64(time.Second))) + } + + // Use a spacer-based layout to give the slider a fixed width. + sliderSized := container.New(newMinWidthLayout(400), decaySlider) + + fpsCheck := widget.NewCheck("FPS", func(on bool) { + viewer.SetFPSEnabled(on) + }) + fpsCheck.SetChecked(true) + + bottomBar := container.NewBorder( + nil, nil, + statusLabel, + container.NewHBox(fpsCheck, decayLabel, sliderSized), + ) + + content := container.NewBorder( + nil, + container.New(layout.NewCustomPaddedLayout(4, 4, 8, 8), bottomBar), + nil, nil, + viewer, + ) + + ctx, cancel := context.WithCancel(context.Background()) + + go func() { + ticker := time.NewTicker(updateRate) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pts := gen.Generate(batchSize) + buf.Add(pts) + flat := buf.Flatten() + viewer.SetPointsPreserveView(flat) + + batches, points, oldest := buf.Stats() + text := fmt.Sprintf("Batches: %d | Points: %d | Oldest: %.1fs", + batches, points, oldest.Seconds()) + fyne.Do(func() { + statusLabel.SetText(text) + }) + } + } + }() + + myWindow.SetOnClosed(func() { + cancel() + }) + + myWindow.SetContent(content) + myWindow.Resize(fyne.NewSize(900, 700)) + myWindow.ShowAndRun() +} diff --git a/cmd/pointcloud/main.go b/cmd/pointcloud/main.go index 91e0e09..c5e6311 100644 --- a/cmd/pointcloud/main.go +++ b/cmd/pointcloud/main.go @@ -356,11 +356,7 @@ func main() { v := buildViewer() statusLabel := widget.NewLabel("No file loaded") - const pad float32 = 40 - viewerArea := container.New( - layout.NewCustomPaddedLayout(pad, pad, pad, pad), - v, - ) + viewerArea := container.NewStack(v) // onFlythroughChanged is set after flyCheck is created and used by // rebuildViewer to re-wire the callback on the new viewer. @@ -805,10 +801,21 @@ func main() { settingsScroll := container.NewVScroll(settingsContent) settingsScroll.SetMinSize(fyne.NewSize(240, 0)) - settingsPanel := container.New( - layout.NewCustomPaddedLayout(pad, pad, pad, pad), - settingsScroll, - ) + settingsPanel := container.NewStack(settingsScroll) + + settingsVisible := false + settingsPanel.Hide() + toggleSettings := func() { + settingsVisible = !settingsVisible + if settingsVisible { + settingsPanel.Show() + } else { + settingsPanel.Hide() + } + } + + toolbar.Append(widget.NewToolbarSeparator()) + toolbar.Append(widget.NewToolbarAction(theme.SettingsIcon(), toggleSettings)) top := container.NewBorder(nil, nil, nil, statusLabel, diff --git a/images/goat.png b/images/goat.png new file mode 100644 index 0000000..004f0cf Binary files /dev/null and b/images/goat.png differ diff --git a/images/screenshot.png b/images/screenshot.png deleted file mode 100644 index e19a46f..0000000 Binary files a/images/screenshot.png and /dev/null differ diff --git a/images/seabed.png b/images/seabed.png new file mode 100644 index 0000000..257cc10 Binary files /dev/null and b/images/seabed.png differ diff --git a/viewer.go b/viewer.go index eabce57..20efdb0 100644 --- a/viewer.go +++ b/viewer.go @@ -533,6 +533,21 @@ func (v *Viewer) SetLODTargetSize(n int) { v.canvas.mu.Unlock() } +// SetFPSEnabled shows or hides the FPS counter. The viewer must have been +// created with [WithFPS](true) for this to have any effect. +func (v *Viewer) SetFPSEnabled(on bool) { + if v.fpsLabel == nil { + return + } + fyne.Do(func() { + if on { + v.fpsLabel.Show() + } else { + v.fpsLabel.Hide() + } + }) +} + // SetFPSColor sets the FPS counter text color. func (v *Viewer) SetFPSColor(c color.RGBA) { if v.fpsLabel != nil {