Last active
April 20, 2023 21:51
-
-
Save hanskokx/1d97e7fcdd86593043871e6820e064f9 to your computer and use it in GitHub Desktop.
Rope simulation
This file contains 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:async'; | |
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:flutter/material.dart'; | |
void main() { | |
runApp(const MaterialApp(home: Scaffold(body: RopeSimulation()))); | |
} | |
class RopeSimulation extends StatefulWidget { | |
const RopeSimulation({super.key}); | |
@override | |
State<RopeSimulation> createState() => _RopeSimulationState(); | |
} | |
class _RopeSimulationState extends State<RopeSimulation> { | |
late final Rope rope; | |
final double segmentLength = 20.0; | |
final double stiffness = | |
0.2; // Decrease the value to make the rope more wiggly | |
final int iterations = 20; | |
final Offset gravity = const Offset( | |
0, | |
9.8, | |
); // Increase the vertical component for a stronger gravity effect. 9.8 is Earth gravity. | |
Timer? _timer; | |
Offset? _cursorPosition; | |
bool _isDragging = false; | |
DateTime _lastFrameTime = DateTime.now(); | |
late double deltaTime; | |
@override | |
Widget build(BuildContext context) { | |
final now = DateTime.now(); | |
deltaTime = (now.difference(_lastFrameTime)).inMicroseconds / 1000000.0; | |
_lastFrameTime = now; | |
return LayoutBuilder( | |
builder: (BuildContext context, BoxConstraints constraints) { | |
return GestureDetector( | |
onPanUpdate: (details) { | |
setState(() { | |
_cursorPosition = details.localPosition; | |
}); | |
}, | |
onPanStart: (details) { | |
setState(() { | |
_cursorPosition = details.localPosition; | |
_isDragging = true; | |
}); | |
}, | |
onPanEnd: (details) { | |
setState(() { | |
_cursorPosition = null; | |
_isDragging = false; | |
}); | |
}, | |
child: SizedBox( | |
width: double.infinity, | |
height: double.infinity, | |
child: CustomPaint( | |
painter: rope, | |
), | |
), | |
); | |
}); | |
} | |
@override | |
void dispose() { | |
_timer?.cancel(); | |
super.dispose(); | |
} | |
@override | |
void initState() { | |
super.initState(); | |
rope = Rope( | |
startPosition: const Offset(100, 100), | |
numSegments: 20, | |
segmentLength: 20, | |
stiffness: 0.2, | |
iterations: 20, | |
gravity: const Offset(0, 9.8), | |
colors: [ | |
const Color.fromRGBO(0, 243, 199, 1), | |
const Color.fromRGBO(47, 43, 126, 1) | |
], | |
); | |
_lastFrameTime = DateTime.now(); | |
_timer = Timer.periodic(const Duration(milliseconds: 16), (timer) { | |
final now = DateTime.now(); | |
deltaTime = (now.difference(_lastFrameTime)).inMicroseconds / 1000000.0; | |
_lastFrameTime = now; | |
rope.update( | |
deltaTime: deltaTime, | |
cursorPosition: _cursorPosition, | |
isDragging: _isDragging, | |
damping: _isDragging ? 1.0 : 0.98, | |
); | |
if (_isDragging && _cursorPosition != null) { | |
rope.segments.last.position = _cursorPosition!; | |
rope.segments.last.lastPosition = _cursorPosition!; | |
} | |
setState(() {}); | |
}); | |
} | |
} | |
class Rope extends CustomPainter { | |
final double segmentLength; | |
final int numSegments; | |
final double stiffness; | |
final int iterations; | |
final Offset gravity; | |
final List<Color> colors; | |
final Offset startPosition; | |
final bool isDragging; | |
final double thickness; | |
final List<RopeSegment> _segments; | |
final bool _coiled; | |
factory Rope({ | |
required Offset startPosition, | |
required double segmentLength, | |
required int numSegments, | |
bool isDragging = false, | |
double stiffness = 0.2, | |
int iterations = 20, | |
Offset gravity = const Offset(0, 9.8), | |
int thickness = 5, | |
List<Color> colors = const [ | |
Color.fromRGBO(0, 0, 255, 1), | |
Color.fromRGBO(255, 0, 0, 1) | |
], | |
}) { | |
final segments = List.generate( | |
numSegments, | |
(index) => RopeSegment( | |
Offset( | |
startPosition.dx, | |
startPosition.dy + index * segmentLength, | |
), | |
), | |
); | |
return Rope._( | |
segments, | |
startPosition: startPosition, | |
numSegments: numSegments, | |
segmentLength: segmentLength, | |
stiffness: stiffness, | |
iterations: iterations, | |
gravity: gravity, | |
colors: colors, | |
thickness: thickness.toDouble(), | |
isDragging: isDragging, | |
coiled: false, | |
); | |
} | |
factory Rope.coiled({ | |
required Offset startPosition, | |
required double segmentLength, | |
required int numSegments, | |
bool isDragging = false, | |
double stiffness = 0.2, | |
int iterations = 20, | |
Offset gravity = const Offset(0, 9.8), | |
int thickness = 5, | |
List<Color> colors = const [ | |
Color.fromRGBO(0, 0, 255, 1), | |
Color.fromRGBO(255, 0, 0, 1) | |
], | |
}) { | |
final segments = List.generate( | |
numSegments, | |
(index) => RopeSegment( | |
Offset( | |
startPosition.dx, | |
startPosition.dy + index * segmentLength, | |
), | |
), | |
); | |
return Rope._( | |
segments, | |
startPosition: startPosition, | |
numSegments: numSegments, | |
segmentLength: segmentLength, | |
stiffness: stiffness, | |
iterations: iterations, | |
gravity: gravity, | |
colors: colors, | |
thickness: thickness.toDouble(), | |
isDragging: isDragging, | |
coiled: true, | |
); | |
} | |
Rope._( | |
this._segments, { | |
required this.startPosition, | |
required this.segmentLength, | |
required this.numSegments, | |
this.isDragging = false, | |
this.stiffness = 0.2, | |
this.iterations = 20, | |
this.gravity = const Offset(0, 9.8), | |
this.thickness = 5, | |
required bool coiled, | |
required this.colors, | |
}) : _coiled = coiled; | |
List<RopeSegment> get segments => _segments; | |
void drawRope(Canvas canvas, Path path, Paint paint, double strokeWidth) { | |
const int braidSegments = 10; | |
final PathMetrics pathMetrics = path.computeMetrics(); | |
final double totalLength = pathMetrics.fold<double>( | |
0, (double prev, PathMetric pathMetric) => prev + pathMetric.length); | |
for (double i = 0; i < totalLength; i += totalLength / braidSegments) { | |
final PathMetric? pathMetric = pathMetrics.isNotEmpty | |
? pathMetrics.firstWhere((PathMetric pm) => pm.length >= i, | |
orElse: () => pathMetrics.last) | |
: null; | |
if (pathMetric == null) continue; | |
final double localTangentOffset = | |
(i - pathMetric.length).clamp(0, pathMetric.length); | |
final Tangent? tangent = | |
pathMetric.getTangentForOffset(localTangentOffset); | |
if (tangent == null) continue; | |
final Offset normal = | |
Offset(-tangent.vector.dy, tangent.vector.dx).normalize() * | |
strokeWidth; | |
final Offset start = tangent.position + normal; | |
final Offset end = tangent.position - normal; | |
canvas.drawLine(start, end, paint); | |
} | |
} | |
@override | |
void paint(Canvas canvas, Size size) { | |
if (_coiled) { | |
paintCoiled(canvas, size); | |
} else { | |
paintStraight(canvas, size); | |
} | |
} | |
void paintCoiled(Canvas canvas, Size size) { | |
final paint1 = Paint() | |
..color = colors[0] | |
..strokeWidth = thickness | |
..style = PaintingStyle.stroke | |
..strokeJoin = StrokeJoin.round | |
..strokeCap = StrokeCap.round; | |
final paint2 = Paint() | |
..color = colors[1] | |
..strokeWidth = thickness | |
..style = PaintingStyle.stroke | |
..strokeJoin = StrokeJoin.round | |
..strokeCap = StrokeCap.round; | |
final double wrapInterval = segmentLength / 2; | |
Path path1 = Path(); | |
Path path2 = Path(); | |
for (int i = 0; i < segments.length - 1; i++) { | |
final segmentA = segments[i]; | |
final segmentB = segments[i + 1]; | |
final angle = atan2( | |
segmentB.position.dy - segmentA.position.dy, | |
segmentB.position.dx - segmentA.position.dx, | |
); | |
final double dx = segmentB.position.dx - segmentA.position.dx; | |
final double dy = segmentB.position.dy - segmentA.position.dy; | |
final double offsetX1 = cos(angle + pi / 2) * 2; | |
final double offsetY1 = sin(angle + pi / 2) * 2; | |
final double offsetX2 = cos(angle - pi / 2) * 2; | |
final double offsetY2 = sin(angle - pi / 2) * 2; | |
for (double t = 0; t <= 1; t += 0.01) { | |
final double x = segmentA.position.dx + dx * t; | |
final double y = segmentA.position.dy + dy * t; | |
final double offsetX = cos(t * pi * 2 * wrapInterval) * 3; | |
final double offsetY = sin(t * pi * 2 * wrapInterval) * 3; | |
final double x1 = x + offsetX * offsetX1; | |
final double y1 = y + offsetY * offsetY1; | |
final double x2 = x + offsetX * offsetX2; | |
final double y2 = y + offsetY * offsetY2; | |
if (t == 0 && i == 0) { | |
path1.moveTo(x1, y1); | |
path2.moveTo(x2, y2); | |
} else { | |
path1.lineTo(x1, y1); | |
path2.lineTo(x2, y2); | |
} | |
} | |
} | |
canvas.drawPath(path1, paint1); | |
canvas.drawPath(path2, paint2); | |
} | |
void paintStraight(Canvas canvas, Size size) { | |
final paint = Paint() | |
..style = PaintingStyle.stroke | |
..strokeWidth = thickness | |
..strokeJoin = StrokeJoin.round | |
..strokeCap = StrokeCap.round; | |
final path = Path(); | |
for (int i = 0; i < segments.length - 1; i++) { | |
Offset a = segments[i].position; | |
Offset b = segments[i + 1].position; | |
final colorIndex = (i / segments.length * colors.length).floor(); | |
paint.color = colors[colorIndex % colors.length]; | |
path.moveTo(a.dx, a.dy); | |
path.lineTo(b.dx, b.dy); | |
canvas.drawPath(path, paint); | |
} | |
final pathMetrics = path.computeMetrics(); | |
final totalLength = pathMetrics.fold<double>( | |
0, (double prev, PathMetric pathMetric) => prev + pathMetric.length); | |
for (double i = 0; i < totalLength; i += totalLength / 10) { | |
final PathMetric? pathMetric = pathMetrics.isNotEmpty | |
? pathMetrics.firstWhere((PathMetric pm) => pm.length >= i, | |
orElse: () => pathMetrics.last) | |
: null; | |
if (pathMetric == null) continue; | |
final double localTangentOffset = | |
(i - pathMetric.length).clamp(0, pathMetric.length); | |
final Tangent? tangent = | |
pathMetric.getTangentForOffset(localTangentOffset); | |
if (tangent == null) continue; | |
final Offset normal = | |
Offset(-tangent.vector.dy, tangent.vector.dx).normalize() * thickness; | |
final Offset start = tangent.position + normal; | |
final Offset end = tangent.position - normal; | |
paint.color = colors[i.floor() % colors.length]; | |
canvas.drawLine(start, end, paint); | |
} | |
} | |
@override | |
bool shouldRepaint(covariant CustomPainter oldDelegate) { | |
return true; | |
} | |
void update({ | |
required double deltaTime, | |
Offset? cursorPosition, | |
double damping = 1.0, | |
bool isDragging = false, | |
}) { | |
for (final segment in segments) { | |
final velocity = (segment.position - segment.lastPosition) * damping; | |
segment.lastPosition = segment.position; | |
segment.position += velocity; | |
segment.position += gravity * deltaTime; | |
} | |
// Set the last segment's position to the cursor position if dragging | |
if (isDragging && cursorPosition != null) { | |
segments.last.position = cursorPosition; | |
} | |
for (int iteration = 0; iteration < iterations; iteration++) { | |
for (int i = 0; i < segments.length - 1; i++) { | |
final a = segments[i]; | |
final b = segments[i + 1]; | |
final distance = (b.position - a.position).distance; | |
final difference = segmentLength - distance; | |
final direction = (b.position - a.position).normalize(); | |
final correction = direction * difference * stiffness; | |
a.position -= correction * 0.5; | |
b.position += correction * 0.5; | |
} | |
// Pin the first segment of the rope | |
segments.first.position = startPosition; | |
} | |
} | |
Path wrapPath(Path originalPath, double wrapInterval) { | |
final Path wrappedPath = Path(); | |
final PathMetric pathMetric = originalPath.computeMetrics().first; | |
final double totalLength = pathMetric.length; | |
for (double t = 0; t < totalLength; t += totalLength / wrapInterval) { | |
final tangent1 = pathMetric.getTangentForOffset(t); | |
final tangent2 = | |
pathMetric.getTangentForOffset(t + totalLength / (2 * wrapInterval)); | |
final tangent3 = | |
pathMetric.getTangentForOffset(t + totalLength / wrapInterval); | |
wrappedPath.moveTo(tangent1!.position.dx, tangent1.position.dy); | |
wrappedPath.quadraticBezierTo(tangent2!.position.dx, tangent2.position.dy, | |
tangent3!.position.dx, tangent3.position.dy); | |
} | |
return wrappedPath; | |
} | |
} | |
class RopeSegment { | |
Offset position; | |
Offset lastPosition; | |
RopeSegment(this.position) : lastPosition = position; | |
} | |
extension OffsetExtensions on Offset { | |
Offset normalize() { | |
double length = distance; | |
return length != 0.0 ? this / length : this; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment