From 0adefefe20210eaaff8bcd4f027d986cec9874a3 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 18:58:19 -0400 Subject: [PATCH 1/4] Run the web cone query the way the native one does On the web there is no native library, so every cone runs the Dart query. It had fallen behind ish_query: it cast rays at seams and beside corners a wall runs straight through, kept every collinear outline point, and hashed Offsets and doubles, which is slow once compiled to JavaScript. Port ish_query's events, sector culling and same-edge outline simplification, index vertices once instead of hashing them, remember which vertices are seams until the wall mask changes, and test each tree node's bounds once per ray. A dragged Lotus cone's query drops from 9.3 ms to 1.7 ms (median) in Edge, and from 14.5 ms to 1.8 ms on Breeze. Over 256 Lotus queries the Dart and native cones cast the same rays and draw the same outline. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_height_visibility.dart | 392 +++++++++++++++++------ test/svg_height_visibility_test.dart | 29 +- 2 files changed, 318 insertions(+), 103 deletions(-) diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index b3e97449..806df2ef 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -275,53 +275,79 @@ class SvgHeightVisibility { double? _activeEye; List? _activeWalls; List _activeWallsAt(double eye) { - if (_activeEye == eye && _activeWalls != null) return _activeWalls!; - final active = - List.unmodifiable([for (final wall in walls) wall.blocks(eye)]); + final previous = _activeWalls; + if (_activeEye == eye && previous != null) return previous; _activeEye = eye; - _activeWalls = active; - return active; + final active = [for (final wall in walls) wall.blocks(eye)]; + // On a ramp the eye moves every frame while the mask rarely changes; + // keeping the same list keeps what is cached against it. + if (previous != null) { + var same = true; + for (var i = 0; i < active.length && same; i++) { + same = active[i] == previous[i]; + } + if (same) return previous; + } + return _activeWalls = List.unmodifiable(active); } // Static SVG topology, computed only if the Dart fallback is used. late final _crossings = _findCrossings(); - late final _vertexEdges = () { - final result = >{}; - for (var i = 0; i < _edges.length; i++) { - (result[_edges[i].a] ??= []).add(i); - (result[_edges[i].b] ??= []).add(i); - } - return result; - }(); + late final _topology = _VertexTopology(_edges); - /// Whether no active boundary turns at [point]: the edges two touching + /// Rays one either side of each vertex ray, resolving the two sides of a + /// corner; the native query's offset. + static const _cornerOffset = 1e-8; + + /// Whether no active boundary turns at [vertex]: the edges two touching /// pieces share cancel, and what remains runs straight through it, or /// nothing does. A ray there meets the wall the rays beside it meet, so it /// is not a visibility event. Pieces cut along one stroke leave such seams /// every metre or so; skipping them is most of a cone's rays. - bool _seam(Offset point, List active) { + bool _seam(int vertex, List active) { + if (!identical(active, _seamMask)) { + _seamMask = active; + _seams = Int8List(_topology.points.length); + } + final seams = _seams!; + if (seams[vertex] == 0) seams[vertex] = _findSeam(vertex, active) ? 1 : 2; + return seams[vertex] == 1; + } + + // Whether a vertex is a seam depends only on the active walls, which stay + // the same while a cone is dragged across one floor: 0 not yet known, + // 1 a seam, 2 not. + List? _seamMask; + Int8List? _seams; + + bool _findSeam(int vertex, List active) { + final topology = _topology; final live = [ - for (final id in _vertexEdges[point] ?? const []) - if (active[_edges[id].wall]) _edges[id] + for (final id in topology.edgesAt[vertex]) + if (active[_edges[id].wall]) id ]; final cancelled = List.filled(live.length, false); for (var i = 0; i < live.length; i++) { for (var j = i + 1; j < live.length && !cancelled[i]; j++) { if (cancelled[j]) continue; // A shared side only when the two walls lie on either side of it. - final first = live[i], second = live[j]; + final first = _edges[live[i]], second = _edges[live[j]]; if (first.interior == null || second.interior == null) continue; - final same = first.a == second.a && first.b == second.b; - final reversed = first.a == second.b && first.b == second.a; + final same = topology.aVertex[live[i]] == topology.aVertex[live[j]] && + topology.bVertex[live[i]] == topology.bVertex[live[j]]; + final reversed = + topology.aVertex[live[i]] == topology.bVertex[live[j]] && + topology.bVertex[live[i]] == topology.aVertex[live[j]]; if ((same && first.interior != second.interior) || (reversed && first.interior == second.interior)) { cancelled[i] = cancelled[j] = true; } } } + final at = topology.points[vertex]; final away = [ for (var i = 0; i < live.length; i++) - if (!cancelled[i]) (live[i].a == point ? live[i].b : live[i].a) - point + if (!cancelled[i]) _away(live[i], vertex) - at ]; if (away.isEmpty) return true; if (away.length != 2) return false; @@ -330,6 +356,32 @@ class SvgHeightVisibility { 1e-12 * away[0].distance * away[1].distance; } + /// Whether the wall runs straight across the ray at [vertex], [delta] + /// from the origin: its two active edges there leave to either side of the + /// ray's line. Rays just beside such a vertex meet those two edges, so only + /// the vertex ray adds a corner. Rays beside the vertex matter where the + /// wall turns back (a silhouette) and something further can show past it. + bool _passThrough(int vertex, Offset delta, List active) { + final at = _topology.points[vertex]; + var count = 0; + var first = 0.0; + for (final id in _topology.edgesAt[vertex]) { + if (!active[_edges[id].wall]) continue; + if (++count > 2) return false; + final side = _cross(delta, _away(id, vertex) - at); + if (count == 1) { + first = side; + } else if (first * side >= 0) { + return false; + } + } + return count == 2; + } + + /// The far end of edge [id] from [vertex]. + Offset _away(int id, int vertex) => + _topology.aVertex[id] == vertex ? _edges[id].b : _edges[id].a; + List<(Offset, int, int)> _findCrossings() { double cross(Offset a, Offset b) => a.dx * b.dy - a.dy * b.dx; final result = <(Offset, int, int)>[]; @@ -903,14 +955,15 @@ class SvgHeightVisibility { nativeMicros: result.queryMicros), eyeElevationMeters: ground == null ? null : eye); } + // The native query's algorithm, in Dart for the web: the same events, + // rays and outline as ish_query in native/height. Every structure is a + // list or an index: hashing points and angles dominated the cost once + // Dart is compiled to JavaScript. final half = apertureRadians / 2; - final angles = { - for (var i = 0; i <= arcSteps; i++) - -half + apertureRadians * i / arcSteps, - }; - final arcAngles = [ - for (var i = 0; i <= arcSteps; i++) -half + apertureRadians * i / arcSteps - ]; + final arcAngles = Float64List(arcSteps + 1); + for (var i = 0; i <= arcSteps; i++) { + arcAngles[i] = -half + apertureRadians * i / arcSteps; + } final arcHits = [ for (final angle in arcAngles) _cast( @@ -921,9 +974,6 @@ class SvgHeightVisibility { active, stats), ]; - final arcResults = { - for (var i = 0; i < arcAngles.length; i++) arcAngles[i]: arcHits[i], - }; bool hiddenEvent(double angle, Offset delta) { if (apertureRadians / arcSteps >= math.pi || angle <= -half || @@ -934,33 +984,61 @@ class SvgHeightVisibility { final first = arcHits[interval]?._edgeIndex; if (first == null || first != arcHits[interval + 1]?._edgeIndex || - angle - 1e-8 < arcAngles[interval] || - angle + 1e-8 > arcAngles[interval + 1]) return false; + angle - _cornerOffset < arcAngles[interval] || + angle + _cornerOffset > arcAngles[interval + 1]) return false; final distance = delta.distance; final hit = _edges[first].intersection(origin, delta / distance, distance); return hit != null && hit < distance - 1e-7; } + final angles = [...arcAngles]; + // Rays aimed at a vertex or at a wall's crossing of the range circle + // stay in the outline even when their neighbours meet the same edge. + final vertexAngles = []; + void add(double event, {required bool vertex}) { + if (event < -half || event > half) return; + angles.add(event); + if (vertex) vertexAngles.add(event); + } + + void emit(double angle, {bool beside = true}) { + if (beside) add(angle - _cornerOffset, vertex: false); + add(angle, vertex: true); + if (beside) add(angle + _cornerOffset, vertex: false); + } + + // A vertex outside the aperture, with slack for the corner offsets, + // cannot start a ray inside it: skip its trigonometry. + final facing = + Offset(math.cos(directionRadians), math.sin(directionRadians)); + final cullSector = half + 1e-6 < math.pi; + final cosSlack = math.cos(math.min(math.pi, half + 1e-6)); + bool inSector(Offset delta) => + !cullSector || _dot(delta, facing) >= cosSlack * delta.distance; + double angleOf(Offset delta) { + final relative = math.atan2(delta.dy, delta.dx) - directionRadians; + return math.atan2(math.sin(relative), math.cos(relative)); + } + + final rangeSquared = range * range; // Overlapping painted strokes create visibility corners at their crossing. // Events behind a proven nearer straight wall cannot change the boundary. for (final crossing in _crossings) { if (!active[crossing.$2] || !active[crossing.$3]) continue; final delta = crossing.$1 - origin; - if (delta.distanceSquared > range * range || delta == Offset.zero) + if (delta.distanceSquared > rangeSquared || delta == Offset.zero) { continue; - final relative = math.atan2(delta.dy, delta.dx) - directionRadians; - final angle = math.atan2(math.sin(relative), math.cos(relative)); - if (hiddenEvent(angle, delta)) continue; - for (final offset in [-1e-8, 0.0, 1e-8]) { - if (angle + offset >= -half && angle + offset <= half) - angles.add(angle + offset); } + if (!inSector(delta)) continue; + final angle = angleOf(delta); + if (hiddenEvent(angle, delta)) continue; + emit(angle); } final preparationMicros = timer.elapsedMicroseconds; final candidates = []; _tree?.query(Rect.fromCircle(center: origin, radius: range), candidates); - final seenPoints = {}; + final topology = _topology; for (final id in candidates) { final edge = _edges[id]; if (!active[edge.wall]) continue; @@ -974,28 +1052,28 @@ class SvgHeightVisibility { -(relative.dx * segment.dx + relative.dy * segment.dy) / lengthSquared; final closest = relative + segment * projection; - final remaining = range * range - closest.distanceSquared; + final remaining = rangeSquared - closest.distanceSquared; if (remaining >= 0) { final offset = math.sqrt(remaining / lengthSquared); for (final t in [projection - offset, projection + offset]) { if (t < 0 || t > 1) continue; final delta = relative + segment * t; - final relativeAngle = - math.atan2(delta.dy, delta.dx) - directionRadians; - final angle = - math.atan2(math.sin(relativeAngle), math.cos(relativeAngle)); - if (angle >= -half && angle <= half && !hiddenEvent(angle, delta)) + if (!inSector(delta)) continue; + final angle = angleOf(delta); + if (angle >= -half && angle <= half && !hiddenEvent(angle, delta)) { angles.add(angle); + vertexAngles.add(angle); + } } } - for (final point in [edge.a, edge.b]) { - if (!seenPoints.add(point) || _seam(point, active)) continue; - final delta = point - origin; - if (delta.distanceSquared > range * range || delta == Offset.zero) - continue; - final relativeAngle = math.atan2(delta.dy, delta.dx) - directionRadians; - final angle = - math.atan2(math.sin(relativeAngle), math.cos(relativeAngle)); + for (var end = 0; end < 2; end++) { + final vertex = end == 0 ? topology.aVertex[id] : topology.bVertex[id]; + if (_seam(vertex, active)) continue; + final delta = (end == 0 ? edge.a : edge.b) - origin; + final distanceSquared = delta.distanceSquared; + if (distanceSquared > rangeSquared || delta == Offset.zero) continue; + if (!inSector(delta)) continue; + final angle = angleOf(delta); // If both bounding arc rays hit the same straight segment, that // segment covers the angular interval. A vertex strictly behind it // cannot change the visible boundary. Nearer corners still add rays. @@ -1009,32 +1087,58 @@ class SvgHeightVisibility { final last = arcHits[interval + 1]?._edgeIndex; if (first != null && first == last && - angle - 1e-8 >= arcAngles[interval] && - angle + 1e-8 <= arcAngles[interval + 1]) { - final distance = delta.distance; + angle - _cornerOffset >= arcAngles[interval] && + angle + _cornerOffset <= arcAngles[interval + 1]) { + final distance = math.sqrt(distanceSquared); final hit = _edges[first].intersection(origin, delta / distance, distance); if (hit != null && hit < distance - 1e-7) continue; } } - // Adjacent rays resolve the two sides of a corner. They do not offset - // wall contacts: every endpoint still intersects the actual footprint. - for (final offset in [-1e-8, 0.0, 1e-8]) { - final value = angle + offset; - if (value >= -half && value <= half) angles.add(value); - } + // Rays just beside a vertex the wall runs straight across meet its + // two edges, so only the vertex ray adds a corner. + emit(angle, beside: !_passThrough(vertex, delta, active)); } } - final sorted = angles.toList()..sort(); + final sorted = _sortedUnique(angles); + final vertexSorted = _sortedUnique(vertexAngles); final candidateMicros = timer.elapsedMicroseconds - preparationMicros; + final polygon = [origin]; + SvgVisibilityHit? previousHit; + var sameEdgeRun = 0; + var runAnchor = Offset.zero; for (final angle in sorted) { final direction = Offset(math.cos(directionRadians + angle), math.sin(directionRadians + angle)); - final hit = arcResults.containsKey(angle) - ? arcResults[angle] + final arc = _indexOf(arcAngles, angle); + final hit = arc >= 0 + ? arcHits[arc] : _cast(origin, direction, range, active, stats); - polygon.add(hit?.point ?? origin + direction * range); + final point = hit?.point ?? origin + direction * range; + // Rays meeting the same edge in a row lie on one straight line; the + // middle ones add nothing to the outline. Vertex rays always stay. + if (_indexOf(vertexSorted, angle) < 0 && + hit != null && + previousHit != null && + hit._edgeIndex == previousHit._edgeIndex) { + sameEdgeRun++; + if (sameEdgeRun == 2) { + polygon.add(point); + } else if (_betweenOnSameLine(runAnchor, polygon.last, point)) { + polygon[polygon.length - 1] = point; + } else { + // Endpoint roundoff can give an excursion the same edge as its + // neighbours; keep it unless the points prove it redundant. + runAnchor = polygon.last; + polygon.add(point); + } + } else { + previousHit = hit; + sameEdgeRun = 1; + runAnchor = point; + polygon.add(point); + } } return SvgVisibilityCone( List.unmodifiable(polygon), @@ -1094,11 +1198,17 @@ class SvgHeightVisibility { if (tree == null || range == 0) return null; var best = range; int? wall, bestEdge; + final rootEntry = tree.entry(origin, direction, best); + if (rootEntry == null) return null; + // Each node with where the ray enters its bounds. A nearer hit found + // since it was pushed rules it out once the entry lies beyond it. final stack = <_EdgeNode>[tree]; + final entries = [rootEntry]; while (stack.isNotEmpty) { final node = stack.removeLast(); + final entry = entries.removeLast(); stats.cells++; - if (node.entry(origin, direction, best) == null) continue; + if (entry > best) continue; final ids = node.ids; if (ids != null) { for (final id in ids) { @@ -1119,16 +1229,26 @@ class SvgHeightVisibility { // Visit the nearer branch first so its hit prunes the farther one. if (a != null && b != null) { if (a <= b) { - stack.add(right); - stack.add(left); + stack + ..add(right) + ..add(left); + entries + ..add(b) + ..add(a); } else { - stack.add(left); - stack.add(right); + stack + ..add(left) + ..add(right); + entries + ..add(a) + ..add(b); } } else if (a != null) { stack.add(left); + entries.add(a); } else if (b != null) { stack.add(right); + entries.add(b); } } } @@ -1309,32 +1429,21 @@ class SvgVisibilityCone { return visibility; }(); - /// The cone outline under a 4x4 column-major affine [transform], built - /// without allocating a point object per vertex. + /// The cone outline under a 4x4 column-major affine [transform]. One + /// polygon rather than a line per point: on the web each line is a call + /// into CanvasKit, repeated every frame the outline is drawn. Path outlinePath(Float64List transform) { - final path = Path()..fillType = PathFillType.nonZero; final points = xy; - if (points == null) { - final source = _polygon!; - for (var i = 0; i < source.length; i++) { - final p = source[i]; - final x = transform[0] * p.dx + transform[4] * p.dy + transform[12]; - final y = transform[1] * p.dx + transform[5] * p.dy + transform[13]; - i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); - } - } else { - for (var i = 0; i < points.length; i += 2) { - final x = transform[0] * points[i] + - transform[4] * points[i + 1] + - transform[12]; - final y = transform[1] * points[i] + - transform[5] * points[i + 1] + - transform[13]; - i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); - } - } - path.close(); - return path; + final count = points == null ? _polygon!.length : points.length ~/ 2; + return Path() + ..addPolygon( + List.generate(count, (i) { + final x = points == null ? _polygon![i].dx : points[2 * i]; + final y = points == null ? _polygon![i].dy : points[2 * i + 1]; + return Offset(transform[0] * x + transform[4] * y + transform[12], + transform[1] * x + transform[5] * y + transform[13]); + }), + true); } } @@ -1472,7 +1581,9 @@ enum _Side { left, right } class _Edge { _Edge(this.a, this.b, this.wall, {required this.interior}) - : _inverseLength = 1 / (b - a).distance; + : _inverseLength = 1 / (b - a).distance, + _ex = b.dx - a.dx, + _ey = b.dy - a.dy; final Offset a, b; final int wall; @@ -1481,18 +1592,24 @@ class _Edge { final _Side? interior; final double _inverseLength; + /// `b - a`, kept as numbers: every ray tests many edges, and on the web + /// each intermediate [Offset] is an allocation. + final double _ex, _ey; + double? intersection(Offset origin, Offset direction, double range) { - final edge = b - a, relative = a - origin; - final determinant = _cross(direction, edge); + final dx = direction.dx, dy = direction.dy; + final rx = a.dx - origin.dx, ry = a.dy - origin.dy; + final determinant = dx * _ey - dy * _ex; if (determinant == 0) { + final relative = a - origin; if (_cross(relative, direction) != 0) return null; final first = _dot(relative, direction), last = _dot(b - origin, direction); final entry = math.max(0.0, math.min(first, last)); return math.max(first, last) >= 0 && entry <= range ? entry : null; } - final distance = _cross(relative, edge) / determinant; - final along = _cross(relative, direction) / determinant; + final distance = (rx * _ey - ry * _ex) / determinant; + final along = (rx * dy - ry * dx) / determinant; // atan2/sin/cos can put a ray aimed at an exact corner a few ulps beyond // both adjoining endpoints. Admit that much along the wall: a sliver of // what the 1e-8 radian rays beside a corner pass it by at the same @@ -1641,3 +1758,74 @@ List> _rings(Map row) { if (result.isEmpty) throw const FormatException('Empty footprint.'); return List.unmodifiable(result); } + +/// The edges' endpoints as vertex ids, the edges meeting at each vertex and +/// where each vertex is. Built once, so a cone query never hashes a point. +class _VertexTopology { + factory _VertexTopology(List<_Edge> edges) { + final ids = {}; + final points = []; + int id(Offset point) => ids.putIfAbsent(point, () { + points.add(point); + return points.length - 1; + }); + final a = Int32List(edges.length), b = Int32List(edges.length); + for (var i = 0; i < edges.length; i++) { + a[i] = id(edges[i].a); + b[i] = id(edges[i].b); + } + final at = List.generate(points.length, (_) => []); + for (var i = 0; i < edges.length; i++) { + at[a[i]].add(i); + at[b[i]].add(i); + } + return _VertexTopology._( + a, b, [for (final list in at) Int32List.fromList(list)], points); + } + + _VertexTopology._(this.aVertex, this.bVertex, this.edgesAt, this.points); + + final Int32List aVertex, bVertex; + final List edgesAt; + final List points; +} + +/// [values] sorted, with exact repeats removed. +List _sortedUnique(List values) { + values.sort(); + final out = []; + for (final value in values) { + if (out.isEmpty || out.last != value) out.add(value); + } + return out; +} + +/// Where [value] is in the sorted [values], or -1. +int _indexOf(List values, double value) { + var low = 0, high = values.length - 1; + while (low <= high) { + final middle = (low + high) >> 1; + final candidate = values[middle]; + if (candidate < value) { + low = middle + 1; + } else if (candidate > value) { + high = middle - 1; + } else { + return middle; + } + } + return -1; +} + +/// Whether [middle] lies on the segment from [first] to [last], up to +/// roundoff: the native query's test for a redundant outline point. +bool _betweenOnSameLine(Offset first, Offset middle, Offset last) { + final span = last - first; + final offset = middle - first; + final lengthSquared = _dot(span, span); + final along = _dot(offset, span); + if (lengthSquared == 0 || along < 0 || along > lengthSquared) return false; + final length = math.sqrt(lengthSquared); + final roundoff = 64 * 2.220446049250313e-16 * math.max(1.0, length); + return _cross(offset, span).abs() <= roundoff * length; +} diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index c79505d0..09fd285c 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -495,10 +495,37 @@ void main() { wall('piece-$y', [rectangle(10, y, 11, y + 1)]) ]); expect(area(pieces.polygon), closeTo(area(whole.polygon), 1e-9)); - expect(pieces.polygon.length, whole.polygon.length, + // Rays, not outline points: one wall's face collapses to its two ends, + // while each piece keeps its own edge in the outline. + expect(pieces.stats.rayCount, whole.stats.rayCount, reason: 'seams between the pieces are not visibility events'); }); + test('a seam at one eye height is a corner at another', () { + // Below 2 m the low wall and the tall wall meet along y = 0, so their + // shared corners are seams. Above it only the tall wall blocks, and the + // same points are its corners. + final walls = [ + wall('low', [rectangle(10, -5, 11, 0)], + bands: [ + [0, 2] + ]), + wall('tall', [rectangle(10, 0, 11, 5)]), + ]; + SvgVisibilityCone at(SvgHeightVisibility model, double camera) => + model.cone( + origin: Offset.zero, + directionRadians: 0, + range: 100, + apertureRadians: 1.5, + cameraHeightMeters: camera); + final reused = SvgHeightVisibility.fromJson(data(walls)); + at(reused, 1.75); + final fresh = SvgHeightVisibility.fromJson(data(walls)); + expect(at(reused, 3).polygon, at(fresh, 3).polygon); + expect(at(reused, 1.75).polygon, at(fresh, 1.75).polygon); + }); + test('two walls over the same footprint keep their corners', () { final ring = rectangle(10, 1.13, 11, 3.17); final reversed = [ From 11da90dabda4ab38adb1604b6632ab5df2680c56 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 18:58:20 -0400 Subject: [PATCH 2/4] Build cone clip paths as one polygon each Flutter's web engine drops every built path at the end of a frame, so a path made of lineTo calls is rebuilt point by point, one call into CanvasKit each, every frame it is drawn. The map's playable-area clip has thousands of points, which cost about 3 ms a frame while a cone was dragged. addPolygon hands each ring over in one call; the shape is the same, and screenshots of all four test maps match main pixel for pixel. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../draggable_widgets/utilities/svg_height_view_cone.dart | 7 +------ .../draggable_widgets/utilities/view_cone_widget.dart | 8 +------- 2 files changed, 2 insertions(+), 13 deletions(-) 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 81eb82e3..d4e7baab 100644 --- a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart +++ b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart @@ -332,12 +332,7 @@ class SvgHeightViewConePainter extends CustomPainter { final path = Path() ..fillType = evenOdd ? PathFillType.evenOdd : PathFillType.nonZero; for (final ring in rings) { - if (ring.isEmpty) continue; - path.moveTo(ring.first.dx, ring.first.dy); - for (final point in ring.skip(1)) { - path.lineTo(point.dx, point.dy); - } - path.close(); + if (ring.isNotEmpty) path.addPolygon(ring, true); } return path; } diff --git a/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart b/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart index 616735aa..019d025f 100644 --- a/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/view_cone_widget.dart @@ -429,13 +429,7 @@ class ViewConePainter extends CustomPainter { // second circular screen-space clip would trim them on stretched maps. canvas.clipPath(clipPath); if (visibilityPolygon != null && visibilityPolygon!.length >= 3) { - final visibilityPath = Path() - ..moveTo(visibilityPolygon!.first.dx, visibilityPolygon!.first.dy); - for (final point in visibilityPolygon!.skip(1)) { - visibilityPath.lineTo(point.dx, point.dy); - } - visibilityPath.close(); - canvas.clipPath(visibilityPath); + canvas.clipPath(Path()..addPolygon(visibilityPolygon!, true)); } final gradientCenter = apex; From a11220fe0432bb6055c6f0e542978d7a313701ad Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 18:58:23 -0400 Subject: [PATCH 3/4] Build the web cone query's topology while the map loads The Dart query found wall crossings and vertex topology on its first cone, a 25 to 45 ms stall the first time a cone was dragged on each map. Build them when the map's sightlines load instead, only when the native library is unavailable. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/providers/svg_height_runtime_provider.dart | 6 ++++-- lib/view_cone/svg_height_visibility.dart | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/providers/svg_height_runtime_provider.dart b/lib/providers/svg_height_runtime_provider.dart index 73da48ac..fd05a21f 100644 --- a/lib/providers/svg_height_runtime_provider.dart +++ b/lib/providers/svg_height_runtime_provider.dart @@ -180,8 +180,10 @@ final svgHeightRuntimeProvider = FutureProvider.autoDispose if (disposed) return null; final runtime = SplitSvgHeightRuntime.forMap(map, attack, defense); currentRuntime = runtime; - attack.enableNativeAcceleration(); - defense.enableNativeAcceleration(); + for (final side in [attack, defense]) { + // Without the native library (the web), cones use the Dart query. + if (!side.enableNativeAcceleration()) side.prepareDartQuery(); + } return runtime; } catch (error, stack) { AppErrorReporter.reportError( diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 806df2ef..902b8ce0 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -408,6 +408,14 @@ class SvgHeightVisibility { bool get usesNativeAcceleration => _native != null; + /// Builds the wall crossings and topology the Dart cone query reads, which + /// it would otherwise build on its first cone: a frame-long stall the + /// first time a cone is dragged on the web. + void prepareDartQuery() { + _crossings; + _topology; + } + /// Height selection and SVG footprints remain owned by this model. Native /// acceleration executes the same two-dimensional edge query only. bool enableNativeAcceleration({String? libraryPath}) { From 2c1e4e85e0878bd09f068f03f2dca0d254b7d54e Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Mon, 5 Oct 2026 19:03:59 -0400 Subject: [PATCH 4/4] Count a ray that misses every wall as one visited node Matches what the native query and the old Dart query report. The seam test now checks the return to the lower eye against a fresh model too, so a cache that only resets when walls stop blocking would fail it. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_height_visibility.dart | 5 ++++- test/svg_height_visibility_test.dart | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 902b8ce0..a2a70b20 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -1207,7 +1207,10 @@ class SvgHeightVisibility { var best = range; int? wall, bestEdge; final rootEntry = tree.entry(origin, direction, best); - if (rootEntry == null) return null; + if (rootEntry == null) { + stats.cells++; + return null; + } // Each node with where the ray enters its bounds. A nearer hit found // since it was pushed rules it out once the entry lies beyond it. final stack = <_EdgeNode>[tree]; diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 09fd285c..276aad78 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -506,10 +506,11 @@ void main() { // shared corners are seams. Above it only the tall wall blocks, and the // same points are its corners. final walls = [ - wall('low', [rectangle(10, -5, 11, 0)], - bands: [ - [0, 2] - ]), + wall('low', [ + rectangle(10, -5, 11, 0) + ], bands: [ + [0, 2] + ]), wall('tall', [rectangle(10, 0, 11, 5)]), ]; SvgVisibilityCone at(SvgHeightVisibility model, double camera) => @@ -519,11 +520,13 @@ void main() { range: 100, apertureRadians: 1.5, cameraHeightMeters: camera); - final reused = SvgHeightVisibility.fromJson(data(walls)); + SvgHeightVisibility fresh() => SvgHeightVisibility.fromJson(data(walls)); + final reused = fresh(); at(reused, 1.75); - final fresh = SvgHeightVisibility.fromJson(data(walls)); - expect(at(reused, 3).polygon, at(fresh, 3).polygon); - expect(at(reused, 1.75).polygon, at(fresh, 1.75).polygon); + expect(at(reused, 3).polygon, at(fresh(), 3).polygon, + reason: 'the low wall stopped blocking'); + expect(at(reused, 1.75).polygon, at(fresh(), 1.75).polygon, + reason: 'the low wall blocks again'); }); test('two walls over the same footprint keep their corners', () {