diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 69933f3..fc67ddd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,26 +54,22 @@ jobs: arch: amd64 goos: linux binary: pointcloud - convert-binary: xyz-convert cc: gcc - os: ubuntu-latest arch: arm64 goos: linux binary: pointcloud - convert-binary: xyz-convert cc: aarch64-linux-gnu-gcc goarch: arm64 - os: macos-latest arch: arm64 goos: darwin binary: pointcloud - convert-binary: xyz-convert cc: "" - os: windows-latest arch: amd64 goos: windows binary: pointcloud.exe - convert-binary: xyz-convert.exe cc: "" runs-on: ${{ matrix.os }} @@ -130,17 +126,6 @@ jobs: go build -ldflags "-X main.Version=${{ steps.version.outputs.version }}" \ -o bin/${{ matrix.binary }} . - - name: Build xyz-convert - shell: bash - env: - CGO_ENABLED: "1" - GOARCH: ${{ matrix.goarch || '' }} - CC: ${{ matrix.cc || '' }} - PKG_CONFIG_PATH: ${{ matrix.arch == 'arm64' && runner.os == 'Linux' && '/usr/lib/aarch64-linux-gnu/pkgconfig' || '' }} - run: | - go build -ldflags "-X main.Version=${{ steps.version.outputs.version }}" \ - -o bin/${{ matrix.convert-binary }} ./cmd/xyz-convert - - name: Create archive shell: bash run: | diff --git a/Makefile b/Makefile index 6edb38c..bf92187 100644 --- a/Makefile +++ b/Makefile @@ -17,9 +17,11 @@ BINARIES := $(notdir $(shell find cmd -mindepth 1 -maxdepth 1 -type d)) all: lint vet staticcheck test build +build: $(BINARIES) + $(BINARIES): @echo "*** $@" - @cd cmd/$@ && CGO_ENABLED=0 go build $(LDFLAGS) -trimpath -o ../../bin/$@ + @cd cmd/$@ && go build $(LDFLAGS) -trimpath -o ../../bin/$@ run: @echo "*** $@" diff --git a/README.md b/README.md index ffa6859..95bc201 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Point Cloud Viewer +![Gopher](images/gopher-small.png) + [![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. -![Screenshot](screenshot.png) +![Screenshot](images/screenshot.png) ## Features @@ -21,10 +23,24 @@ A simple point cloud viewer built with [Fyne](https://fyne.io/) and OpenGL. Read ## Installation +### Library + +To use the viewer widget in your own Fyne application: + ```sh go get github.com/borud/pointcloud ``` +### Demo application + +Pre-built binaries for Linux (amd64, arm64), macOS (arm64), and Windows (amd64) are available on the [releases page](https://github.com/borud/pointcloud/releases). + +Or build from source: + +```sh +go install github.com/borud/pointcloud/cmd/pointcloud@latest +``` + ## Using the widget The `Viewer` is a standard Fyne widget that you can embed in any Fyne application. It uses a functional options pattern for configuration. @@ -35,28 +51,28 @@ The `Viewer` is a standard Fyne widget that you can embed in any Fyne applicatio package main import ( - "log" + "log" - "fyne.io/fyne/v2/app" - "github.com/borud/pointcloud" + "fyne.io/fyne/v2/app" + "github.com/borud/pointcloud" ) func main() { - myApp := app.New() - w := myApp.NewWindow("Point Cloud") + myApp := app.New() + w := myApp.NewWindow("Point Cloud") - v := pointcloud.New() + v := pointcloud.New() - pc, err := pointcloud.ReadFile("model.ply") - if err != nil { - log.Fatal(err) - } - pc.Normalize() - v.SetScale(pc.NormScale) - v.SetPoints(pc.Points) + pc, err := pointcloud.ReadFile("model.ply") + if err != nil { + log.Fatal(err) + } + pc.Normalize() + v.SetScale(pc.NormScale) + v.SetPoints(pc.Points) - w.SetContent(v) - w.ShowAndRun() + w.SetContent(v) + w.ShowAndRun() } ``` @@ -66,16 +82,16 @@ Use functional options to customize appearance when creating the viewer. ```go v := pointcloud.New( - pointcloud.WithBackgroundColor(color.RGBA{30, 30, 30, 255}), - pointcloud.WithDefaultPointColor(color.RGBA{255, 150, 255, 255}), - pointcloud.WithOrientationCube(true), - pointcloud.WithHomeButton(true), - pointcloud.WithZoomFitButton(true), - pointcloud.WithInfoLabel(true), - pointcloud.WithScaleBar(true), - pointcloud.WithScaleUnit("m"), - pointcloud.WithFPS(true), - pointcloud.WithMaxZoomOutFraction(0.3), + pointcloud.WithBackgroundColor(color.RGBA{30, 30, 30, 255}), + pointcloud.WithDefaultPointColor(color.RGBA{255, 150, 255, 255}), + pointcloud.WithOrientationCube(true), + pointcloud.WithHomeButton(true), + pointcloud.WithZoomFitButton(true), + pointcloud.WithInfoLabel(true), + pointcloud.WithScaleBar(true), + pointcloud.WithScaleUnit("m"), + pointcloud.WithFPS(true), + pointcloud.WithMaxZoomOutFraction(0.3), ) v.SetUpAxis(pointcloud.ZUp) ``` @@ -102,12 +118,12 @@ You can construct a `PointCloud` programmatically instead of reading from a file ```go pc := &pointcloud.PointCloud{ - Points: []pointcloud.Point3D{ - {X: 0, Y: 0, Z: 0, R: 255, G: 0, B: 0, HasColor: true}, - {X: 1, Y: 0, Z: 0, R: 0, G: 255, B: 0, HasColor: true}, - {X: 0, Y: 1, Z: 0, R: 0, G: 0, B: 255, HasColor: true}, - {X: 0, Y: 0, Z: 1, R: 255, G: 255, B: 0, HasColor: true}, - }, + Points: []pointcloud.Point3D{ + {X: 0, Y: 0, Z: 0, R: 255, G: 0, B: 0, HasColor: true}, + {X: 1, Y: 0, Z: 0, R: 0, G: 255, B: 0, HasColor: true}, + {X: 0, Y: 1, Z: 0, R: 0, G: 0, B: 255, HasColor: true}, + {X: 0, Y: 0, Z: 1, R: 255, G: 255, B: 0, HasColor: true}, + }, } pc.ComputeBounds() pc.Normalize() @@ -151,6 +167,44 @@ defer f.Close() pointcloud.WritePLY(f, pc) ``` +## Controls + +The viewer has two camera modes: **orbit** (default) and **flythrough** (first-person). Toggle between them with the `g` key or the eye button in the toolbar. + +### Orbit mode + +| Input | Action | +|---|---| +| Drag | Arcball rotation | +| Shift+Drag | Pan | +| Scroll wheel | Zoom in/out | +| Click | Pick nearest point (shows coordinates in info label) | +| `h` | Reset to home view | +| `f` | Zoom to fit all points | +| `+` / `-` | Zoom in / out | +| Arrow keys | Rotate in 5-degree steps | +| `g` | Enter flythrough mode | + +### Flythrough mode + +In flythrough mode the camera moves freely through the point cloud using FPS-style controls. + +| Input | Action | +|---|---| +| `W` / `S` or Up / Down | Move forward / backward | +| `A` / `D` or Left / Right | Strafe left / right | +| Space | Move up (world space) | +| `Q` | Move down (world space) | +| Drag | Mouse look | +| Scroll wheel | Adjust movement speed | +| `g` | Return to orbit mode | +| Escape | Return to orbit mode | +| `h` | Return to orbit mode and reset home view | + +### Orientation cube + +Click a face, edge midpoint, or corner of the orientation cube to snap the view to that direction. + ## Benchmarking Install [benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat) for comparing results: diff --git a/bench_test.go b/bench_test.go index 439f53a..3f19b15 100644 --- a/bench_test.go +++ b/bench_test.go @@ -84,6 +84,23 @@ func BenchmarkClear_1080p(b *testing.B) { } } +// BenchmarkDraw_Flythrough_1M measures draw with flythrough camera inside cloud. +func BenchmarkDraw_Flythrough_1M(b *testing.B) { + pts := generatePoints(1_000_000) + c := setupCanvas(pts) + c.flyMode = true + c.fly = newFlythroughCamera(c) + c.fly.pos = [3]float64{0, 0, 0} // camera at center of cloud + c.fly.orientation = QuatIdentity() + w, h := 1024, 768 + + b.ReportAllocs() + b.ResetTimer() + for range b.N { + c.draw(w, h) + } +} + // BenchmarkProjection_1M measures only the projection math (no pixel writes). func BenchmarkProjection_1M(b *testing.B) { pts := generatePoints(1_000_000) diff --git a/canvas3d.go b/canvas3d.go index fbc5cf3..531487f 100644 --- a/canvas3d.go +++ b/canvas3d.go @@ -43,6 +43,12 @@ type canvas3d struct { homeOrientation Quat maxZoomOutFraction float64 + // Flythrough mode. + flyMode bool + fly *flythroughCamera + grid *spatialGrid + onFlythroughChanged func(bool) + // Original points kept for Tapped to return the original Point3D. points []Point3D @@ -80,6 +86,7 @@ type canvas3d struct { onOrientationChanged func() onHomeView func() onZoomChanged func() + onSpeedChanged func(multiple float64) onPointTapped func(p Point3D, screenX, screenY float64) onPointCleared func() onFrameDrawn func(d time.Duration) // called at end of draw with render time @@ -155,6 +162,9 @@ func (c *canvas3d) convertToSoA() { } } + // Build spatial grid for flythrough frustum culling. + c.grid, c.xs, c.ys, c.zs, c.rgba = buildGrid(c.xs, c.ys, c.zs, c.rgba) + c.buildLOD() } @@ -223,6 +233,17 @@ func (c *canvas3d) startInteraction() { } func (c *canvas3d) zoomToExtents() { + if c.flyMode && c.fly != nil { + // In flythrough mode, move camera to see the full cloud. + // Place camera at (0, 0, 4) looking toward origin — the same + // implicit position as orbit mode's default. + c.fly.mu.Lock() + c.fly.pos = c.fly.orientation.RotateVec3([3]float64{0, 0, 4.0}) + c.fly.mu.Unlock() + fyne.Do(func() { c.raster.Refresh() }) + return + } + size := c.Size() w, h := float64(size.Width), float64(size.Height) if w < 1 || h < 1 { @@ -251,6 +272,17 @@ func (c *canvas3d) fireZoomChanged() { } func (c *canvas3d) homeView() { + if c.flyMode && c.fly != nil { + // In flythrough mode, reset orientation and move camera to + // the home view position without leaving flythrough. + c.fly.mu.Lock() + c.fly.orientation = c.homeOrientation + c.fly.pos = c.homeOrientation.RotateVec3([3]float64{0, 0, 4.0}) + c.fly.mu.Unlock() + fyne.Do(func() { c.raster.Refresh() }) + return + } + c.mu.Lock() c.orientation = c.homeOrientation c.matrixDirty = true @@ -287,11 +319,14 @@ func (c *canvas3d) draw(w, h int) image.Image { bg := c.bgColor // Use LOD arrays during interaction for responsive frame rates. + // Use LOD arrays during interaction for responsive frame rates. + // In flythrough mode, skip LOD — the grid-reordered full arrays + // are needed and frustum culling provides the perf benefit instead. xs := c.xs ys := c.ys zs := c.zs rgba := c.rgba - if c.dragging && c.xsLOD != nil { + if c.dragging && c.xsLOD != nil && !c.flyMode { xs = c.xsLOD ys = c.ysLOD zs = c.zsLOD @@ -347,9 +382,31 @@ func (c *canvas3d) draw(w, h int) image.Image { nWorkers = 1 } + // Compute camera translation. In orbit mode these are the fixed values + // that produce identical output to the old `dist = 4.0 - rz` formula. + // In flythrough mode, they encode the actual camera transform. + var txCam, tyCam, tzCam float32 + if c.flyMode && c.fly != nil { + var vm [9]float64 + var vtx, vty, vtz float64 + vm, vtx, vty, vtz = c.fly.viewMatrix() + // Replace the rotation matrix with the flythrough view matrix. + m0, m1, m2 = float32(vm[0]), float32(vm[1]), float32(vm[2]) + m3, m4, m5 = float32(vm[3]), float32(vm[4]), float32(vm[5]) + m6, m7, m8 = float32(vm[6]), float32(vm[7]), float32(vm[8]) + txCam = float32(vtx) + tyCam = float32(vty) + tzCam = float32(vtz) + } else { + txCam = 0 + tyCam = 0 + tzCam = 4.0 + } + if nWorkers <= 1 { projectChunk(xs, ys, zs, rgba, pix, stride, w, h, m0, m1, m2, m3, m4, m5, m6, m7, m8, + txCam, tyCam, tzCam, zoomF, centerX, centerY, defR, defG, defB) } else { var wg sync.WaitGroup @@ -366,6 +423,7 @@ func (c *canvas3d) draw(w, h int) image.Image { projectChunk(xs[lo:hi], ys[lo:hi], zs[lo:hi], rgba[lo:hi], pix, stride, w, h, m0, m1, m2, m3, m4, m5, m6, m7, m8, + txCam, tyCam, tzCam, zoomF, centerX, centerY, defR, defG, defB) }() } @@ -381,9 +439,15 @@ func (c *canvas3d) draw(w, h int) image.Image { // projectChunk projects a contiguous slice of points and writes pixels to the // shared framebuffer. Called from one goroutine per chunk during parallel draw. +// +// The tx, ty, tz parameters encode the camera translation. In orbit mode +// these are (0, 0, 4.0), which produces the same result as the original +// `dist = 4.0 - rz` formula. In flythrough mode they encode the full +// camera transform. func projectChunk( xs, ys, zs []float32, rgba []uint32, pix []byte, stride, w, h int, m0, m1, m2, m3, m4, m5, m6, m7, m8 float32, + tx, ty, tz float32, zoomF, centerX, centerY float32, defR, defG, defB float32, ) { @@ -391,11 +455,11 @@ func projectChunk( py := ys[i] pz := zs[i] - rx := m0*px + m1*py + m2*pz - ry := m3*px + m4*py + m5*pz + rx := m0*px + m1*py + m2*pz + tx + ry := m3*px + m4*py + m5*pz + ty rz := m6*px + m7*py + m8*pz - dist := 4.0 - rz + dist := tz - rz if dist < 0.1 { continue } @@ -411,8 +475,9 @@ func projectChunk( off := iy*stride + ix*4 packed := rgba[i] - // Depth-based shading: clamp shade to [0.3, 1.0]. - shade := 1.0 - rz*0.15 + // Depth-based shading: use camera-space depth for consistent + // shading in both orbit and flythrough modes. + shade := 1.0 - (tz-dist)*0.15 if shade < 0.3 { shade = 0.3 } else if shade > 1.0 { @@ -465,6 +530,14 @@ func (c *canvas3d) MouseUp(_ *desktop.MouseEvent) { // Dragged implements fyne.Draggable. func (c *canvas3d) Dragged(ev *fyne.DragEvent) { c.startInteraction() + + // In flythrough mode, dragging controls the look direction. + if c.flyMode && c.fly != nil { + c.fly.handleMouseLook(float64(ev.Dragged.DX), float64(ev.Dragged.DY)) + c.raster.Refresh() + return + } + panning := c.dragModifier&fyne.KeyModifierShift != 0 if panning { c.mu.Lock() @@ -512,6 +585,25 @@ func (c *canvas3d) DragEnd() {} // Scrolled implements fyne.Scrollable. func (c *canvas3d) Scrolled(ev *fyne.ScrollEvent) { c.startInteraction() + + // In flythrough mode, scroll adjusts movement speed multiplier. + if c.flyMode && c.fly != nil { + c.fly.mu.Lock() + c.fly.speedMultiple *= 1.0 + float64(ev.Scrolled.DY)*0.02 + if c.fly.speedMultiple < 0.25 { + c.fly.speedMultiple = 0.25 + } + if c.fly.speedMultiple > 16.0 { + c.fly.speedMultiple = 16.0 + } + mult := c.fly.speedMultiple + c.fly.mu.Unlock() + if c.onSpeedChanged != nil { + c.onSpeedChanged(mult) + } + return + } + c.mu.Lock() c.zoom *= 1.0 + float64(ev.Scrolled.DY)*0.02 if mz := c.minZoom(); c.zoom < mz { @@ -553,11 +645,24 @@ func (c *canvas3d) TypedRune(r rune) { } case 'f': c.zoomToExtents() + case 'g': + c.setFlythrough(!c.flyMode) } } // TypedKey implements fyne.Focusable. func (c *canvas3d) TypedKey(ev *fyne.KeyEvent) { + // Esc exits flythrough mode. + if ev.Name == fyne.KeyEscape && c.flyMode { + c.setFlythrough(false) + return + } + + // In flythrough mode, arrow keys are handled by KeyDown/KeyUp. + if c.flyMode { + return + } + const angle = 0.087 // ~5 degrees var dq Quat switch ev.Name { @@ -582,6 +687,95 @@ func (c *canvas3d) TypedKey(ev *fyne.KeyEvent) { } } +// KeyDown implements desktop.Keyable — tracks held keys for flythrough movement. +func (c *canvas3d) KeyDown(ev *fyne.KeyEvent) { + if !c.flyMode || c.fly == nil { + return + } + c.fly.mu.Lock() + if ev.Name == desktop.KeyShiftLeft || ev.Name == desktop.KeyShiftRight { + c.fly.shiftHeld = true + } else { + c.fly.keysHeld[ev.Name] = true + } + c.fly.mu.Unlock() + + // Start the ticker if not already running. + if c.fly.hasKeysHeld() { + c.fly.start() + } +} + +// KeyUp implements desktop.Keyable — releases held keys. +func (c *canvas3d) KeyUp(ev *fyne.KeyEvent) { + if !c.flyMode || c.fly == nil { + return + } + c.fly.mu.Lock() + if ev.Name == desktop.KeyShiftLeft || ev.Name == desktop.KeyShiftRight { + c.fly.shiftHeld = false + } else { + delete(c.fly.keysHeld, ev.Name) + } + c.fly.mu.Unlock() + + // Stop the ticker when no keys are held. + if !c.fly.hasKeysHeld() { + c.fly.stop() + } +} + +// setFlythrough toggles flythrough mode on or off. +func (c *canvas3d) setFlythrough(on bool) { + if c.flyMode == on { + return + } + + if on { + // Orbit → Flythrough transition. + c.fly = newFlythroughCamera(c) + c.mu.Lock() + c.fly.fromOrbit(c.orientation, c.zoom, c.panX, c.panY) + // Pan is now baked into the camera position. + c.panX = 0 + c.panY = 0 + c.flyMode = true + c.mu.Unlock() + } else { + // Flythrough → Orbit transition. + if c.fly != nil { + c.fly.stop() + c.mu.Lock() + orient, zoom := c.fly.toOrbit(c.zoom) + c.orientation = orient + c.matrixDirty = true + c.zoom = zoom + if mz := c.minZoom(); c.zoom < mz { + c.zoom = mz + } + c.panX = 0 + c.panY = 0 + c.flyMode = false + c.mu.Unlock() + } else { + c.mu.Lock() + c.flyMode = false + c.mu.Unlock() + } + } + + c.raster.Refresh() + if c.onOrientationChanged != nil { + c.onOrientationChanged() + } + if c.onFlythroughChanged != nil { + c.onFlythroughChanged(on) + } +} + +// Compile-time checks for interface implementations. +var _ desktop.Keyable = (*canvas3d)(nil) + // Tapped implements fyne.Tappable — picks the nearest point to the click. func (c *canvas3d) Tapped(ev *fyne.PointEvent) { if c.onPointTapped == nil { @@ -623,6 +817,21 @@ func (c *canvas3d) Tapped(ev *fyne.PointEvent) { m3, m4, m5 := float32(m64[3]), float32(m64[4]), float32(m64[5]) m6, m7, m8 := float32(m64[6]), float32(m64[7]), float32(m64[8]) + var txCam, tyCam, tzCam float32 + if c.flyMode && c.fly != nil { + vm, vtx, vty, vtz := c.fly.viewMatrix() + m0, m1, m2 = float32(vm[0]), float32(vm[1]), float32(vm[2]) + m3, m4, m5 = float32(vm[3]), float32(vm[4]), float32(vm[5]) + m6, m7, m8 = float32(vm[6]), float32(vm[7]), float32(vm[8]) + txCam = float32(vtx) + tyCam = float32(vty) + tzCam = float32(vtz) + } else { + txCam = 0 + tyCam = 0 + tzCam = 4.0 + } + centerX := float32(pixW)/2 + float32(panX)*float32(scaleX) centerY := float32(pixH)/2 + float32(panY)*float32(scaleY) @@ -635,11 +844,11 @@ func (c *canvas3d) Tapped(ev *fyne.PointEvent) { py := ys[i] pz := zs[i] - rx := m0*px + m1*py + m2*pz - ry := m3*px + m4*py + m5*pz + rx := m0*px + m1*py + m2*pz + txCam + ry := m3*px + m4*py + m5*pz + tyCam rz := m6*px + m7*py + m8*pz - dist := float32(4.0) - rz + dist := tzCam - rz if dist < 0.1 { continue } diff --git a/cmd/pointcloud/main.go b/cmd/pointcloud/main.go index ffcdf9e..91e0e09 100644 --- a/cmd/pointcloud/main.go +++ b/cmd/pointcloud/main.go @@ -362,6 +362,10 @@ func main() { v, ) + // onFlythroughChanged is set after flyCheck is created and used by + // rebuildViewer to re-wire the callback on the new viewer. + var onFlythroughChanged func(bool) + // rebuildViewer replaces the viewer in the layout, preserving the // current orientation, zoom, and pan. rebuildViewer := func() { @@ -375,6 +379,11 @@ func main() { v.SetZoom(oldZoom) v.SetPan(oldPanX, oldPanY) + // Re-wire flythrough callback to sync the checkbox. + if onFlythroughChanged != nil { + v.OnFlythroughChanged = onFlythroughChanged + } + viewerArea.Objects[0] = v viewerArea.Refresh() } @@ -623,10 +632,22 @@ func main() { }) lodCheck.SetChecked(lodEnabled) + flyCheck := widget.NewCheck("Flythrough mode (G)", func(on bool) { + v.SetFlythrough(on) + }) + flyCheck.SetChecked(false) + + // Sync checkbox when flythrough is toggled via keyboard ('G' / Esc). + onFlythroughChanged = func(on bool) { + fyne.Do(func() { flyCheck.SetChecked(on) }) + } + v.OnFlythroughChanged = onFlythroughChanged + renderSection := widget.NewCard("Rendering", "", container.NewVBox( withTooltip(zupCheck, "Treat Z as up axis (typical for LiDAR and surveying data)"), withTooltip(lodCheck, "Reduce point count during mouse interaction for faster frame rates"), + withTooltip(flyCheck, "First-person camera: WASD to move, mouse to look, scroll to adjust speed"), ), ) @@ -735,6 +756,7 @@ func main() { fpsFontSelect.SetSelected(nameFromStyle(fpsStyle)) zupCheck.SetChecked(true) lodCheck.SetChecked(false) + flyCheck.SetChecked(false) rebuildViewer() }) diff --git a/flythrough.go b/flythrough.go new file mode 100644 index 0000000..f4d685e --- /dev/null +++ b/flythrough.go @@ -0,0 +1,267 @@ +package pointcloud + +import ( + "math" + "sync" + "time" + + "fyne.io/fyne/v2" +) + +// flythroughBaseSpeed is the default movement speed in normalized units per tick. +const flythroughBaseSpeed = 0.08 + +// flythroughCamera implements a first-person camera for flying through +// a point cloud with WASD+mouse controls. +type flythroughCamera struct { + mu sync.Mutex + pos [3]float64 // camera position in normalized space + orientation Quat // camera look direction + speedMultiple float64 // speed as multiplier of base speed (1.0 = 1x) + shiftHeld bool // when true, WASD/arrows rotate instead of move + keysHeld map[fyne.KeyName]bool + ticker *time.Ticker + canvas *canvas3d // back-reference for refresh +} + +func newFlythroughCamera(c *canvas3d) *flythroughCamera { + return &flythroughCamera{ + orientation: QuatIdentity(), + speedMultiple: 1.0, + keysHeld: make(map[fyne.KeyName]bool), + canvas: c, + } +} + +// start begins the movement ticker goroutine. +func (f *flythroughCamera) start() { + if f.ticker != nil { + return + } + f.ticker = time.NewTicker(16 * time.Millisecond) + go func() { + for range f.ticker.C { + if f.tick(0.016) { + f.canvas.startInteraction() + fyne.Do(func() { + f.canvas.raster.Refresh() + }) + } + } + }() +} + +// stop stops the movement ticker. +func (f *flythroughCamera) stop() { + if f.ticker != nil { + f.ticker.Stop() + f.ticker = nil + } +} + +// hasKeysHeld returns true if any movement keys are currently held. +func (f *flythroughCamera) hasKeysHeld() bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, held := range f.keysHeld { + if held { + return true + } + } + return false +} + +// tick updates the camera based on held keys. With shift held, WASD/arrows +// rotate the camera (pitch/roll). Without shift, they move. Returns true +// if anything changed. +func (f *flythroughCamera) tick(dt float64) bool { + f.mu.Lock() + defer f.mu.Unlock() + + if f.shiftHeld { + return f.tickRotate(dt) + } + return f.tickMove(dt) +} + +// tickMove handles translation. Must be called with f.mu held. +func (f *flythroughCamera) tickMove(dt float64) bool { + var dx, dy, dz float64 + if f.keysHeld[fyne.KeyW] || f.keysHeld[fyne.KeyUp] { + dz++ + } + if f.keysHeld[fyne.KeyS] || f.keysHeld[fyne.KeyDown] { + dz-- + } + if f.keysHeld[fyne.KeyA] || f.keysHeld[fyne.KeyLeft] { + dx-- + } + if f.keysHeld[fyne.KeyD] || f.keysHeld[fyne.KeyRight] { + dx++ + } + if f.keysHeld[fyne.KeySpace] { + dy++ + } + if f.keysHeld[fyne.KeyQ] { + dy-- + } + + if dx == 0 && dy == 0 && dz == 0 { + return false + } + + length := math.Sqrt(dx*dx + dy*dy + dz*dz) + if length > 0 { + dx /= length + dy /= length + dz /= length + } + + speed := flythroughBaseSpeed * f.speedMultiple * dt * 60 + + right := f.orientation.RotateVec3([3]float64{1, 0, 0}) + up := [3]float64{0, 1, 0} + forward := f.orientation.RotateVec3([3]float64{0, 0, -1}) + + f.pos[0] += (right[0]*dx + up[0]*dy + forward[0]*dz) * speed + f.pos[1] += (right[1]*dx + up[1]*dy + forward[1]*dz) * speed + f.pos[2] += (right[2]*dx + up[2]*dy + forward[2]*dz) * speed + + return true +} + +const rotateRate = 1.5 // radians per second + +// tickRotate handles pitch and roll. Must be called with f.mu held. +func (f *flythroughCamera) tickRotate(dt float64) bool { + var pitch, roll float64 + + // W/S and Up/Down: pitch (rotate around camera-local X). + if f.keysHeld[fyne.KeyW] || f.keysHeld[fyne.KeyUp] { + pitch-- + } + if f.keysHeld[fyne.KeyS] || f.keysHeld[fyne.KeyDown] { + pitch++ + } + + // A/D and Left/Right: roll (rotate around camera-local Z). + if f.keysHeld[fyne.KeyA] || f.keysHeld[fyne.KeyLeft] { + roll++ + } + if f.keysHeld[fyne.KeyD] || f.keysHeld[fyne.KeyRight] { + roll-- + } + + if pitch == 0 && roll == 0 { + return false + } + + angle := rotateRate * dt + if pitch != 0 { + q := QuatFromAxisAngle(1, 0, 0, pitch*angle) + f.orientation = f.orientation.Mul(q).Normalize() + } + if roll != 0 { + q := QuatFromAxisAngle(0, 0, 1, roll*angle) + f.orientation = f.orientation.Mul(q).Normalize() + } + + return true +} + +// handleMouseLook applies yaw (around world Y) and pitch (around local X). +func (f *flythroughCamera) handleMouseLook(dx, dy float64) { + const sensitivity = 0.003 + + f.mu.Lock() + defer f.mu.Unlock() + + // Yaw around world Y axis. + yaw := QuatFromAxisAngle(0, 1, 0, -dx*sensitivity) + // Pitch around camera-local X axis. + pitch := QuatFromAxisAngle(1, 0, 0, -dy*sensitivity) + + f.orientation = yaw.Mul(f.orientation).Mul(pitch).Normalize() +} + +// viewMatrix returns the view rotation matrix and translation for projectChunk. +func (f *flythroughCamera) viewMatrix() (m [9]float64, tx, ty, tz float64) { + f.mu.Lock() + pos := f.pos + orient := f.orientation + f.mu.Unlock() + + // The view matrix is the inverse of the camera transform. + // For a rotation quaternion, the inverse is the conjugate. + invOrient := orient.Conjugate() + m = invOrient.ToMatrix() + + // Translate to camera space: multiply position by inverse rotation. + // tx/ty are -(R^T * pos) and get added to rx/ry in the inner loop. + // tz uses the positive sign because the inner loop computes + // dist = tz - rz (not tz + rz), so the sign is already flipped. + tx = -(m[0]*pos[0] + m[1]*pos[1] + m[2]*pos[2]) + ty = -(m[3]*pos[0] + m[4]*pos[1] + m[5]*pos[2]) + tz = m[6]*pos[0] + m[7]*pos[1] + m[8]*pos[2] + + return m, tx, ty, tz +} + +// fromOrbit sets the flythrough camera state from the current orbit state. +// +// Key insight: orbit rotates points by orientation.ToMatrix(), while +// viewMatrix() returns orientation.Conjugate().ToMatrix(). So the flythrough +// orientation must be the conjugate of the orbit orientation to produce the +// same rotation matrix — and thus the same view. +// +// With the correct orientation, placing the camera at +// flyOrientation.RotateVec3({0,0,4.0}) produces tx=0, ty=0, tz=4.0 in the +// view matrix, exactly matching orbit's implicit camera. Zoom (c.zoom) stays +// unchanged since both modes use it identically. +func (f *flythroughCamera) fromOrbit(orientation Quat, zoom float64, panX, panY float64) { + f.mu.Lock() + defer f.mu.Unlock() + + f.orientation = orientation.Conjugate() + + // Place camera so viewMatrix returns tx=0, ty=0, tz=4.0. + f.pos = f.orientation.RotateVec3([3]float64{0, 0, 4.0}) + + // Bake orbit pan into camera position as a lateral offset. + // Pan is in DIP; at center depth the world offset is pan * tz / zoom. + if (panX != 0 || panY != 0) && zoom > 0 { + right := f.orientation.RotateVec3([3]float64{1, 0, 0}) + up := f.orientation.RotateVec3([3]float64{0, 1, 0}) + wx := -panX * 4.0 / zoom + wy := -panY * 4.0 / zoom + f.pos[0] += right[0]*wx + up[0]*wy + f.pos[1] += right[1]*wx + up[1]*wy + f.pos[2] += right[2]*wx + up[2]*wy + } + + f.speedMultiple = 1.0 +} + +// toOrbit extracts orbit parameters from the flythrough state. +// currentZoom is the unchanged c.zoom value. +// Returns orbit orientation and adjusted zoom. Pan is set to zero since +// any lateral offset is encoded in the camera position. +func (f *flythroughCamera) toOrbit(currentZoom float64) (Quat, float64) { + f.mu.Lock() + defer f.mu.Unlock() + + // Orbit orientation is the conjugate of flythrough orientation. + orient := f.orientation.Conjugate() + + // Compute tz from viewMatrix to find how far the camera is from + // the origin along the view axis. Orbit always uses tz=4.0, so + // we scale zoom to compensate: orbit_zoom = currentZoom * 4 / tz. + m := orient.ToMatrix() + tz := m[6]*f.pos[0] + m[7]*f.pos[1] + m[8]*f.pos[2] + if tz < 0.01 { + tz = 4.0 + } + zoom := currentZoom * 4.0 / tz + + return orient, zoom +} diff --git a/flythrough_test.go b/flythrough_test.go new file mode 100644 index 0000000..1493a4d --- /dev/null +++ b/flythrough_test.go @@ -0,0 +1,186 @@ +package pointcloud + +import ( + "math" + "testing" + + "fyne.io/fyne/v2" +) + +func TestQuatConjugate(t *testing.T) { + q := QuatFromAxisAngle(0, 1, 0, math.Pi/4) + c := q.Conjugate() + + // q * conjugate(q) should be identity. + product := q.Mul(c) + if math.Abs(product.W-1.0) > 1e-10 { + t.Errorf("q * conj(q) should be identity, got W=%f", product.W) + } + if math.Abs(product.X)+math.Abs(product.Y)+math.Abs(product.Z) > 1e-10 { + t.Errorf("q * conj(q) should have zero imaginary, got (%f, %f, %f)", product.X, product.Y, product.Z) + } +} + +func TestQuatRotateVec3(t *testing.T) { + // 90-degree rotation around Y should send (1,0,0) to (0,0,-1). + q := QuatFromAxisAngle(0, 1, 0, math.Pi/2) + v := q.RotateVec3([3]float64{1, 0, 0}) + if math.Abs(v[0]-0) > 1e-10 || math.Abs(v[1]-0) > 1e-10 || math.Abs(v[2]-(-1)) > 1e-10 { + t.Errorf("expected (0,0,-1), got (%f,%f,%f)", v[0], v[1], v[2]) + } + + // Identity quaternion should not change the vector. + q = QuatIdentity() + v = q.RotateVec3([3]float64{3, 4, 5}) + if math.Abs(v[0]-3) > 1e-10 || math.Abs(v[1]-4) > 1e-10 || math.Abs(v[2]-5) > 1e-10 { + t.Errorf("identity rotation changed vector: got (%f,%f,%f)", v[0], v[1], v[2]) + } +} + +func TestProjectChunkOrbitCompatibility(t *testing.T) { + // Verify that projectChunk with tx=0, ty=0, tz=4.0 produces the same + // output as the original formula (dist = 4.0 - rz). + pts := generatePoints(1000) + c := setupCanvas(pts) + + w, h := 512, 384 + m := c.orientation.ToMatrix() + + // Run with the new parameterized version. + img1 := make([]byte, w*h*4) + stride := w * 4 + m0, m1, m2 := float32(m[0]), float32(m[1]), float32(m[2]) + m3, m4, m5 := float32(m[3]), float32(m[4]), float32(m[5]) + m6, m7, m8 := float32(m[6]), float32(m[7]), float32(m[8]) + zoom := float32(c.zoom) + centerX := float32(w) / 2 + centerY := float32(h) / 2 + + projectChunk(c.xs, c.ys, c.zs, c.rgba, img1, stride, w, h, + m0, m1, m2, m3, m4, m5, m6, m7, m8, + 0, 0, 4.0, + zoom, centerX, centerY, 255, 150, 255) + + // Verify at least some pixels were written (not all zero). + nonZero := 0 + for i := 0; i < len(img1); i += 4 { + if img1[i] != 0 || img1[i+1] != 0 || img1[i+2] != 0 { + nonZero++ + } + } + if nonZero == 0 { + t.Error("projectChunk produced no visible pixels") + } +} + +func TestViewMatrixIdentity(t *testing.T) { + cam := newFlythroughCamera(nil) + cam.pos = [3]float64{0, 0, 0} + cam.orientation = QuatIdentity() + + m, tx, ty, tz := cam.viewMatrix() + // With identity orientation and position at origin, the view matrix + // should be identity and translations should be zero. + if math.Abs(m[0]-1) > 1e-10 || math.Abs(m[4]-1) > 1e-10 || math.Abs(m[8]-1) > 1e-10 { + t.Errorf("expected identity matrix diagonal, got [%f, %f, %f]", m[0], m[4], m[8]) + } + if math.Abs(tx) > 1e-10 || math.Abs(ty) > 1e-10 || math.Abs(tz) > 1e-10 { + t.Errorf("expected zero translation, got (%f, %f, %f)", tx, ty, tz) + } +} + +func TestViewMatrixKnownPosition(t *testing.T) { + cam := newFlythroughCamera(nil) + cam.pos = [3]float64{0, 0, 4.0} + cam.orientation = QuatIdentity() + + _, tx, ty, tz := cam.viewMatrix() + // Camera at (0,0,4) looking down -Z: tz should be 4.0 (same as orbit mode). + if math.Abs(tx) > 1e-10 || math.Abs(ty) > 1e-10 || math.Abs(tz-4.0) > 1e-10 { + t.Errorf("expected (0, 0, 4), got (%f, %f, %f)", tx, ty, tz) + } +} + +func TestOrbitFlythroughRoundTrip(t *testing.T) { + cam := newFlythroughCamera(nil) + origOrientation := QuatFromEulerXY(-0.3, -math.Pi/4) + origZoom := 200.0 + + cam.fromOrbit(origOrientation, origZoom, 0, 0) + gotOrient, gotZoom := cam.toOrbit(origZoom) + + // Orientation should be preserved exactly. + if math.Abs(gotOrient.X-origOrientation.X) > 1e-10 || + math.Abs(gotOrient.Y-origOrientation.Y) > 1e-10 || + math.Abs(gotOrient.Z-origOrientation.Z) > 1e-10 || + math.Abs(gotOrient.W-origOrientation.W) > 1e-10 { + t.Errorf("orientation not preserved: got %v, want %v", gotOrient, origOrientation) + } + + // Zoom should be exactly preserved through the round trip (tz=4.0). + if math.Abs(gotZoom-origZoom)/origZoom > 1e-10 { + t.Errorf("zoom not preserved: got %f, want %f", gotZoom, origZoom) + } +} + +func TestFlythroughTick(t *testing.T) { + cam := newFlythroughCamera(nil) + cam.pos = [3]float64{0, 0, 0} + cam.orientation = QuatIdentity() + cam.speedMultiple = 10.0 // fast enough to see movement in one tick + + // Hold W key (forward). + cam.keysHeld[fyne.KeyW] = true + moved := cam.tick(1.0 / 60.0) + if !moved { + t.Error("tick should report movement when W is held") + } + + // Camera should have moved forward (negative Z in camera space). + if cam.pos[2] >= 0 { + t.Errorf("camera should have moved forward (negative Z), got Z=%f", cam.pos[2]) + } +} + +func TestBuildGrid(t *testing.T) { + pts := generatePoints(10000) + c := setupCanvas(pts) + + g, xs, ys, zs, rgba := buildGrid(c.xs, c.ys, c.zs, c.rgba) + if g == nil { + t.Fatal("buildGrid returned nil") + } + + // Verify all points are accounted for. + total := 0 + for i := range g.cells { + total += g.cells[i].count + } + if total != len(xs) { + t.Errorf("grid has %d points, expected %d", total, len(xs)) + } + + _ = ys + _ = zs + _ = rgba +} + +func TestGridFrustumCulling(t *testing.T) { + pts := generatePoints(10000) + c := setupCanvas(pts) + + g, _, _, _, _ := buildGrid(c.xs, c.ys, c.zs, c.rgba) + if g == nil { + t.Fatal("buildGrid returned nil") + } + + // Extract frustum planes from a known view and verify culling. + m := QuatIdentity().ToMatrix() + planes := extractFrustumPlanes(m, 0, 0, 4.0, 200, 1.33) + cells := g.visibleCells(planes) + + // With a generous frustum from distance 4, most cells should be visible. + if len(cells) == 0 { + t.Error("frustum culling removed all cells") + } +} diff --git a/grid.go b/grid.go new file mode 100644 index 0000000..ef0f941 --- /dev/null +++ b/grid.go @@ -0,0 +1,242 @@ +package pointcloud + +import "math" + +const gridSize = 8 // 8x8x8 = 512 cells + +// spatialGrid is a uniform 3D grid over normalized space for coarse +// frustum culling in flythrough mode. +type spatialGrid struct { + cells [gridSize * gridSize * gridSize]gridCell + cellSize [3]float64 // size of each cell + origin [3]float64 // min corner of the grid +} + +// gridCell stores a contiguous range into the SoA arrays plus a bounding sphere. +type gridCell struct { + start, count int + centerX float32 + centerY float32 + centerZ float32 + radius float32 +} + +// buildGrid assigns points to cells and reorders SoA arrays so each cell's +// points are contiguous. Returns the grid and reordered arrays. +func buildGrid(xs, ys, zs []float32, rgba []uint32) (*spatialGrid, []float32, []float32, []float32, []uint32) { + n := len(xs) + if n == 0 { + return nil, xs, ys, zs, rgba + } + + // Find bounding box with a small margin. + minX, minY, minZ := float64(xs[0]), float64(ys[0]), float64(zs[0]) + maxX, maxY, maxZ := minX, minY, minZ + for i := 1; i < n; i++ { + x, y, z := float64(xs[i]), float64(ys[i]), float64(zs[i]) + if x < minX { + minX = x + } + if x > maxX { + maxX = x + } + if y < minY { + minY = y + } + if y > maxY { + maxY = y + } + if z < minZ { + minZ = z + } + if z > maxZ { + maxZ = z + } + } + + // Add small epsilon to avoid edge points falling outside. + const eps = 0.001 + minX -= eps + minY -= eps + minZ -= eps + maxX += eps + maxY += eps + maxZ += eps + + g := &spatialGrid{ + origin: [3]float64{minX, minY, minZ}, + cellSize: [3]float64{(maxX - minX) / gridSize, (maxY - minY) / gridSize, (maxZ - minZ) / gridSize}, + } + + // Ensure no zero-size cells. + for i := range g.cellSize { + if g.cellSize[i] < eps { + g.cellSize[i] = eps + } + } + + // Count points per cell. + cellIdx := make([]int, n) + counts := [gridSize * gridSize * gridSize]int{} + invCellX := 1.0 / g.cellSize[0] + invCellY := 1.0 / g.cellSize[1] + invCellZ := 1.0 / g.cellSize[2] + + for i := 0; i < n; i++ { + cx := int((float64(xs[i]) - minX) * invCellX) + cy := int((float64(ys[i]) - minY) * invCellY) + cz := int((float64(zs[i]) - minZ) * invCellZ) + if cx >= gridSize { + cx = gridSize - 1 + } + if cy >= gridSize { + cy = gridSize - 1 + } + if cz >= gridSize { + cz = gridSize - 1 + } + idx := cx*gridSize*gridSize + cy*gridSize + cz + cellIdx[i] = idx + counts[idx]++ + } + + // Compute start offsets (prefix sum). + offset := 0 + for i := range g.cells { + g.cells[i].start = offset + g.cells[i].count = counts[i] + offset += counts[i] + } + + // Reorder arrays by cell. + newXs := make([]float32, n) + newYs := make([]float32, n) + newZs := make([]float32, n) + newRGBA := make([]uint32, n) + writePos := [gridSize * gridSize * gridSize]int{} + for i := range writePos { + writePos[i] = g.cells[i].start + } + + for i := 0; i < n; i++ { + ci := cellIdx[i] + wp := writePos[ci] + newXs[wp] = xs[i] + newYs[wp] = ys[i] + newZs[wp] = zs[i] + newRGBA[wp] = rgba[i] + writePos[ci]++ + } + + // Compute bounding spheres for each cell. + for i := range g.cells { + c := &g.cells[i] + if c.count == 0 { + continue + } + // Compute center as average. + var sx, sy, sz float64 + end := c.start + c.count + for j := c.start; j < end; j++ { + sx += float64(newXs[j]) + sy += float64(newYs[j]) + sz += float64(newZs[j]) + } + fn := float64(c.count) + c.centerX = float32(sx / fn) + c.centerY = float32(sy / fn) + c.centerZ = float32(sz / fn) + + // Compute radius as max distance from center. + var maxR2 float64 + cx, cy, cz := float64(c.centerX), float64(c.centerY), float64(c.centerZ) + for j := c.start; j < end; j++ { + dx := float64(newXs[j]) - cx + dy := float64(newYs[j]) - cy + dz := float64(newZs[j]) - cz + r2 := dx*dx + dy*dy + dz*dz + if r2 > maxR2 { + maxR2 = r2 + } + } + c.radius = float32(math.Sqrt(maxR2)) + } + + return g, newXs, newYs, newZs, newRGBA +} + +// visibleCells returns indices of cells that intersect the frustum defined +// by 6 planes. Each plane is [nx, ny, nz, d] where nx*x + ny*y + nz*z + d >= 0 +// means the point is inside. +func (g *spatialGrid) visibleCells(planes [6][4]float32) []gridCell { + var result []gridCell + for i := range g.cells { + c := &g.cells[i] + if c.count == 0 { + continue + } + // Sphere-frustum test: if sphere is fully outside any plane, skip. + outside := false + for _, p := range planes { + dist := p[0]*c.centerX + p[1]*c.centerY + p[2]*c.centerZ + p[3] + if dist < -c.radius { + outside = true + break + } + } + if !outside { + result = append(result, *c) + } + } + return result +} + +// extractFrustumPlanes derives 6 frustum planes from view-projection parameters. +// The planes are in the form [nx, ny, nz, d] with inward-pointing normals. +func extractFrustumPlanes(m [9]float64, tx, ty, tz, zoom, aspect float64) [6][4]float32 { + var planes [6][4]float32 + + // Build a combined view-projection matrix rows for plane extraction. + // The perspective projection for our renderer is: + // screenX = (rx / dist) * zoom where dist = tz - rz + // screenY = (ry / dist) * zoom + // This is equivalent to a projection matrix where: + // clip_x = rx * zoom + // clip_y = ry * zoom + // clip_w = dist = tz - rz + // Frustum planes from clip space: left/right/top/bottom/near/far. + + // Row vectors of the combined transform: + // row0 = [m0, m1, m2, tx] * zoom (for x) + // row1 = [m3, m4, m5, ty] * zoom (for y) + // row3 = [tz - m6z... ] (for w = tz - rz) + // Actually row3: w = tz - (m6*x + m7*y + m8*z) = -m6*x - m7*y - m8*z + tz + + r0 := [4]float64{m[0] * zoom, m[1] * zoom, m[2] * zoom, tx * zoom} + r1 := [4]float64{m[3] * zoom, m[4] * zoom, m[5] * zoom, ty * zoom} + r3 := [4]float64{-m[6], -m[7], -m[8], tz} + + // Left plane: row3 + row0 (clip_x + clip_w >= 0) + normalizePlane := func(a, b, c, d float64) [4]float32 { + l := math.Sqrt(a*a + b*b + c*c) + if l < 1e-10 { + return [4]float32{} + } + return [4]float32{float32(a / l), float32(b / l), float32(c / l), float32(d / l)} + } + + // Use aspect ratio to scale horizontal planes. + _ = aspect + planes[0] = normalizePlane(r3[0]+r0[0], r3[1]+r0[1], r3[2]+r0[2], r3[3]+r0[3]) // left + planes[1] = normalizePlane(r3[0]-r0[0], r3[1]-r0[1], r3[2]-r0[2], r3[3]-r0[3]) // right + planes[2] = normalizePlane(r3[0]+r1[0], r3[1]+r1[1], r3[2]+r1[2], r3[3]+r1[3]) // bottom + planes[3] = normalizePlane(r3[0]-r1[0], r3[1]-r1[1], r3[2]-r1[2], r3[3]-r1[3]) // top + + // Near plane: dist >= 0.1 → -m6*x - m7*y - m8*z + tz >= 0.1 + planes[4] = normalizePlane(-m[6], -m[7], -m[8], tz-0.1) + + // Far plane: a generous far distance to avoid clipping visible points. + planes[5] = normalizePlane(m[6], m[7], m[8], -(tz - 100.0)) + + return planes +} diff --git a/images/gopher-small.png b/images/gopher-small.png new file mode 100644 index 0000000..4d75980 Binary files /dev/null and b/images/gopher-small.png differ diff --git a/images/gopher.png b/images/gopher.png new file mode 100644 index 0000000..1b57ee6 Binary files /dev/null and b/images/gopher.png differ diff --git a/screenshot.png b/images/screenshot.png similarity index 100% rename from screenshot.png rename to images/screenshot.png diff --git a/internal/ui/doc.go b/internal/ui/doc.go new file mode 100644 index 0000000..dd759fd --- /dev/null +++ b/internal/ui/doc.go @@ -0,0 +1,2 @@ +// Package ui contains internal UI types +package ui diff --git a/internal/ui/iconbutton.go b/internal/ui/iconbutton.go index c0d78ba..f263eca 100644 --- a/internal/ui/iconbutton.go +++ b/internal/ui/iconbutton.go @@ -3,6 +3,7 @@ package ui import ( "image" "image/color" + "math" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" @@ -85,6 +86,44 @@ func DrawZoomFitIcon(img *image.RGBA, w, h int) { raster.LineAA(img, cx, cy-aOff, cx, cy+aOff, arrowColor) } +// DrawFlythroughIcon draws a simple eye/camera icon suggesting first-person view. +func DrawFlythroughIcon(img *image.RGBA, w, h int) { + outline := color.RGBA{200, 200, 200, 200} + cx, cy := float64(w)/2, float64(h)/2 + s := float64(w) * 0.30 + + // Draw an eye shape: two arcs meeting at left and right points. + // Top arc + steps := 8 + for i := range steps { + t0 := float64(i) / float64(steps) + t1 := float64(i+1) / float64(steps) + x0 := cx - s + t0*2*s + y0 := cy - s*0.6*math.Sin(t0*math.Pi) + x1 := cx - s + t1*2*s + y1 := cy - s*0.6*math.Sin(t1*math.Pi) + raster.LineAA(img, x0, y0, x1, y1, outline) + } + // Bottom arc + for i := range steps { + t0 := float64(i) / float64(steps) + t1 := float64(i+1) / float64(steps) + x0 := cx - s + t0*2*s + y0 := cy + s*0.6*math.Sin(t0*math.Pi) + x1 := cx - s + t1*2*s + y1 := cy + s*0.6*math.Sin(t1*math.Pi) + raster.LineAA(img, x0, y0, x1, y1, outline) + } + // Pupil dot (filled circle approximation). + pupilR := s * 0.25 + for i := range 12 { + t0 := float64(i) / 12.0 * 2 * math.Pi + t1 := float64(i+1) / 12.0 * 2 * math.Pi + raster.LineAA(img, cx+pupilR*math.Cos(t0), cy+pupilR*math.Sin(t0), + cx+pupilR*math.Cos(t1), cy+pupilR*math.Sin(t1), outline) + } +} + // DrawHomeIcon draws a simple house icon. func DrawHomeIcon(img *image.RGBA, w, h int) { fill := color.RGBA{160, 160, 160, 160} diff --git a/options.go b/options.go index 3567337..2b03723 100644 --- a/options.go +++ b/options.go @@ -33,6 +33,8 @@ type config struct { fpsColor *color.RGBA fpsStyle *fyne.TextStyle fpsSize *float32 + showFlythroughButton *bool + flythroughEnabled *bool } // CubeColors configures the colors of the orientation cube. @@ -215,3 +217,13 @@ func quatOr(p *Quat, def Quat) Quat { } return def } + +// WithFlythroughButton controls whether the flythrough toggle button is displayed. +func WithFlythroughButton(show bool) Option { + return func(cfg *config) { cfg.showFlythroughButton = &show } +} + +// WithFlythroughEnabled sets whether flythrough mode is initially active. +func WithFlythroughEnabled(on bool) Option { + return func(cfg *config) { cfg.flythroughEnabled = &on } +} diff --git a/quat.go b/quat.go index 63fccc4..a1dc816 100644 --- a/quat.go +++ b/quat.go @@ -50,6 +50,27 @@ func (q Quat) Normalize() Quat { return Quat{q.X / l, q.Y / l, q.Z / l, q.W / l} } +// Conjugate returns the conjugate of the quaternion, which for unit +// quaternions is also the inverse rotation. +func (q Quat) Conjugate() Quat { + return Quat{X: -q.X, Y: -q.Y, Z: -q.Z, W: q.W} +} + +// RotateVec3 rotates a 3D vector by the quaternion using the formula q*v*q^-1. +func (q Quat) RotateVec3(v [3]float64) [3]float64 { + // Optimized quaternion-vector rotation (avoids full quaternion multiply). + // t = 2 * cross(q.xyz, v) + tx := 2 * (q.Y*v[2] - q.Z*v[1]) + ty := 2 * (q.Z*v[0] - q.X*v[2]) + tz := 2 * (q.X*v[1] - q.Y*v[0]) + // result = v + q.w*t + cross(q.xyz, t) + return [3]float64{ + v[0] + q.W*tx + (q.Y*tz - q.Z*ty), + v[1] + q.W*ty + (q.Z*tx - q.X*tz), + v[2] + q.W*tz + (q.X*ty - q.Y*tx), + } +} + // ToMatrix returns a row-major 3x3 rotation matrix. func (q Quat) ToMatrix() [9]float64 { xx, yy, zz := q.X*q.X, q.Y*q.Y, q.Z*q.Z diff --git a/viewer.go b/viewer.go index bb8b0fa..eabce57 100644 --- a/viewer.go +++ b/viewer.go @@ -41,14 +41,20 @@ const ( type Viewer struct { widget.BaseWidget - canvas *canvas3d - cube *orientationCube - home *ui.IconButton - zoomFit *ui.IconButton - infoLabel *canvas.Text - fpsLabel *canvas.Text - scaleBar *ui.ScaleBar - content *fyne.Container + canvas *canvas3d + cube *orientationCube + home *ui.IconButton + zoomFit *ui.IconButton + flyBtn *ui.IconButton + infoLabel *canvas.Text + fpsLabel *canvas.Text + speedLabel *canvas.Text + scaleBar *ui.ScaleBar + content *fyne.Container + + // OnFlythroughChanged is called when flythrough mode is toggled, + // either via the UI button, the 'G' key, or SetFlythrough. + OnFlythroughChanged func(on bool) // FPS tracking state. fpsFrameCount int @@ -87,6 +93,13 @@ func New(opts ...Option) *Viewer { }) } + showFlyBtn := boolOr(cfg.showFlythroughButton, true) + if showFlyBtn { + v.flyBtn = ui.NewIconButton(28, 28, ui.DrawFlythroughIcon, func() { + v.canvas.setFlythrough(!v.canvas.flyMode) + }) + } + if showCube { cubeColors := DefaultCubeColors() if cfg.cubeColors != nil { @@ -160,6 +173,22 @@ func New(opts ...Option) *Viewer { } } + // Speed indicator — hidden by default, shown in flythrough mode. + v.speedLabel = canvas.NewText("", color.RGBA{200, 200, 200, 255}) + v.speedLabel.TextStyle = fyne.TextStyle{Monospace: true} + v.speedLabel.TextSize = 12 + v.speedLabel.Text = "" // hidden until flythrough activates + + v.canvas.onSpeedChanged = func(mult float64) { + if v.speedLabel == nil { + return + } + fyne.Do(func() { + v.speedLabel.Text = formatSpeed(mult) + v.speedLabel.Refresh() + }) + } + v.canvas.onZoomChanged = func() { if v.scaleBar != nil { fyne.Do(func() { v.scaleBar.Raster.Refresh() }) @@ -186,8 +215,34 @@ func New(opts ...Option) *Viewer { v.infoLabel.Refresh() } + // Track flythrough state changes to update the button and speed label. + v.canvas.onFlythroughChanged = func(on bool) { + if v.flyBtn != nil { + fyne.Do(func() { v.flyBtn.Raster.Refresh() }) + } + if v.cube != nil { + fyne.Do(func() { v.cube.raster.Refresh() }) + } + if v.speedLabel != nil { + fyne.Do(func() { + if on { + v.speedLabel.Text = formatSpeed(1.0) + } else { + v.speedLabel.Text = "" + } + v.speedLabel.Refresh() + }) + } + if v.OnFlythroughChanged != nil { + v.OnFlythroughChanged(on) + } + } + // Build the overlay controls. btnItems := []fyne.CanvasObject{layout.NewSpacer()} + if v.flyBtn != nil { + btnItems = append(btnItems, container.New(layout.NewGridWrapLayout(fyne.NewSize(28, 28)), v.flyBtn)) + } if v.zoomFit != nil { btnItems = append(btnItems, container.New(layout.NewGridWrapLayout(fyne.NewSize(28, 28)), v.zoomFit)) } @@ -218,9 +273,16 @@ func New(opts ...Option) *Viewer { topRight := container.NewHBox(layout.NewSpacer(), controls) - var topLeft fyne.CanvasObject + var topLeftItems []fyne.CanvasObject if v.fpsLabel != nil { - topLeft = container.NewVBox(v.fpsLabel) + topLeftItems = append(topLeftItems, v.fpsLabel) + } + if v.speedLabel != nil { + topLeftItems = append(topLeftItems, v.speedLabel) + } + var topLeft fyne.CanvasObject + if len(topLeftItems) > 0 { + topLeft = container.NewVBox(topLeftItems...) } overlay := container.New( @@ -234,6 +296,12 @@ func New(opts ...Option) *Viewer { v.content = container.NewStack(v.canvas, overlay) v.ExtendBaseWidget(v) + + // Apply initial flythrough state if configured. + if boolOr(cfg.flythroughEnabled, false) { + v.canvas.setFlythrough(true) + } + return v } @@ -534,3 +602,23 @@ func (v *Viewer) MaxZoomOutFraction() float64 { defer v.canvas.mu.Unlock() return v.canvas.maxZoomOutFraction } + +// SetFlythrough enables or disables flythrough (first-person) camera mode. +func (v *Viewer) SetFlythrough(on bool) { + v.canvas.setFlythrough(on) +} + +// IsFlythrough returns true if flythrough mode is currently active. +func (v *Viewer) IsFlythrough() bool { + v.canvas.mu.Lock() + defer v.canvas.mu.Unlock() + return v.canvas.flyMode +} + +// formatSpeed returns a human-readable speed multiplier string. +func formatSpeed(mult float64) string { + if mult >= 1.0 { + return fmt.Sprintf("Speed: %.0fx", mult) + } + return fmt.Sprintf("Speed: %.2fx", mult) +}