Created
October 9, 2025 04:02
-
-
Save keolo/bb104bacc3d377d2226f0acfe26c22e7 to your computer and use it in GitHub Desktop.
Point Cloud Example in Dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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