Skip to content

Instantly share code, notes, and snippets.

@keolo
Created October 9, 2025 04:02
Show Gist options
  • Save keolo/bb104bacc3d377d2226f0acfe26c22e7 to your computer and use it in GitHub Desktop.
Save keolo/bb104bacc3d377d2226f0acfe26c22e7 to your computer and use it in GitHub Desktop.
Point Cloud Example in Dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:vector_math/vector_math_64.dart' as v;
void main() => runApp(const MaterialApp(home: ParticleSphereDemo()));
class ParticleSphereDemo extends StatefulWidget {
const ParticleSphereDemo({super.key});
@override
State<ParticleSphereDemo> createState() => _ParticleSphereDemoState();
}
class _ParticleSphereDemoState extends State<ParticleSphereDemo>
with SingleTickerProviderStateMixin {
late final List<v.Vector3> points;
late final AnimationController _ctrl;
// rotation (in radians)
double rotX = 0, rotY = 0;
double _lastPanDX = 0, _lastPanDY = 0; // Stores last delta for momentum
double _momentumX = 0, _momentumY = 0; // Momentum applied after drag
bool _isDragging = false; // Flag to indicate if user is actively dragging
// Issue 3: Centralize magic numbers for better readability and maintainability
static const int _pointCount = 900;
static const double _sphereRadius = 1.0;
static const Duration _animationDuration = Duration(seconds: 12);
static const double _dragSensitivity = 0.01;
static const double _momentumScaleFactor = 5.0;
static const double _momentumDecayFactor = 0.95;
static const double _momentumThreshold = 0.0001;
static const double _autoRotateSpeedY = 0.003;
static const double _autoRotateSpeedX = 0.0015;
@override
void initState() {
super.initState();
points = _fibonacciSphere(_pointCount, radius: _sphereRadius);
_ctrl = AnimationController(
vsync: this, duration: _animationDuration) // Issue 4: Uses const Duration
..addListener(_handleAnimationTick)
..repeat(); // Start auto-rotation immediately
}
// Handles animation ticks for auto-rotation and momentum decay
void _handleAnimationTick() {
setState(() {
if (_isDragging) {
// No action here if actively dragging
} else if (_momentumX != 0 || _momentumY != 0) {
// Apply momentum, then decay it
rotY += _momentumY;
rotX += _momentumX;
_momentumY *= _momentumDecayFactor; // Issue 3: Using named constant
_momentumX *= _momentumDecayFactor; // Issue 3: Using named constant
// Stop momentum if it becomes very small
if (_momentumX.abs() < _momentumThreshold) _momentumX = 0; // Issue 3: Using named constant
if (_momentumY.abs() < _momentumThreshold) _momentumY = 0; // Issue 3: Using named constant
} else {
// Auto-rotate if no drag or momentum
rotY += _autoRotateSpeedY; // Issue 3: Using named constant
rotX += _autoRotateSpeedX; // Issue 3: Using named constant
}
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
// Evenly distributed points on a sphere
List<v.Vector3> _fibonacciSphere(int n, {double radius = 1.0}) {
final pts = <v.Vector3>[];
// Fix: `math.sqrt(5)` is not a constant expression, so `goldenAngle` must be `final`.
final goldenAngle = math.pi * (3 - math.sqrt(5)); // ~2.399
for (int i = 0; i < n; i++) {
final t = i + 0.5;
final y = 1 - (t / n) * 2; // y from 1 to -1
final r = math.sqrt(1 - y * y); // radius at y
final phi = goldenAngle * t;
final x = math.cos(phi) * r;
final z = math.sin(phi) * r;
pts.add(v.Vector3(x * radius, y * radius, z * radius));
}
return pts;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onPanStart: (_) {
_isDragging = true;
_ctrl.stop(); // Stop auto-rotation and momentum decay
_momentumX = 0; // Clear any pending momentum
_momentumY = 0;
_lastPanDX = 0; // Issue 5: Clear last pan deltas on start
_lastPanDY = 0; // Issue 5: Clear last pan deltas on start
},
onPanUpdate: (d) {
setState(() {
// drag to spin; scale deltas for feel
rotY += d.delta.dx * _dragSensitivity; // Issue 3: Using named constant
rotX -= d.delta.dy * _dragSensitivity; // Issue 3: Using named constant
_lastPanDX = d.delta.dx; // Store last delta for momentum
_lastPanDY = d.delta.dy;
});
},
onPanEnd: (_) {
_isDragging = false;
// Calculate initial momentum based on the last drag delta
_momentumY = _lastPanDX * _dragSensitivity * _momentumScaleFactor; // Issue 3: Using named constant
_momentumX = -_lastPanDY * _dragSensitivity * _momentumScaleFactor; // Issue 3: Using named constant
_ctrl.repeat(); // Restart the animation for momentum decay or auto-rotate
},
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints c) {
return CustomPaint(
painter: _SpherePainter(
points: points,
rotX: rotX,
rotY: rotY,
),
size: Size(c.maxWidth, c.maxHeight),
);
},
),
),
);
}
}
/// Helper class to store a 2D point and its perspective value.
class _Point2D {
final Offset pos;
final double persp;
_Point2D(this.pos, this.persp);
}
class _SpherePainter extends CustomPainter {
final List<v.Vector3> points;
final double rotX, rotY;
// Issue 1: Reusable Vector4 for transformations to avoid re-instantiation in paint method
// Changed from Vector3 to Vector4 because Matrix4.transform operates on Vector4
final v.Vector4 _tempVec4 = v.Vector4.zero();
// Issue 2: Reusable Paint object to avoid re-instantiation in paint method
final Paint _paint = Paint()..style = PaintingStyle.fill;
// Issue 3: Centralize magic numbers for painter
static const double _fov = 600; // larger -> less perspective
static const double _scale = 220; // overall sphere size in px
static const double _depthFactor = 200; // Multiplier for Z-depth in perspective
static const double _minRadius = 0.6;
static const double _maxRadius = 2.8;
static const double _minAlpha = 0.2;
static const double _maxAlpha = 1.0;
static const double _radiusBaseFactor = 1.4;
static const double _alphaBaseFactor = 0.35;
static const double _alphaPerspectiveFactor = 0.65;
_SpherePainter({
required this.points,
required this.rotX,
required this.rotY,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
// build rotation + perspective
final m = v.Matrix4.identity()
..rotateX(rotX)
..rotateY(rotY);
// Draw back-to-front for nicer overlap
final List<({_Point2D p, double depth})> transformed =
<({_Point2D p, double depth})>[];
for (final v.Vector3 p in points) {
// Set _tempVec4 with the current point's data and a w-component of 1.0
_tempVec4.setValues(p.x, p.y, p.z, 1.0);
// Transform _tempVec4 in place. The transform method with one argument mutates it.
m.transform(_tempVec4);
// perspective divide: project z in [-1,1] to screen
final double depth = _tempVec4.z; // keep for painter’s sort
final double perspective = _fov / (_fov - (_tempVec4.z * _depthFactor)); // Issue 3: Using named constants
final double x = _tempVec4.x * _scale * perspective + center.dx; // Issue 3: Using named constants
final double y = _tempVec4.y * _scale * perspective + center.dy; // Issue 3: Using named constants
// Fix: Use named record fields `p` and `depth` to match the list's generic type.
transformed.add((p: _Point2D(Offset(x, y), perspective), depth: depth));
}
transformed.sort(
(a, b) => a.depth.compareTo(b.depth)); // Explicit type for compareTo arguments
for (final ({_Point2D p, double depth}) e in transformed) {
final _Point2D p = e.p;
// size + brightness based on perspective (gives 3D feel)
final double r = (_radiusBaseFactor * p.persp).clamp(_minRadius, _maxRadius); // Issue 3: Using named constants
final double alpha = (_alphaBaseFactor + _alphaPerspectiveFactor * p.persp).clamp(_minAlpha, _maxAlpha); // Issue 3: Using named constants
// Fix: Replace deprecated `withOpacity` with `withAlpha`
_paint.color = Colors.white.withAlpha((255 * alpha).round()); // Issue 2: Reuse paint object, only change color
canvas.drawCircle(p.pos, r, _paint); // Issue 2: Reuse paint object
}
}
@override
bool shouldRepaint(covariant _SpherePainter oldDelegate) =>
oldDelegate.rotX != rotX || oldDelegate.rotY != rotY;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment