From 3204c8bb8221d0fd9fd493585d9e34c6976383ee Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 14 Oct 2026 19:41:49 -0400 Subject: [PATCH 1/2] Cones stop clipping to the floor every frame On Windows, Flutter draws through Skia without a stencil buffer, so a clip to any shape but a rectangle, a circle or a small convex polygon is a mask drawn on the CPU for every draw under it. Every cone was clipped to the map's whole painted floor (the replay once per frame around all cones; the editor per cone), and editor cones also clipped to their visibility shape. That was most of what cones cost. - The revealed map is clipped to the floor once, in its own repaint boundary (RevealedFloor/ConeCoverageLayer take the floor), and the cone mask is composited over it in a clip layer that saves a layer. A colour filter would group them too, but the engine caches a filter's children as one image, redrawing the map with every mask. - Cones joining a layer that clips the map skip their own floor clip; drag previews, screenshots and the replay's no-worker fallback still clip themselves. - An editor cone without height floors draws its lit area (visibility within reach) as one path made once per painter, with no clip. The only visible change: floor edges inside cones are now antialiased (1 px lines, at most 17/255 on screenshots of a replay). Co-Authored-By: Claude Opus 5.5 --- lib/interactive_map.dart | 18 +++ lib/view_cone/cone_coverage.dart | 132 +++++++++++++----- .../utilities/svg_height_view_cone.dart | 98 +++++++++++-- lib/widgets/replay/replay_canvas.dart | 8 ++ lib/widgets/replay/replay_cones.dart | 55 ++------ test/cone_coverage_test.dart | 44 +++++- 6 files changed, 266 insertions(+), 89 deletions(-) diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index 469ff775..482d1ddf 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; +import 'package:icarus/providers/svg_height_runtime_provider.dart'; import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; @@ -130,6 +132,21 @@ class _InteractiveMapState extends ConsumerState { super.dispose(); } + /// The painted floor, which the revealed map is clipped to once; null + /// until the map's height model loads (no height cones show before it + /// does). + Path? _revealedFloor(bool isAttack) { + final map = ref.watch(mapProvider).currentMap; + final model = ref + .watch(svgHeightRuntimeProvider(map)) + .asData + ?.value + ?.model(isAttack); + if (model == null) return null; + return SvgHeightMapTransform.forMap(map) + .floorOnScreen(model, isAttack: isAttack); + } + @override Widget build(BuildContext context) { bool isAttack = ref.watch(mapProvider).isAttack; @@ -308,6 +325,7 @@ class _InteractiveMapState extends ConsumerState { child: IgnorePointer( child: ConeCoverageLayer( coverage: _cones, + floor: _revealedFloor(isAttack), opacity: ref .watch(interactionStateProvider) .isLineUpMode diff --git a/lib/view_cone/cone_coverage.dart b/lib/view_cone/cone_coverage.dart index 16fdea3a..aea8babd 100644 --- a/lib/view_cone/cone_coverage.dart +++ b/lib/view_cone/cone_coverage.dart @@ -67,13 +67,16 @@ double _contrast(Color a, Color b) { return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05); } -/// Something that paints one cone's lit area, clipped to the floor, with a -/// given [fill], in its own coordinates. +/// Something that paints one cone's lit area with a given [fill], in its +/// own coordinates. abstract interface class ConeAreaPainter { /// How strongly this cone is drawn: below 1 for a drag preview. double get opacity; - void paintArea(Canvas canvas, Size size, Paint fill); + /// Paints the lit area, also clipped to the painted floor when + /// [clipToFloor] (when the layer it joins does not clip its map itself). + void paintArea(Canvas canvas, Size size, Paint fill, + {required bool clipToFloor}); } /// Gives the cones below it a shared [ConeCoverage]. @@ -240,49 +243,64 @@ class RenderConeArea extends RenderBox { bool hitTestSelf(Offset position) => false; } -/// Paints [mask] (what cones see, opaque where they reach) and shows -/// [child] only there. Everything happens inside its own layer, so nothing -/// under it is read; [child] must draw straight to the canvas (a picture, no -/// repaint boundary of its own). -void _paintRevealed( +/// Shows [child] only where [mask] (what cones see, opaque where they reach) +/// paints, and returns the layer that does it. +/// +/// [child] is a repaint boundary, so the revealed map keeps one picture +/// while cones move. The mask is drawn over it with dstIn, both inside a +/// layer of their own (a clip to the whole box that saves a layer), so dstIn +/// reaches only the revealed map, never the map under it. A colour filter +/// would group them too, but the engine caches a filter's children as one +/// image, which would redraw the map into a new image with every mask. +ClipRectLayer? _paintRevealed( PaintingContext context, + bool needsCompositing, Offset offset, Size size, RenderBox child, + ClipRectLayer? oldLayer, void Function(Canvas canvas) mask, ) { - final canvas = context.canvas; - final bounds = offset & size; - canvas.saveLayer(bounds, Paint()); - canvas.save(); - canvas.translate(offset.dx, offset.dy); - mask(canvas); - canvas.restore(); - canvas.saveLayer(bounds, Paint()..blendMode = BlendMode.srcIn); - context.paintChild(child, offset); - canvas.restore(); - canvas.restore(); + return context.pushClipRect(needsCompositing, offset, Offset.zero & size, + (context, offset) { + context.paintChild(child, offset); + final canvas = context.canvas; + canvas.saveLayer(offset & size, Paint()..blendMode = BlendMode.dstIn); + canvas.translate(offset.dx, offset.dy); + mask(canvas); + canvas.restore(); + }, clipBehavior: Clip.antiAliasWithSaveLayer, oldLayer: oldLayer); } /// Shows [child], the revealed copy of the map, wherever a cone registered /// with [coverage] reaches. class ConeCoverageLayer extends SingleChildRenderObjectWidget { - const ConeCoverageLayer({ + ConeCoverageLayer({ super.key, required this.coverage, this.opacity = 1, - required super.child, - }); + Path? floor, + required Widget child, + }) : clipsToFloor = floor != null, + super(child: _cachedMap(floor, child)); final ConeCoverage coverage; + /// Whether the revealed map is clipped to the floor here, once, so cones + /// need not each clip themselves. + final bool clipsToFloor; + /// How strongly the whole layer shows, as placed widgets are dimmed in /// lineup mode. final double opacity; @override RenderConeCoverageLayer createRenderObject(BuildContext context) => - RenderConeCoverageLayer(coverage: coverage, opacity: opacity); + RenderConeCoverageLayer( + coverage: coverage, + opacity: opacity, + clipsToFloor: clipsToFloor, + ); @override void updateRenderObject( @@ -291,7 +309,8 @@ class ConeCoverageLayer extends SingleChildRenderObjectWidget { ) { renderObject ..coverage = coverage - ..opacity = opacity; + ..opacity = opacity + ..clipsToFloor = clipsToFloor; } } @@ -299,8 +318,17 @@ class RenderConeCoverageLayer extends RenderProxyBox { RenderConeCoverageLayer({ required ConeCoverage coverage, required double opacity, + required bool clipsToFloor, }) : _coverage = coverage, - _opacity = opacity; + _opacity = opacity, + _clipsToFloor = clipsToFloor; + + bool _clipsToFloor; + set clipsToFloor(bool value) { + if (value == _clipsToFloor) return; + _clipsToFloor = value; + markNeedsPaint(); + } ConeCoverage _coverage; set coverage(ConeCoverage value) { @@ -340,13 +368,17 @@ class RenderConeCoverageLayer extends RenderProxyBox { @override void paint(PaintingContext context, Offset offset) { final child = this.child; - if (child == null || _opacity <= 0) return; final areas = [ for (final area in _coverage.areas) if (area.attached && area.hasSize) area, ]; - if (areas.isEmpty) return; - _paintRevealed(context, offset, size, child, (canvas) { + if (child == null || _opacity <= 0 || areas.isEmpty) { + layer = null; + return; + } + layer = _paintRevealed( + context, needsCompositing, offset, size, child, layer as ClipRectLayer?, + (canvas) { // Each cone's strength as an opaque grey on black (an edge pixel it // half covers is half as grey), lightened together: wherever cones // overlap, the strongest one counts, in any order. The grey then @@ -373,6 +405,7 @@ class RenderConeCoverageLayer extends RenderProxyBox { Paint() ..color = Color.from( alpha: 1, red: strength, green: strength, blue: strength), + clipToFloor: !_clipsToFloor, ); canvas.restore(); canvas.restore(); @@ -393,7 +426,14 @@ class RenderConeCoverageLayer extends RenderProxyBox { /// Shows [child], the revealed copy of the map, wherever [cones] (a painter /// that draws every cone itself, opaque where each reaches) paints. class RevealedFloor extends SingleChildRenderObjectWidget { - const RevealedFloor({super.key, required this.cones, required super.child}); + /// [floor], when given, is where [child] shows at all: it is clipped to it + /// once, in the map's own cached layer, so [cones] need not clip to it. + RevealedFloor({ + super.key, + required this.cones, + Path? floor, + required Widget child, + }) : super(child: _cachedMap(floor, child)); final CustomPainter cones; @@ -433,9 +473,12 @@ class RenderRevealedFloor extends RenderProxyBox { @override void paint(PaintingContext context, Offset offset) { final child = this.child; - if (child == null) return; - _paintRevealed( - context, offset, size, child, (canvas) => _cones.paint(canvas, size)); + if (child == null) { + layer = null; + return; + } + layer = _paintRevealed(context, needsCompositing, offset, size, child, + layer as ClipRectLayer?, (canvas) => _cones.paint(canvas, size)); } } @@ -462,3 +505,28 @@ class _ConeCoverageHostState extends State { @override Widget build(BuildContext context) => widget.builder(context, _coverage); } + +/// The revealed map in a layer of its own, clipped to [floor] if given. +/// +/// Clipping the map once here, rather than every cone to the floor, is most +/// of what makes cones cheap on Windows: the engine draws there without a +/// stencil buffer, so a clip to a shape as complex as the floor is a mask +/// drawn on the CPU, and cones moving every frame need new ones. +Widget _cachedMap(Path? floor, Widget map) => RepaintBoundary( + child: floor == null + ? map + : ClipPath(clipper: _FloorClipper(floor), child: map), + ); + +class _FloorClipper extends CustomClipper { + const _FloorClipper(this.floor); + + final Path floor; + + @override + Path getClip(Size size) => floor; + + @override + bool shouldReclip(_FloorClipper oldClipper) => + !identical(oldClipper.floor, floor); +} diff --git a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart index 37fe11ea..29dcdb1a 100644 --- a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart +++ b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart @@ -41,6 +41,48 @@ class SvgHeightMapTransform { Offset sideWorldFromSource(Offset point, {required bool isAttack}) => offset + (point - (isAttack ? Offset.zero : defenseArtworkOffset)) * scale; + + /// Height-model space to the screen, seen from one side: an even scale + /// and an offset. + Float64List sourceToScreen({required bool isAttack}) { + final coordinates = CoordinateSystem.instance; + final screenScale = coordinates.worldHeightToScreen(scale); + final shift = coordinates.coordinateToScreen( + sideWorldFromSource(Offset.zero, isAttack: isAttack)); + return Float64List(16) + ..[0] = screenScale + ..[5] = screenScale + ..[10] = 1 + ..[12] = shift.dx + ..[13] = shift.dy + ..[15] = 1; + } + + /// The painted floor of [model] on screen: where a cone may reveal + /// anything. Off it, and in its holes, cones reveal nothing. Kept while + /// the screen stays put, so a clip to it can be drawn once and reused. + Path floorOnScreen(SvgHeightVisibility model, {required bool isAttack}) { + final toScreen = sourceToScreen(isAttack: isAttack); + final cached = _floors[model]; + if (cached != null && + cached.$1[0] == toScreen[0] && + cached.$1[12] == toScreen[12] && + cached.$1[13] == toScreen[13]) { + return cached.$2; + } + Path? floor; + for (final receiver in model.receivers) { + final next = SvgHeightViewConePainter.ringsPath(receiver.rings, + evenOdd: receiver.evenOdd); + floor = + floor == null ? next : Path.combine(PathOperation.union, floor, next); + } + final screen = (floor ?? Path()).transform(toScreen); + _floors[model] = (toScreen, screen); + return screen; + } + + static final _floors = Expando<(Float64List, Path)>(); } /// Compatibility accessors for Split-specific tests and audit tools. @@ -285,14 +327,40 @@ class SvgHeightViewConePainter extends CustomPainter /// Painting by itself: a grey fading toward the cone's reach. @override - void paint(Canvas canvas, Size size) => _paintArea(canvas, _fill); + void paint(Canvas canvas, Size size) => + _paintArea(canvas, _fill, clipToFloor: true); /// Painting into a [ConeCoverageLayer]: flat [fill]. @override - void paintArea(Canvas canvas, Size size, Paint fill) => - _paintArea(canvas, (_, __) => fill); + void paintArea(Canvas canvas, Size size, Paint fill, + {required bool clipToFloor}) => + _paintArea(canvas, (_, __) => fill, clipToFloor: clipToFloor); + + /// What a cone without height floors lights: what it sees, out to its + /// reach. Made once per painter, so painting it needs no clip. On Windows + /// the engine draws without a stencil buffer, so a clip to anything but a + /// rectangle, a rounded rectangle or a small convex polygon (unturned) is + /// a mask drawn on the CPU. + static final _lit = Expando(); + + /// Marks a painter whose lit area could not be made (path operations + /// can fail on awkward outlines); it is clipped instead, as it used to be. + static final _noLit = Path(); + + static Path _litArea(Path visibility, Offset apex, double radius) { + try { + return Path.combine( + PathOperation.intersect, + visibility, + Path()..addOval(Rect.fromCircle(center: apex, radius: radius)), + ); + } on StateError { + return _noLit; + } + } - void _paintArea(Canvas canvas, Paint Function(Offset, double) fill) { + void _paintArea(Canvas canvas, Paint Function(Offset, double) fill, + {required bool clipToFloor}) { if ((_visibility == null && visibilityPolygon.length < 3) || (_receiver == null && receiverRings.isEmpty)) return; final visibility = @@ -300,15 +368,23 @@ class SvgHeightViewConePainter extends CustomPainter final receiver = _receiver ?? _path(receiverRings, evenOdd: receiverEvenOdd); canvas.save(); - // Receiver fill is the last geometric clip. No cone pixels can appear in - // the SVG's blank exterior or in authored holes in the playable fill. - canvas.translate(receiverOffset.dx, receiverOffset.dy); - canvas.clipPath(receiver); - canvas.translate(-receiverOffset.dx, -receiverOffset.dy); + if (clipToFloor) { + // Receiver fill is the last geometric clip. No cone pixels can appear + // in the SVG's blank exterior or in authored holes in the playable + // fill. + canvas.translate(receiverOffset.dx, receiverOffset.dy); + canvas.clipPath(receiver); + canvas.translate(-receiverOffset.dx, -receiverOffset.dy); + } final floors = this.floors; if (floors == null) { - canvas.clipPath(visibility); - canvas.drawCircle(apex, radius, fill(apex, radius)); + final lit = _lit[this] ??= _litArea(visibility, apex, radius); + if (identical(lit, _noLit)) { + canvas.clipPath(visibility); + canvas.drawCircle(apex, radius, fill(apex, radius)); + } else { + canvas.drawPath(lit, fill(apex, radius)); + } } else { final (cone, sourceToCanvas, range) = floors; final origin = cone.sector!.first; diff --git a/lib/widgets/replay/replay_canvas.dart b/lib/widgets/replay/replay_canvas.dart index 23017468..64b4b411 100644 --- a/lib/widgets/replay/replay_canvas.dart +++ b/lib/widgets/replay/replay_canvas.dart @@ -229,6 +229,14 @@ class _ReplayCanvasState extends ConsumerState { isAttack: isAttack, apertureDegrees: cuts.apertureDegrees, ), + floor: SvgHeightMapTransform.forMap( + widget.map) + .floorOnScreen( + isAttack + ? cuts.attackModel + : cuts.defenseModel, + isAttack: isAttack, + ), child: revealedMap, ), null => ConeCoverageLayer( diff --git a/lib/widgets/replay/replay_cones.dart b/lib/widgets/replay/replay_cones.dart index e1081154..a903e4c0 100644 --- a/lib/widgets/replay/replay_cones.dart +++ b/lib/widgets/replay/replay_cones.dart @@ -10,12 +10,16 @@ import 'package:icarus/view_cone/vision_occluders.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone.dart'; /// Every view cone of a replay moment in one layer, drawn as the editor -/// draws placed cones (the same cut, clipped to the painted floor, opaque -/// where each reaches, as the mask a [RevealedFloor] shows the revealed map -/// through; see `lib/view_cone/cone_coverage.dart`) but clipped to the floor -/// once for all of them, from cuts made off the UI thread. A cut lagging its agent is -/// carried along and turned with it. Smokes and smoke walls are erased from -/// each cone they hide part of. +/// draws placed cones (the same cut, opaque where each reaches, as the mask +/// a [RevealedFloor] shows the revealed map through; see +/// `lib/view_cone/cone_coverage.dart`), from cuts made off the UI thread. A +/// cut lagging its agent is carried along and turned with it. Smokes and +/// smoke walls are erased from each cone they hide part of. +/// +/// Cones are not clipped to the painted floor here: the revealed map is, +/// once, with the map (see [RevealedFloor]). On Windows the engine can only +/// clip to a shape that complex with a mask drawn on the CPU, which with +/// cones moving every frame cost more than the cones themselves. class ReplayConesPainter extends CustomPainter { ReplayConesPainter({ required this.cones, @@ -41,7 +45,6 @@ class ReplayConesPainter extends CustomPainter { /// Where a cone reaches, for [RevealedFloor] to show the revealed map. static final _reach = Paint()..color = const Color(0xFFFFFFFF); - static final _floors = Expando<(Size, Float64List, Path)>(); @override void paint(Canvas canvas, Size size) { @@ -51,20 +54,7 @@ class ReplayConesPainter extends CustomPainter { final scale = coordinates.worldHeightToScreen(transform.scale); final toScreen = (Offset source) => coordinates.coordinateToScreen( transform.sideWorldFromSource(source, isAttack: isAttack)); - final shift = toScreen(Offset.zero); - final sourceToScreen = Float64List(16) - ..[0] = scale - ..[5] = scale - ..[10] = 1 - ..[12] = shift.dx - ..[13] = shift.dy - ..[15] = 1; - - canvas.save(); - // Cone pixels never land off the painted floor, nor in its holes. The - // fill is faint at any floor edge, which wall ink mostly covers, so a - // hard edge reads the same and spares an antialiased mask every frame. - canvas.clipPath(_floor(size, sourceToScreen), doAntiAlias: false); + final sourceToScreen = transform.sourceToScreen(isAttack: isAttack); for (final (:aim, :cut, :sight) in cones) { final cone = cut?.cone; final origin = cut?.origin; @@ -113,7 +103,6 @@ class ReplayConesPainter extends CustomPainter { canvas.restore(); } } - canvas.restore(); } /// The sector an agent at [apex] facing [facing] sees, out to [radius]. @@ -163,28 +152,6 @@ class ReplayConesPainter extends CustomPainter { return occluderShadows(origin, reach, near); } - /// The painted floor on the canvas, built once per canvas size and side. - Path _floor(Size size, Float64List sourceToScreen) { - final cached = _floors[model]; - if (cached != null && - cached.$1 == size && - cached.$2[0] == sourceToScreen[0] && - cached.$2[12] == sourceToScreen[12] && - cached.$2[13] == sourceToScreen[13]) { - return cached.$3; - } - Path? floor; - for (final receiver in model.receivers) { - final next = SvgHeightViewConePainter.ringsPath(receiver.rings, - evenOdd: receiver.evenOdd); - floor = - floor == null ? next : Path.combine(PathOperation.union, floor, next); - } - final screen = (floor ?? Path()).transform(sourceToScreen); - _floors[model] = (size, sourceToScreen, screen); - return screen; - } - @override bool shouldRepaint(ReplayConesPainter oldDelegate) => true; } diff --git a/test/cone_coverage_test.dart b/test/cone_coverage_test.dart index 7ad205f5..475fb537 100644 --- a/test/cone_coverage_test.dart +++ b/test/cone_coverage_test.dart @@ -15,7 +15,8 @@ class _BoxCone extends CustomPainter implements ConeAreaPainter { double get opacity => 1; @override - void paintArea(Canvas canvas, Size size, Paint fill) => + void paintArea(Canvas canvas, Size size, Paint fill, + {bool clipToFloor = true}) => canvas.drawRect(Offset.zero & size, fill); @override @@ -38,7 +39,8 @@ class _ShapedCone extends CustomPainter implements ConeAreaPainter { double get opacity => 1; @override - void paintArea(Canvas canvas, Size size, Paint fill) { + void paintArea(Canvas canvas, Size size, Paint fill, + {bool clipToFloor = true}) { if (layered) { canvas.saveLayer(Offset.zero & size, Paint()); canvas.drawRect(Offset.zero & size, fill); @@ -62,6 +64,18 @@ class _ShapedCone extends CustomPainter implements ConeAreaPainter { bool shouldRepaint(_ShapedCone oldDelegate) => false; } +/// A box cone that notes whether it was asked to clip itself to the floor. +class _NotingCone extends _BoxCone { + final clipped = []; + + @override + void paintArea(Canvas canvas, Size size, Paint fill, + {bool clipToFloor = true}) { + clipped.add(clipToFloor); + super.paintArea(canvas, size, fill, clipToFloor: clipToFloor); + } +} + const _floor = Color(0xFF202020); const _revealed = Color(0xFF808080); @@ -74,6 +88,7 @@ Widget _page( List? fades, List? painters, double turn = 0, + Path? paintedFloor, }) { final placed = Stack(children: [ for (final (i, at) in cones.indexed) @@ -108,6 +123,7 @@ Widget _page( Positioned.fill( child: ConeCoverageLayer( coverage: coverage, + floor: paintedFloor, child: const ColoredBox(color: _revealed), ), ), @@ -163,6 +179,30 @@ void main() { expect(await at(80, 80), _floor, reason: 'no cone'); }); + testWidgets( + 'a layer given the floor reveals only the floor, and spares cones ' + 'clipping themselves', (tester) async { + final coverage = ConeCoverage(); + addTearDown(coverage.dispose); + // The painted floor is the left 30 px; the cone covers x 10..50. + final cone = _NotingCone(); + await tester.pumpWidget(_page(coverage, const [Offset(10, 10)], + painters: [cone], + paintedFloor: Path()..addRect(const Rect.fromLTWH(0, 0, 30, 100)))); + final at = _pixels(tester); + expect(await at(20, 30), _revealed, reason: 'cone on the floor'); + expect(await at(40, 30), _floor, reason: 'cone off the floor'); + expect(cone.clipped, isNotEmpty); + expect(cone.clipped, everyElement(isFalse)); + + // Without the floor, cones still clip themselves. + final alone = _NotingCone(); + await tester + .pumpWidget(_page(coverage, const [Offset(10, 10)], painters: [alone])); + expect(alone.clipped, isNotEmpty); + expect(alone.clipped, everyElement(isTrue)); + }); + testWidgets('a cone with no layer above it paints itself', (tester) async { await tester.pumpWidget(_page(null, const [Offset(10, 10)])); final at = _pixels(tester); From a0a16efd6de7ee40d525d2e58dc2d60012e494eb Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 14 Oct 2026 20:20:57 -0400 Subject: [PATCH 2/2] Drop an import the cone painter no longer uses Co-Authored-By: Claude Opus 5.5 --- lib/widgets/replay/replay_cones.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/widgets/replay/replay_cones.dart b/lib/widgets/replay/replay_cones.dart index a903e4c0..669236ba 100644 --- a/lib/widgets/replay/replay_cones.dart +++ b/lib/widgets/replay/replay_cones.dart @@ -1,5 +1,4 @@ import 'dart:math' as math; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:icarus/const/coordinate_system.dart';