From fbe7bce7881f2fc5c086f24163020ed23c11fe88 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 22:06:38 -0400 Subject: [PATCH 01/15] Cones: skip seam vertices, close corner roundoff, paint floors in layers Three changes to the view-cone runtime, none of which change what a cone covers: - A vertex where touching pieces of one stroke meet, with the boundary running straight through it, is no visibility event. Skipping it drops 35-65% of a cone's polygon points and 20-45% of its time (native and Dart alike); cone areas match the old ones to 1e-8. - A ray aimed at an exact corner could land 1e-11 past both adjoining edges and run on to the range (9 of 485,570 vertex rays in an all-map audit). Endpoint slack is now 1e-10 SVG units along the wall instead of 1e-12 of the edge, far below the 1e-8 radian rays that must pass a corner. - Cones that overlook Lotus's and Icebox's measured floors no longer build their lit area with ~1,000 sequential path subtractions (160-190 ms per cone). The floor pass now returns its shadows as shapes, and painters fill each floor and erase them in a layer. The boolean area is kept, built only when the sightline report asks for it. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_floor_visibility.dart | 152 ++++++++++++--- lib/view_cone/svg_height_visibility.dart | 184 +++++++++++++----- .../utilities/svg_height_view_cone.dart | 57 ++++-- native/height/icarus_svg_height.cpp | 59 +++++- 4 files changed, 353 insertions(+), 99 deletions(-) diff --git a/lib/view_cone/svg_floor_visibility.dart b/lib/view_cone/svg_floor_visibility.dart index d3a60491..22d67eee 100644 --- a/lib/view_cone/svg_floor_visibility.dart +++ b/lib/view_cone/svg_floor_visibility.dart @@ -12,21 +12,93 @@ class SvgFloorOccluder { final Rect bounds; } -/// Projects the same SVG wall volumes onto one measured, horizontal floor. -/// A target stands on that floor, so its eye can differ from the observer's. -/// Clipping projected faces in double precision keeps distant shadows out of -/// Skia's float coordinates and preserves holes in the painted footprints. -Path visibleSvgFloor({ - required Path floor, - required Path sector, +/// One measured destination floor as a cone overlooks it. +class SvgFloorLayer { + const SvgFloorLayer(this.floor, this.shadows); + + /// The floor's footprint, in source coordinates. + final Path floor; + final SvgFloorShadows shadows; +} + +/// The shadows the SVG wall volumes cast onto one measured, horizontal floor, +/// for a target standing on it, whose eye can differ from the observer's. +/// +/// They are kept as shapes rather than subtracted: a painter fills the floor +/// and erases them, so a cone costs no path operations however many walls +/// stand between it and the floor. Faces are wound one way, so their nonzero +/// union is their shadow; caps keep their footprint's fill rule and holes. +class SvgFloorShadows { + SvgFloorShadows._(this.faces, this.caps); + + final List> faces; + final List<(List>, bool)> caps; + + late final Path facesPath = () { + final path = Path()..fillType = PathFillType.nonZero; + for (final face in faces) { + path.addPolygon(face, true); + } + return path; + }(); + + late final List capPaths = [ + for (final (rings, evenOdd) in caps) + Path() + ..fillType = evenOdd ? PathFillType.evenOdd : PathFillType.nonZero + ..addPolygonRings(rings) + ]; + + /// Erases the shadows from what [canvas] has drawn so far in this layer. + void erase(Canvas canvas) { + final clear = Paint()..blendMode = BlendMode.clear; + for (final cap in capPaths) { + canvas.drawPath(cap, clear); + } + canvas.drawPath(facesPath, clear); + } + + /// [area] less the shadows, as one path. Each shadow is subtracted on its + /// own: near a wall endpoint, overlapping thin contours in one path can + /// make Skia's path operation fail. Slow; for reports and tests. + Path subtractFrom(Path area) { + var visible = area; + for (final cap in capPaths) { + if (!cap.getBounds().isEmpty) { + visible = Path.combine(PathOperation.difference, visible, cap); + } + } + for (final face in faces) { + final side = Path()..addPolygon(face, true); + if (!side.getBounds().isEmpty) { + visible = Path.combine(PathOperation.difference, visible, side); + } + } + return visible; + } +} + +extension on Path { + void addPolygonRings(List> rings) { + for (final ring in rings) { + addPolygon(ring, true); + } + } +} + +/// Projects the wall volumes onto a target standing anywhere in [bounds]. +/// Clipping projected faces to [bounds] in double precision keeps distant +/// shadows out of Skia's float coordinates. +SvgFloorShadows svgFloorShadows({ + required Rect bounds, required Offset origin, required double observerEye, required double targetEye, required Iterable walls, }) { - var visible = Path.combine(PathOperation.intersect, floor, sector); - final bounds = visible.getBounds(); - if (bounds.isEmpty) return visible; + final faces = >[]; + final caps = <(List>, bool)>[]; + if (bounds.isEmpty) return SvgFloorShadows._(faces, caps); final influence = bounds.expandToInclude(Rect.fromPoints(origin, origin)); final delta = targetEye - observerEye; for (final wall in walls) { @@ -47,19 +119,13 @@ Path visibleSvgFloor({ final near = 1 / last; final far = first == 0 ? null : 1 / first; // Horizontal caps matter when an eye starts below an overhead footprint. - // Subtraction distributes over their union; even-odd holes stay intact. for (final factor in [near, if (far != null && far != near) far]) { - final cap = Path() - ..fillType = - wall.evenOdd ? PathFillType.evenOdd : PathFillType.nonZero; - for (final ring in wall.rings) { - final clipped = _clipRect( - [for (final p in ring) origin + (p - origin) * factor], bounds); - if (clipped.length >= 3) cap.addPolygon(clipped, true); - } - if (!cap.getBounds().isEmpty) { - visible = Path.combine(PathOperation.difference, visible, cap); - } + final rings = [ + for (final ring in wall.rings) + _clipRect( + [for (final p in ring) origin + (p - origin) * factor], bounds) + ].where((ring) => ring.length >= 3).toList(); + if (rings.isNotEmpty) caps.add((rings, wall.evenOdd)); } for (final ring in wall.rings) { for (var i = 0; i < ring.length; i++) { @@ -78,20 +144,42 @@ Path visibleSvgFloor({ face = _clip( face, (p) => sign * _cross(edge, p - (origin + av * far))); } - if (face.length >= 3) { - // Subtraction distributes over the union of projected faces. - // Keep each face simple: near a wall endpoint, overlapping thin - // contours in one path can make Skia's path operation fail. - final side = Path()..addPolygon(face, true); - if (!side.getBounds().isEmpty) { - visible = Path.combine(PathOperation.difference, visible, side); - } - } + if (face.length >= 3) faces.add(_counterClockwise(face)); } } } } - return visible; + return SvgFloorShadows._(faces, caps); +} + +/// [floor] inside [sector], less the shadows the walls cast on a target +/// standing on it. The boolean form of what [SvgFloorShadows] paints. +Path visibleSvgFloor({ + required Path floor, + required Path sector, + required Offset origin, + required double observerEye, + required double targetEye, + required Iterable walls, +}) { + final visible = Path.combine(PathOperation.intersect, floor, sector); + final bounds = visible.getBounds(); + if (bounds.isEmpty) return visible; + return svgFloorShadows( + bounds: bounds, + origin: origin, + observerEye: observerEye, + targetEye: targetEye, + walls: walls) + .subtractFrom(visible); +} + +List _counterClockwise(List points) { + var twiceArea = 0.0; + for (var i = 0; i < points.length; i++) { + twiceArea += _cross(points[i], points[(i + 1) % points.length]); + } + return twiceArea < 0 ? points.reversed.toList() : points; } List _rectangle(Rect r) => diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 9af435da..9b149b08 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -210,6 +210,43 @@ class SvgHeightVisibility { // 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; + }(); + + /// Whether no active boundary turns at [point]: 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) { + final live = [ + for (final id in _vertexEdges[point] ?? const []) + if (active[_edges[id].wall]) _edges[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] && live[i].a == live[j].b && live[i].b == live[j].a) { + cancelled[i] = cancelled[j] = true; + } + } + } + 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 (away.isEmpty) return true; + if (away.length != 2) return false; + return _dot(away[0], away[1]) < 0 && + _cross(away[0], away[1]).abs() <= + 1e-12 * away[0].distance * away[1].distance; + } List<(Offset, int, int)> _findCrossings() { double cross(Offset a, Offset b) => a.dx * b.dy - a.dy * b.dx; @@ -613,49 +650,38 @@ class SvgHeightVisibility { arcSteps: arcSteps); if (sightlineFloors.isEmpty || base.polygon.length < 3) return base; final eye = base.eyeElevationMeters!; - Path? visibility; - Path? sector; + final reach = Rect.fromCircle(center: origin, radius: range); + final floors = []; for (var i = 0; i < sightlineFloors.length; i++) { final targetEye = sightlineFloors[i].surfaceElevationMeters! + (cameraHeightMeters ?? defaultCameraHeightMeters); if (targetEye == eye) continue; final floor = _floorPaths[i]; - if (!floor - .getBounds() - .overlaps(Rect.fromCircle(center: origin, radius: range))) { - continue; - } - sector ??= Path() - ..addPolygon([ - origin, - for (var step = 0; step <= arcSteps; step++) - origin + - Offset( - math.cos(directionRadians - - apertureRadians / 2 + - apertureRadians * step / arcSteps), - math.sin(directionRadians - - apertureRadians / 2 + - apertureRadians * step / arcSteps)) * - range - ], true); - if (Path.combine(PathOperation.intersect, floor, sector) - .getBounds() - .isEmpty) { - continue; - } - final visible = visibleSvgFloor( - floor: floor, - sector: sector, - origin: origin, - observerEye: eye, - targetEye: targetEye, - walls: _floorOccluders); - visibility ??= Path()..addPolygon(base.polygon, true); - visibility = Path.combine(PathOperation.union, - Path.combine(PathOperation.difference, visibility, floor), visible); + final bounds = floor.getBounds().intersect(reach); + if (bounds.width <= 0 || bounds.height <= 0) continue; + floors.add(SvgFloorLayer( + floor, + svgFloorShadows( + bounds: bounds, + origin: origin, + observerEye: eye, + targetEye: targetEye, + walls: _floorOccluders))); } - if (visibility == null) return base; + if (floors.isEmpty) return base; + final sector = [ + origin, + for (var step = 0; step <= arcSteps; step++) + origin + + Offset( + math.cos(directionRadians - + apertureRadians / 2 + + apertureRadians * step / arcSteps), + math.sin(directionRadians - + apertureRadians / 2 + + apertureRadians * step / arcSteps)) * + range + ]; final stats = base.stats; return SvgVisibilityCone( base.polygon, @@ -666,7 +692,8 @@ class SvgHeightVisibility { candidateMicros: stats.candidateMicros, nativeMicros: stats.nativeMicros), eyeElevationMeters: eye, - visibilityPath: visibility); + floors: floors, + sector: sector); } SvgVisibilityCone _horizontalCone({ @@ -811,7 +838,7 @@ class SvgHeightVisibility { } } for (final point in [edge.a, edge.b]) { - if (!seenPoints.add(point)) continue; + if (!seenPoints.add(point) || _seam(point, active)) continue; final delta = point - origin; if (delta.distanceSquared > range * range || delta == Offset.zero) continue; @@ -1064,7 +1091,7 @@ class SvgVisibilityHit { class SvgVisibilityCone { SvgVisibilityCone( List polygon, this.eyeHeightAboveFloorMeters, this.stats, - {this.eyeElevationMeters, this.visibilityPath}) + {this.eyeElevationMeters, this.floors = const [], this.sector}) : _polygon = polygon, xy = null; @@ -1074,7 +1101,8 @@ class SvgVisibilityCone { Float64List this.xy, this.eyeHeightAboveFloorMeters, this.stats, {this.eyeElevationMeters}) : _polygon = null, - visibilityPath = null; + floors = const [], + sector = null; final List? _polygon; final Float64List? xy; @@ -1085,9 +1113,32 @@ class SvgVisibilityCone { final double? eyeElevationMeters; final SvgVisibilityStats stats; - /// Includes projected visibility on explicitly measured destination floors. - /// The polygon retains the horizontal slice for native-query diagnostics. - final Path? visibilityPath; + /// Measured destination floors the cone overlooks, each lit inside + /// [sector] less the shadows cast on it. Where a floor lies, it replaces + /// the horizontal [polygon]; see [paintSvgConeArea]. + final List floors; + + /// The cone's aperture out to its range, when it has [floors]. + final List? sector; + + /// The lit area as one path, when the cone has [floors]: what + /// [paintSvgConeArea] fills, built with path operations. Costly; for + /// reports and tests, never for a frame. + late final Path? visibilityPath = floors.isEmpty + ? null + : () { + final sectorPath = Path()..addPolygon(sector!, true); + var visibility = Path()..addPolygon(polygon, true); + for (final layer in floors) { + final visible = layer.shadows.subtractFrom( + Path.combine(PathOperation.intersect, layer.floor, sectorPath)); + visibility = Path.combine( + PathOperation.union, + Path.combine(PathOperation.difference, visibility, layer.floor), + visible); + } + return visibility; + }(); /// The cone outline under a 4x4 column-major affine [transform], built /// without allocating a point object per vertex. @@ -1128,6 +1179,34 @@ Path _footprintPath(_Footprint footprint) { return path; } +/// Fills the lit area of [cone] with [fill], in source coordinates: +/// [outline], its horizontal cut, and on each destination floor the floor +/// inside the cone's sector less the shadows cast on it. Floors are composed +/// in layers rather than path operations, so a cone that overlooks a floor +/// paints in the same time as one that does not. [bounds] holds the cone. +void paintSvgConeArea(Canvas canvas, SvgVisibilityCone cone, Path outline, + Paint fill, Rect bounds) { + if (cone.floors.isEmpty) { + canvas.drawPath(outline, fill); + return; + } + final sector = Path()..addPolygon(cone.sector!, true); + final clear = Paint()..blendMode = BlendMode.clear; + canvas.saveLayer(bounds, Paint()); + canvas.drawPath(outline, fill); + for (final layer in cone.floors) { + // A floor replaces the horizontal cut where it lies, as the later of + // two overlapping floors replaces the earlier. + canvas.drawPath(layer.floor, clear); + canvas.saveLayer(bounds, Paint()); + canvas.clipPath(sector); + canvas.drawPath(layer.floor, fill); + layer.shadows.erase(canvas); + canvas.restore(); + } + canvas.restore(); +} + class SvgVisibilityStats { const SvgVisibilityStats(this.rayCount, this.edgeTests, this.spatialNodes, this.unknownWallHits, this.elapsedMicroseconds, @@ -1187,9 +1266,17 @@ class _Footprint { } class _Edge { - const _Edge(this.a, this.b, this.wall); + _Edge(this.a, this.b, this.wall) : _slack = _endpointSlack / (b - a).distance; final Offset a, b; final int wall; + + // atan2/sin/cos can put a ray aimed at an exact corner a few ulps beyond + // both adjoining endpoints. Admit that much, measured along the wall in + // SVG units so a long wall admits no more than a short one: far less than + // the 1e-8 radian rays beside a corner pass it by, which must still pass. + static const _endpointSlack = 1e-10; + final double _slack; + double? intersection(Offset origin, Offset direction, double range) { final edge = b - a, relative = a - origin; final determinant = _cross(direction, edge); @@ -1202,13 +1289,10 @@ class _Edge { } final distance = _cross(relative, edge) / determinant; final along = _cross(relative, direction) / determinant; - // atan2/sin/cos can put an exact corner a few ulps beyond both adjoining - // endpoints. Admit endpoint roundoff without moving the supporting line. - const endpointRoundoff = 1e-12; return distance >= 0 && distance <= range && - along >= -endpointRoundoff && - along <= 1 + endpointRoundoff + along >= -_slack && + along <= 1 + _slack ? distance : null; } 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 02430248..213459ec 100644 --- a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart +++ b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart @@ -193,13 +193,15 @@ class _SvgHeightViewConeState extends State { } _paintInputs = paintInputs; _painter = SvgHeightViewConePainter.fromPaths( - visibility: result.cone.visibilityPath?.transform(transform) ?? - result.cone.outlinePath(transform), + visibility: result.cone.outlinePath(transform), receiver: _rotatedReceiver!, receiverOffset: Offset(transform[12], transform[13]), apex: apex, radius: coordinates.worldHeightToScreen(widget.range), opacity: widget.opacity, + floors: result.cone.floors.isEmpty + ? null + : (result.cone, transform, widget.range / mapTransform.scale), ); return RepaintBoundary(child: CustomPaint(size: size, painter: _painter)); } @@ -227,7 +229,8 @@ class SvgHeightViewConePainter extends CustomPainter { this.opacity = 1, }) : receiverOffset = Offset.zero, _visibility = null, - _receiver = null; + _receiver = null, + floors = null; const SvgHeightViewConePainter.fromPaths( {required Path visibility, @@ -235,7 +238,8 @@ class SvgHeightViewConePainter extends CustomPainter { required this.apex, required this.radius, this.receiverOffset = Offset.zero, - this.opacity = 1}) + this.opacity = 1, + this.floors}) : _visibility = visibility, _receiver = receiver, visibilityPolygon = const [], @@ -254,6 +258,11 @@ class SvgHeightViewConePainter extends CustomPainter { /// Translation applied to [_receiver] at paint time; see the widget. final Offset receiverOffset; + /// A cone that overlooks measured floors, with its source-to-canvas + /// transform and range in source units. Its lit area is composed in source + /// coordinates by [paintSvgConeArea] instead of clipped to [_visibility]. + final (SvgVisibilityCone, Float64List, double)? floors; + @override void paint(Canvas canvas, Size size) { if ((_visibility == null && visibilityPolygon.length < 3) || @@ -263,24 +272,41 @@ class SvgHeightViewConePainter extends CustomPainter { final receiver = _receiver ?? _path(receiverRings, evenOdd: receiverEvenOdd); canvas.save(); - canvas.clipPath(visibility); // 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 paint = Paint() - ..shader = RadialGradient( - colors: [ - const Color.fromARGB(255, 147, 147, 147).withValues(alpha: .5 * opacity), - Colors.transparent, - ], - stops: const [0, 1], - ).createShader(Rect.fromCircle(center: apex, radius: radius)); - canvas.drawCircle(apex, radius, paint); + final floors = this.floors; + if (floors == null) { + canvas.clipPath(visibility); + canvas.drawCircle(apex, radius, _fill(apex, radius)); + } else { + final (cone, sourceToCanvas, range) = floors; + final origin = cone.sector!.first; + canvas.transform(sourceToCanvas); + paintSvgConeArea(canvas, cone, cone.outlinePath(_identity), + _fill(origin, range), Rect.fromCircle(center: origin, radius: range)); + } canvas.restore(); } + static final _identity = Float64List(16) + ..[0] = 1 + ..[5] = 1 + ..[10] = 1 + ..[15] = 1; + + Paint _fill(Offset center, double radius) => Paint() + ..shader = RadialGradient( + colors: [ + const Color.fromARGB(255, 147, 147, 147) + .withValues(alpha: .5 * opacity), + Colors.transparent, + ], + stops: const [0, 1], + ).createShader(Rect.fromCircle(center: center, radius: radius)); + static Path _path(List> rings, {required bool evenOdd}) { final path = Path() ..fillType = evenOdd ? PathFillType.evenOdd : PathFillType.nonZero; @@ -305,5 +331,6 @@ class SvgHeightViewConePainter extends CustomPainter { oldDelegate.apex != apex || oldDelegate.radius != radius || oldDelegate.receiverOffset != receiverOffset || - oldDelegate.opacity != opacity; + oldDelegate.opacity != opacity || + oldDelegate.floors != floors; } diff --git a/native/height/icarus_svg_height.cpp b/native/height/icarus_svg_height.cpp index 78ad3b1f..cda0d9ce 100644 --- a/native/height/icarus_svg_height.cpp +++ b/native/height/icarus_svg_height.cpp @@ -62,6 +62,9 @@ struct Edge { Point a, b; uint32_t wall; uint32_t aVertex = 0, bVertex = 0; + // See the Dart _Edge: corner roundoff admitted along the wall, as a + // fraction of this edge, from 1e-10 SVG units. + double slack = 0; bool intersection(Point origin, Point direction, double range, double &distance) const { @@ -84,9 +87,8 @@ struct Edge { const double along = cross(relative, direction) / determinant; // Admit floating-point endpoint roundoff, matching the Dart oracle. The // supporting line and reported intersection distance remain unchanged. - constexpr double endpointRoundoff = 1e-12; if (rayDistance >= 0 && rayDistance <= range && - along >= -endpointRoundoff && along <= 1 + endpointRoundoff) { + along >= -slack && along <= 1 + slack) { distance = rayDistance; return true; } @@ -316,6 +318,9 @@ struct Handle { std::vector chunkCounters; std::vector> chunkAngles, chunkVertexAngles; std::vector candidates; + // The edges meeting at each vertex, to tell a corner from a seam. + std::vector> vertexEdges; + std::vector vertexPoints; Pool pool{poolWorkers()}; std::mutex mutex; std::string error; @@ -336,6 +341,16 @@ struct Handle { }; edge.aVertex = id(edge.a); edge.bVertex = id(edge.b); + const Point delta = edge.b - edge.a; + edge.slack = 1e-10 / std::sqrt(dot(delta, delta)); + } + vertexEdges.resize(vertices.size()); + vertexPoints.resize(vertices.size()); + for (const auto &[key, value] : vertices) + vertexPoints[value] = {key.first, key.second}; + for (uint32_t i = 0; i < edges.size(); ++i) { + vertexEdges[edges[i].aVertex].push_back(i); + vertexEdges[edges[i].bVertex].push_back(i); } candidates.reserve(edges.size()); angles.reserve(std::min(maximumPoints, size_t(4097) + vertices.size() * 3)); @@ -374,6 +389,44 @@ struct Handle { } }; +// Whether no active boundary turns at a 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 an event. +// Pieces cut along one stroke leave such seams every metre or so. +bool seam(const Handle &handle, uint32_t vertex, const uint8_t *active) { + std::array live{}; + size_t count = 0; + for (uint32_t id : handle.vertexEdges[vertex]) { + if (!active[handle.edges[id].wall]) continue; + if (count == live.size()) return false; + live[count++] = id; + } + std::array cancelled{}; + for (size_t i = 0; i < count; ++i) { + const Edge &first = handle.edges[live[i]]; + for (size_t j = i + 1; j < count && !cancelled[i]; ++j) { + if (cancelled[j]) continue; + const Edge &second = handle.edges[live[j]]; + if (first.aVertex == second.bVertex && first.bVertex == second.aVertex) + cancelled[i] = cancelled[j] = true; + } + } + std::array away{}; + size_t remaining = 0; + const Point at = handle.vertexPoints[vertex]; + for (size_t i = 0; i < count; ++i) { + if (cancelled[i]) continue; + if (remaining == 2) return false; + const Edge &edge = handle.edges[live[i]]; + away[remaining++] = (edge.aVertex == vertex ? edge.b : edge.a) - at; + } + if (remaining == 0) return true; + if (remaining == 1) return false; + const double lengths = std::sqrt(dot(away[0], away[0]) * dot(away[1], away[1])); + return dot(away[0], away[1]) < 0 && + std::abs(cross(away[0], away[1])) <= 1e-12 * lengths; +} + Hit castRay(const Handle &handle, Point origin, Point direction, double range, const uint8_t *active, Counters &counters) { Hit result; @@ -681,7 +734,9 @@ int32_t ish_query(void *opaque, double originX, double originY, } } const Point endpoints[] = {edge.a, edge.b}; + const uint32_t endpointVertices[] = {edge.aVertex, edge.bVertex}; for (int endpoint = 0; endpoint < 2; ++endpoint) { + if (seam(handle, endpointVertices[endpoint], active)) continue; const Point point = endpoints[endpoint]; const Point delta = point - origin; const double distanceSquared = dot(delta, delta); From 179b6052154f5a5f1aa73c09a28fbf3cc4b1d9da Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 22:09:48 -0400 Subject: [PATCH 02/15] Keep floor footprints as rings so a worker can build floor layers Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_floor_visibility.dart | 12 +++++++++--- lib/view_cone/svg_height_visibility.dart | 20 ++++---------------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/lib/view_cone/svg_floor_visibility.dart b/lib/view_cone/svg_floor_visibility.dart index 22d67eee..c34c8ff5 100644 --- a/lib/view_cone/svg_floor_visibility.dart +++ b/lib/view_cone/svg_floor_visibility.dart @@ -12,13 +12,19 @@ class SvgFloorOccluder { final Rect bounds; } -/// One measured destination floor as a cone overlooks it. +/// One measured destination floor as a cone overlooks it. Built from plain +/// geometry, so a worker isolate can make one; paths are made when painted. class SvgFloorLayer { - const SvgFloorLayer(this.floor, this.shadows); + SvgFloorLayer(this.rings, this.evenOdd, this.shadows); /// The floor's footprint, in source coordinates. - final Path floor; + final List> rings; + final bool evenOdd; final SvgFloorShadows shadows; + + late final Path floor = Path() + ..fillType = evenOdd ? PathFillType.evenOdd : PathFillType.nonZero + ..addPolygonRings(rings); } /// The shadows the SVG wall volumes cast onto one measured, horizontal floor, diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 9b149b08..1bb35783 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -173,9 +173,6 @@ class SvgHeightVisibility { final SvgGroundHeight? ground; final bool requiresPhysicalGround; final List sightlineFloors; - late final _floorPaths = [ - for (final floor in sightlineFloors) _footprintPath(floor) - ]; late final _floorOccluders = [ for (final wall in walls) SvgFloorOccluder(wall.rings, wall.evenOdd, [ @@ -656,11 +653,12 @@ class SvgHeightVisibility { final targetEye = sightlineFloors[i].surfaceElevationMeters! + (cameraHeightMeters ?? defaultCameraHeightMeters); if (targetEye == eye) continue; - final floor = _floorPaths[i]; - final bounds = floor.getBounds().intersect(reach); + final floor = sightlineFloors[i]; + final bounds = floor.bounds.intersect(reach); if (bounds.width <= 0 || bounds.height <= 0) continue; floors.add(SvgFloorLayer( - floor, + floor.rings, + floor.evenOdd, svgFloorShadows( bounds: bounds, origin: origin, @@ -1169,16 +1167,6 @@ class SvgVisibilityCone { } } -Path _footprintPath(_Footprint footprint) { - final path = Path() - ..fillType = - footprint.evenOdd ? PathFillType.evenOdd : PathFillType.nonZero; - for (final ring in footprint.rings) { - path.addPolygon(ring, true); - } - return path; -} - /// Fills the lit area of [cone] with [fill], in source coordinates: /// [outline], its horizontal cut, and on each destination floor the floor /// inside the cone's sector less the shadows cast on it. Floors are composed From 31a323cd90f0792d21c1d69f8855e26c610ee2b1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 22:47:09 -0400 Subject: [PATCH 03/15] Review fixes: seams need walls on both sides, slack scales with distance - A shared edge cancels at a vertex only when the two walls lie on either side of it. Two walls over the same footprint, wound opposite ways, no longer lose their corners. Dart works out each edge's inside from its wall and hands it to native (ish_set_interior_sides); without it native skips no seams. - Corner slack is 1e-13 + 1e-10 x the hit distance along the wall, so a ray 1e-8 radians beside a corner still passes it from a millimetre away. - Floors are cleared and refilled without antialiasing, by the same pixel centres, so no faint seam is left along a floor edge inside a cone. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_height_native_io.dart | 26 ++++++++++- lib/view_cone/svg_height_native_stub.dart | 2 + lib/view_cone/svg_height_visibility.dart | 49 ++++++++++++++------ native/height/icarus_svg_height.cpp | 29 +++++++++--- native/height/icarus_svg_height.h | 6 +++ test/svg_height_visibility_test.dart | 54 +++++++++++++++++++++++ 6 files changed, 147 insertions(+), 19 deletions(-) diff --git a/lib/view_cone/svg_height_native_io.dart b/lib/view_cone/svg_height_native_io.dart index 2e403bb2..624678d9 100644 --- a/lib/view_cone/svg_height_native_io.dart +++ b/lib/view_cone/svg_height_native_io.dart @@ -63,6 +63,8 @@ typedef _QueryD = int Function(Pointer, double, double, double, double, double, int, Pointer, int, Pointer); typedef _ErrorC = Int32 Function(Pointer, Pointer, Uint32); typedef _ErrorD = int Function(Pointer, Pointer, int); +typedef _SidesC = Int32 Function(Pointer, Pointer, Uint32); +typedef _SidesD = int Function(Pointer, Pointer, int); typedef _CloseC = Int32 Function(Pointer); typedef _CloseD = int Function(Pointer); typedef _FinalizeC = Void Function(Pointer); @@ -117,13 +119,17 @@ class SvgHeightNative implements Finalizable { static const int _maximumPointCount = 1 << 22; + /// [interiorLeft], one byte per edge, says which side of it its own wall + /// lies on; with it the query skips seams between touching pieces. factory SvgHeightNative.open({ required List wallIds, required Float64List edgeRecords, + Uint8List? interiorLeft, String? libraryPath, }) { final edgeCount = edgeRecords.length ~/ 5; if (edgeRecords.length % 5 != 0 || + (interiorLeft != null && interiorLeft.length != edgeCount) || wallIds.length > 1 << 20 || edgeCount > 1 << 19) { throw ArgumentError('SVG native geometry exceeds its bounded capacity.'); @@ -153,6 +159,8 @@ class SvgHeightNative implements Finalizable { final readError = library.lookupFunction<_ErrorC, _ErrorD>('ish_last_error'); final close = library.lookupFunction<_CloseC, _CloseD>('ish_close'); + final setSides = + library.lookupFunction<_SidesC, _SidesD>('ish_set_interior_sides'); final maskBuffer = library .lookupFunction<_MaskBufferC, _MaskBufferD>('ish_active_wall_buffer'); final resultBuffer = library @@ -174,6 +182,18 @@ class SvgHeightNative implements Finalizable { close(handle); throw StateError('SVG native context has no query buffers.'); } + if (interiorLeft != null && interiorLeft.isNotEmpty) { + final sides = calloc(interiorLeft.length); + try { + sides.asTypedList(interiorLeft.length).setAll(0, interiorLeft); + if (setSides(handle, sides, interiorLeft.length) != 0) { + close(handle); + throw StateError('SVG native context refused the wall sides.'); + } + } finally { + calloc.free(sides); + } + } return SvgHeightNative._( ids, handle, mask, result, query, readError, close, finalizer); } finally { @@ -187,13 +207,17 @@ class SvgHeightNative implements Finalizable { static SvgHeightNative? tryOpen({ required List wallIds, required Float64List edgeRecords, + Uint8List? interiorLeft, String? libraryPath, }) { // Validate through the public constructor. DynamicLibrary.open and symbol // lookup both report unavailable native code as ArgumentError. try { return SvgHeightNative.open( - wallIds: wallIds, edgeRecords: edgeRecords, libraryPath: libraryPath); + wallIds: wallIds, + edgeRecords: edgeRecords, + interiorLeft: interiorLeft, + libraryPath: libraryPath); } on UnsupportedError { return null; } on ArgumentError catch (error) { diff --git a/lib/view_cone/svg_height_native_stub.dart b/lib/view_cone/svg_height_native_stub.dart index eb871db6..be2691be 100644 --- a/lib/view_cone/svg_height_native_stub.dart +++ b/lib/view_cone/svg_height_native_stub.dart @@ -42,11 +42,13 @@ class SvgHeightNative { factory SvgHeightNative.open( {required List wallIds, required Float64List edgeRecords, + Uint8List? interiorLeft, String? libraryPath}) => throw UnsupportedError('Native acceleration is unavailable on web.'); static SvgHeightNative? tryOpen( {required List wallIds, required Float64List edgeRecords, + Uint8List? interiorLeft, String? libraryPath}) => null; SvgHeightNativeCone query( diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 1bb35783..4ba74978 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -24,7 +24,10 @@ class SvgHeightVisibility { for (var i = 0; i < points.length; i++) { final a = points[i], b = points[(i + 1) % points.length]; if (a == b) continue; - final edge = _Edge(a, b, wall); + final mid = (a + b) / 2, along = (b - a) / (b - a).distance; + final edge = _Edge(a, b, wall, + interiorLeft: walls[wall] + .contains(mid + Offset(along.dy, -along.dx) * 1e-6)); _edges.add(edge); } } @@ -229,7 +232,13 @@ class SvgHeightVisibility { 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] && live[i].a == live[j].b && live[i].b == live[j].a) { + 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 same = first.a == second.a && first.b == second.b; + final reversed = first.a == second.b && first.b == second.a; + if ((same && first.interiorLeft != second.interiorLeft) || + (reversed && first.interiorLeft == second.interiorLeft)) { cancelled[i] = cancelled[j] = true; } } @@ -278,6 +287,8 @@ class SvgHeightVisibility { if (walls.any((wall) => wall.unknownHeight)) return false; _native = SvgHeightNative.tryOpen( wallIds: [for (final wall in walls) wall.id], + interiorLeft: Uint8List.fromList( + [for (final edge in _edges) edge.interiorLeft ? 1 : 0]), edgeRecords: Float64List.fromList([ for (final edge in _edges) ...[ edge.a.dx, @@ -1179,7 +1190,16 @@ void paintSvgConeArea(Canvas canvas, SvgVisibilityCone cone, Path outline, return; } final sector = Path()..addPolygon(cone.sector!, true); - final clear = Paint()..blendMode = BlendMode.clear; + // The floor is cleared and refilled by the same pixel centres, without + // antialiasing: partial coverage cleared and partial coverage refilled + // would leave a faint seam along every floor edge inside the cone. + final clear = Paint() + ..blendMode = BlendMode.clear + ..isAntiAlias = false; + final floorFill = Paint() + ..shader = fill.shader + ..color = fill.color + ..isAntiAlias = false; canvas.saveLayer(bounds, Paint()); canvas.drawPath(outline, fill); for (final layer in cone.floors) { @@ -1188,7 +1208,7 @@ void paintSvgConeArea(Canvas canvas, SvgVisibilityCone cone, Path outline, canvas.drawPath(layer.floor, clear); canvas.saveLayer(bounds, Paint()); canvas.clipPath(sector); - canvas.drawPath(layer.floor, fill); + canvas.drawPath(layer.floor, floorFill); layer.shadows.erase(canvas); canvas.restore(); } @@ -1254,16 +1274,14 @@ class _Footprint { } class _Edge { - _Edge(this.a, this.b, this.wall) : _slack = _endpointSlack / (b - a).distance; + _Edge(this.a, this.b, this.wall, {required this.interiorLeft}) + : _inverseLength = 1 / (b - a).distance; final Offset a, b; final int wall; - // atan2/sin/cos can put a ray aimed at an exact corner a few ulps beyond - // both adjoining endpoints. Admit that much, measured along the wall in - // SVG units so a long wall admits no more than a short one: far less than - // the 1e-8 radian rays beside a corner pass it by, which must still pass. - static const _endpointSlack = 1e-10; - final double _slack; + /// Whether the edge's own wall lies to its left, going from [a] to [b]. + final bool interiorLeft; + final double _inverseLength; double? intersection(Offset origin, Offset direction, double range) { final edge = b - a, relative = a - origin; @@ -1277,10 +1295,15 @@ class _Edge { } final distance = _cross(relative, edge) / determinant; final along = _cross(relative, direction) / 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 + // distance, which must still pass. + final slack = (1e-13 + distance * 1e-10) * _inverseLength; return distance >= 0 && distance <= range && - along >= -_slack && - along <= 1 + _slack + along >= -slack && + along <= 1 + slack ? distance : null; } diff --git a/native/height/icarus_svg_height.cpp b/native/height/icarus_svg_height.cpp index cda0d9ce..80225681 100644 --- a/native/height/icarus_svg_height.cpp +++ b/native/height/icarus_svg_height.cpp @@ -62,9 +62,9 @@ struct Edge { Point a, b; uint32_t wall; uint32_t aVertex = 0, bVertex = 0; - // See the Dart _Edge: corner roundoff admitted along the wall, as a - // fraction of this edge, from 1e-10 SVG units. - double slack = 0; + // See the Dart _Edge: corner roundoff admitted along the wall. + double inverseLength = 0; + bool interiorLeft = false; bool intersection(Point origin, Point direction, double range, double &distance) const { @@ -87,6 +87,7 @@ struct Edge { const double along = cross(relative, direction) / determinant; // Admit floating-point endpoint roundoff, matching the Dart oracle. The // supporting line and reported intersection distance remain unchanged. + const double slack = (1e-13 + rayDistance * 1e-10) * inverseLength; if (rayDistance >= 0 && rayDistance <= range && along >= -slack && along <= 1 + slack) { distance = rayDistance; @@ -321,6 +322,7 @@ struct Handle { // The edges meeting at each vertex, to tell a corner from a seam. std::vector> vertexEdges; std::vector vertexPoints; + bool interiorSides = false; Pool pool{poolWorkers()}; std::mutex mutex; std::string error; @@ -342,7 +344,7 @@ struct Handle { edge.aVertex = id(edge.a); edge.bVertex = id(edge.b); const Point delta = edge.b - edge.a; - edge.slack = 1e-10 / std::sqrt(dot(delta, delta)); + edge.inverseLength = 1 / std::sqrt(dot(delta, delta)); } vertexEdges.resize(vertices.size()); vertexPoints.resize(vertices.size()); @@ -394,6 +396,7 @@ struct Handle { // A ray there meets the wall the rays beside it meet, so it is not an event. // Pieces cut along one stroke leave such seams every metre or so. bool seam(const Handle &handle, uint32_t vertex, const uint8_t *active) { + if (!handle.interiorSides) return false; std::array live{}; size_t count = 0; for (uint32_t id : handle.vertexEdges[vertex]) { @@ -407,7 +410,11 @@ bool seam(const Handle &handle, uint32_t vertex, const uint8_t *active) { for (size_t j = i + 1; j < count && !cancelled[i]; ++j) { if (cancelled[j]) continue; const Edge &second = handle.edges[live[j]]; - if (first.aVertex == second.bVertex && first.bVertex == second.aVertex) + // A shared side only when the two walls lie on either side of it. + const bool same = first.aVertex == second.aVertex && first.bVertex == second.bVertex; + const bool reversed = first.aVertex == second.bVertex && first.bVertex == second.aVertex; + if ((same && first.interiorLeft != second.interiorLeft) || + (reversed && first.interiorLeft == second.interiorLeft)) cancelled[i] = cancelled[j] = true; } } @@ -555,6 +562,18 @@ void *ish_open(const double *records, uint32_t edgeCount, uint32_t wallCount, } } +int32_t ish_set_interior_sides(void *opaque, const uint8_t *interiorLeft, + uint32_t edgeCount) { + if (!opaque || !interiorLeft) return ISH_INVALID; + auto &handle = *static_cast(opaque); + std::lock_guard lock(handle.mutex); + if (edgeCount != handle.edges.size()) return ISH_INVALID; + for (uint32_t i = 0; i < edgeCount; ++i) + handle.edges[i].interiorLeft = interiorLeft[i] != 0; + handle.interiorSides = true; + return ISH_OK; +} + uint8_t *ish_active_wall_buffer(void *opaque) { return opaque ? static_cast(opaque)->activeScratch.data() : nullptr; } diff --git a/native/height/icarus_svg_height.h b/native/height/icarus_svg_height.h index a0982ba6..853baecf 100644 --- a/native/height/icarus_svg_height.h +++ b/native/height/icarus_svg_height.h @@ -55,6 +55,12 @@ ISH_API int32_t ish_query(void *handle, double originX, double originY, const uint8_t *activeWalls, uint32_t activeWallCount, ISHResult *outResult); +// interiorLeft holds one byte per edge, in record order: nonzero when the +// edge's own wall lies to the left of it (a to b). Without it no vertex is +// treated as a seam between touching pieces. +ISH_API int32_t ish_set_interior_sides(void *handle, const uint8_t *interiorLeft, + uint32_t edgeCount); + ISH_API int32_t ish_last_error(void *handle, char *error, uint32_t errorCapacity); ISH_API int32_t ish_close(void *handle); diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 7904d263..87a8ab5e 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -467,4 +467,58 @@ void main() { expect(() => SvgHeightVisibility.fromJson(data([collinear])), throwsFormatException); }); + + double area(List polygon) { + var twice = 0.0; + for (var i = 0; i < polygon.length; i++) { + final a = polygon[i], b = polygon[(i + 1) % polygon.length]; + twice += a.dx * b.dy - b.dx * a.dy; + } + return twice.abs() / 2; + } + + SvgVisibilityCone coneOf(List> walls, + {Offset origin = Offset.zero, double direction = 0}) => + SvgHeightVisibility.fromJson(data(walls)).cone( + origin: origin, + directionRadians: direction, + range: 100, + apertureRadians: 1); + + test('touching pieces of one wall cast the cone the whole wall casts', () { + final whole = coneOf([ + wall('whole', [rectangle(10, -20, 11, 20)]) + ]); + final pieces = coneOf([ + for (var y = -20.0; y < 20; y += 1) + 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, + reason: 'seams between the pieces are not visibility events'); + }); + + test('two walls over the same footprint keep their corners', () { + final ring = rectangle(10, 1.13, 11, 3.17); + final reversed = [ + for (var i = ring.length - 2; i >= 0; i -= 2) ...[ring[i], ring[i + 1]] + ]; + final once = coneOf([ + wall('once', [ring]) + ]); + final twice = coneOf([ + wall('one', [ring]), + wall('other', [reversed]), + ]); + expect(area(twice.polygon), closeTo(area(once.polygon), 1e-9)); + }); + + test('rays beside a corner pass it even from right next to it', () { + final cone = coneOf([ + wall('corner', [rectangle(-2, 0, 0, 2)]) + ], origin: const Offset(-0.001, -0.001), direction: math.pi / 4); + final beside = const Offset(-0.001, -0.001) + + Offset.fromDirection(math.pi / 4 - 0.002, 50); + expect((Path()..addPolygon(cone.polygon, true)).contains(beside), isTrue); + }); } From 5ed768dc895a6bb50d568c60692152573139581a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 2 Oct 2026 23:00:43 -0400 Subject: [PATCH 04/15] Seams only where a wall's side is certain; add floors instead of masking - An edge's side is taken only when a step either way tells inside from outside. A wall thinner than the step is "unknown" (2 in native), and no seam is skipped at its edges, so duplicated thin walls keep their corners. - Floors are cleared and refilled with antialiasing, and the floor layer is added rather than laid over: at a floor edge on lit ground the cut keeps 1 - c and the floor brings c, which sum to the fill. Edges facing open ground stay smooth. A ray grazing a corner from within about 1e-5 SVG units of it is still caught by the slack's 1e-13 floor; a smaller floor would let roundoff send a corner-aimed ray through the wall, which is worse. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_height_native_io.dart | 21 ++++----- lib/view_cone/svg_height_native_stub.dart | 4 +- lib/view_cone/svg_height_visibility.dart | 55 ++++++++++++++--------- native/height/icarus_svg_height.cpp | 16 ++++--- native/height/icarus_svg_height.h | 8 ++-- test/svg_height_visibility_test.dart | 22 +++++++++ 6 files changed, 83 insertions(+), 43 deletions(-) diff --git a/lib/view_cone/svg_height_native_io.dart b/lib/view_cone/svg_height_native_io.dart index 624678d9..a41647fd 100644 --- a/lib/view_cone/svg_height_native_io.dart +++ b/lib/view_cone/svg_height_native_io.dart @@ -119,17 +119,18 @@ class SvgHeightNative implements Finalizable { static const int _maximumPointCount = 1 << 22; - /// [interiorLeft], one byte per edge, says which side of it its own wall - /// lies on; with it the query skips seams between touching pieces. + /// [interiorSides], one byte per edge, says which side of it its own wall + /// lies on (0 right, 1 left, 2 unknown); with it the query skips seams + /// between touching pieces. factory SvgHeightNative.open({ required List wallIds, required Float64List edgeRecords, - Uint8List? interiorLeft, + Uint8List? interiorSides, String? libraryPath, }) { final edgeCount = edgeRecords.length ~/ 5; if (edgeRecords.length % 5 != 0 || - (interiorLeft != null && interiorLeft.length != edgeCount) || + (interiorSides != null && interiorSides.length != edgeCount) || wallIds.length > 1 << 20 || edgeCount > 1 << 19) { throw ArgumentError('SVG native geometry exceeds its bounded capacity.'); @@ -182,11 +183,11 @@ class SvgHeightNative implements Finalizable { close(handle); throw StateError('SVG native context has no query buffers.'); } - if (interiorLeft != null && interiorLeft.isNotEmpty) { - final sides = calloc(interiorLeft.length); + if (interiorSides != null && interiorSides.isNotEmpty) { + final sides = calloc(interiorSides.length); try { - sides.asTypedList(interiorLeft.length).setAll(0, interiorLeft); - if (setSides(handle, sides, interiorLeft.length) != 0) { + sides.asTypedList(interiorSides.length).setAll(0, interiorSides); + if (setSides(handle, sides, interiorSides.length) != 0) { close(handle); throw StateError('SVG native context refused the wall sides.'); } @@ -207,7 +208,7 @@ class SvgHeightNative implements Finalizable { static SvgHeightNative? tryOpen({ required List wallIds, required Float64List edgeRecords, - Uint8List? interiorLeft, + Uint8List? interiorSides, String? libraryPath, }) { // Validate through the public constructor. DynamicLibrary.open and symbol @@ -216,7 +217,7 @@ class SvgHeightNative implements Finalizable { return SvgHeightNative.open( wallIds: wallIds, edgeRecords: edgeRecords, - interiorLeft: interiorLeft, + interiorSides: interiorSides, libraryPath: libraryPath); } on UnsupportedError { return null; diff --git a/lib/view_cone/svg_height_native_stub.dart b/lib/view_cone/svg_height_native_stub.dart index be2691be..eefadca0 100644 --- a/lib/view_cone/svg_height_native_stub.dart +++ b/lib/view_cone/svg_height_native_stub.dart @@ -42,13 +42,13 @@ class SvgHeightNative { factory SvgHeightNative.open( {required List wallIds, required Float64List edgeRecords, - Uint8List? interiorLeft, + Uint8List? interiorSides, String? libraryPath}) => throw UnsupportedError('Native acceleration is unavailable on web.'); static SvgHeightNative? tryOpen( {required List wallIds, required Float64List edgeRecords, - Uint8List? interiorLeft, + Uint8List? interiorSides, String? libraryPath}) => null; SvgHeightNativeCone query( diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 4ba74978..f2704c38 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -24,10 +24,18 @@ class SvgHeightVisibility { for (var i = 0; i < points.length; i++) { final a = points[i], b = points[(i + 1) % points.length]; if (a == b) continue; + // Which side the wall lies on, when a step either way tells: a + // wall thinner than the step is inside on neither side or both. final mid = (a + b) / 2, along = (b - a) / (b - a).distance; + final left = Offset(along.dy, -along.dx) * 1e-6; + final inLeft = walls[wall].contains(mid + left), + inRight = walls[wall].contains(mid - left); final edge = _Edge(a, b, wall, - interiorLeft: walls[wall] - .contains(mid + Offset(along.dy, -along.dx) * 1e-6)); + interior: inLeft == inRight + ? null + : inLeft + ? _Side.left + : _Side.right); _edges.add(edge); } } @@ -235,10 +243,11 @@ class SvgHeightVisibility { 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]; + 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; - if ((same && first.interiorLeft != second.interiorLeft) || - (reversed && first.interiorLeft == second.interiorLeft)) { + if ((same && first.interior != second.interior) || + (reversed && first.interior == second.interior)) { cancelled[i] = cancelled[j] = true; } } @@ -287,8 +296,14 @@ class SvgHeightVisibility { if (walls.any((wall) => wall.unknownHeight)) return false; _native = SvgHeightNative.tryOpen( wallIds: [for (final wall in walls) wall.id], - interiorLeft: Uint8List.fromList( - [for (final edge in _edges) edge.interiorLeft ? 1 : 0]), + interiorSides: Uint8List.fromList([ + for (final edge in _edges) + switch (edge.interior) { + _Side.right => 0, + _Side.left => 1, + null => 2, + } + ]), edgeRecords: Float64List.fromList([ for (final edge in _edges) ...[ edge.a.dx, @@ -1190,25 +1205,20 @@ void paintSvgConeArea(Canvas canvas, SvgVisibilityCone cone, Path outline, return; } final sector = Path()..addPolygon(cone.sector!, true); - // The floor is cleared and refilled by the same pixel centres, without - // antialiasing: partial coverage cleared and partial coverage refilled - // would leave a faint seam along every floor edge inside the cone. - final clear = Paint() - ..blendMode = BlendMode.clear - ..isAntiAlias = false; - final floorFill = Paint() - ..shader = fill.shader - ..color = fill.color - ..isAntiAlias = false; + final clear = Paint()..blendMode = BlendMode.clear; + // Where a floor edge crosses lit ground, the cleared pixel keeps 1 - c of + // the cut and the floor brings c; adding them, rather than laying one over + // the other, restores the fill exactly, with every edge antialiased. + final add = Paint()..blendMode = BlendMode.plus; canvas.saveLayer(bounds, Paint()); canvas.drawPath(outline, fill); for (final layer in cone.floors) { // A floor replaces the horizontal cut where it lies, as the later of // two overlapping floors replaces the earlier. canvas.drawPath(layer.floor, clear); - canvas.saveLayer(bounds, Paint()); + canvas.saveLayer(bounds, add); canvas.clipPath(sector); - canvas.drawPath(layer.floor, floorFill); + canvas.drawPath(layer.floor, fill); layer.shadows.erase(canvas); canvas.restore(); } @@ -1273,14 +1283,17 @@ class _Footprint { } } +enum _Side { left, right } + class _Edge { - _Edge(this.a, this.b, this.wall, {required this.interiorLeft}) + _Edge(this.a, this.b, this.wall, {required this.interior}) : _inverseLength = 1 / (b - a).distance; final Offset a, b; final int wall; - /// Whether the edge's own wall lies to its left, going from [a] to [b]. - final bool interiorLeft; + /// The side of the edge, going from [a] to [b], its own wall lies on; null + /// when that could not be told. + final _Side? interior; final double _inverseLength; double? intersection(Offset origin, Offset direction, double range) { diff --git a/native/height/icarus_svg_height.cpp b/native/height/icarus_svg_height.cpp index 80225681..a5efb827 100644 --- a/native/height/icarus_svg_height.cpp +++ b/native/height/icarus_svg_height.cpp @@ -64,7 +64,8 @@ struct Edge { uint32_t aVertex = 0, bVertex = 0; // See the Dart _Edge: corner roundoff admitted along the wall. double inverseLength = 0; - bool interiorLeft = false; + // The side its own wall lies on, going a to b: 0 right, 1 left, 2 unknown. + uint8_t interior = 2; bool intersection(Point origin, Point direction, double range, double &distance) const { @@ -413,8 +414,9 @@ bool seam(const Handle &handle, uint32_t vertex, const uint8_t *active) { // A shared side only when the two walls lie on either side of it. const bool same = first.aVertex == second.aVertex && first.bVertex == second.bVertex; const bool reversed = first.aVertex == second.bVertex && first.bVertex == second.aVertex; - if ((same && first.interiorLeft != second.interiorLeft) || - (reversed && first.interiorLeft == second.interiorLeft)) + if (first.interior > 1 || second.interior > 1) continue; + if ((same && first.interior != second.interior) || + (reversed && first.interior == second.interior)) cancelled[i] = cancelled[j] = true; } } @@ -562,14 +564,16 @@ void *ish_open(const double *records, uint32_t edgeCount, uint32_t wallCount, } } -int32_t ish_set_interior_sides(void *opaque, const uint8_t *interiorLeft, +int32_t ish_set_interior_sides(void *opaque, const uint8_t *sides, uint32_t edgeCount) { - if (!opaque || !interiorLeft) return ISH_INVALID; + if (!opaque || !sides) return ISH_INVALID; auto &handle = *static_cast(opaque); std::lock_guard lock(handle.mutex); if (edgeCount != handle.edges.size()) return ISH_INVALID; for (uint32_t i = 0; i < edgeCount; ++i) - handle.edges[i].interiorLeft = interiorLeft[i] != 0; + if (sides[i] > 2) return ISH_INVALID; + for (uint32_t i = 0; i < edgeCount; ++i) + handle.edges[i].interior = sides[i]; handle.interiorSides = true; return ISH_OK; } diff --git a/native/height/icarus_svg_height.h b/native/height/icarus_svg_height.h index 853baecf..36097ef1 100644 --- a/native/height/icarus_svg_height.h +++ b/native/height/icarus_svg_height.h @@ -55,10 +55,10 @@ ISH_API int32_t ish_query(void *handle, double originX, double originY, const uint8_t *activeWalls, uint32_t activeWallCount, ISHResult *outResult); -// interiorLeft holds one byte per edge, in record order: nonzero when the -// edge's own wall lies to the left of it (a to b). Without it no vertex is -// treated as a seam between touching pieces. -ISH_API int32_t ish_set_interior_sides(void *handle, const uint8_t *interiorLeft, +// sides holds one byte per edge, in record order: the side of the edge (a to +// b) its own wall lies on, 0 right, 1 left, 2 unknown. Without it no vertex is +// treated as a seam between touching pieces, nor at an edge of unknown side. +ISH_API int32_t ish_set_interior_sides(void *handle, const uint8_t *sides, uint32_t edgeCount); ISH_API int32_t ish_last_error(void *handle, char *error, diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 87a8ab5e..eedda987 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -513,6 +513,28 @@ void main() { expect(area(twice.polygon), closeTo(area(once.polygon), 1e-9)); }); + test('two walls over a footprint thinner than the side probe', () { + const ring = [10.0, 1.13, 10.0000005, 2.15, 10.0, 3.17, 9.9999995, 2.15]; + const reversed = [ + 9.9999995, + 2.15, + 10.0, + 3.17, + 10.0000005, + 2.15, + 10.0, + 1.13 + ]; + final once = coneOf([ + wall('once', [ring]) + ]); + final twice = coneOf([ + wall('one', [ring]), + wall('other', [reversed]), + ]); + expect(area(twice.polygon), closeTo(area(once.polygon), 1e-9)); + }); + test('rays beside a corner pass it even from right next to it', () { final cone = coneOf([ wall('corner', [rectangle(-2, 0, 0, 2)]) From 3729ad1b3e3f7c3324d3c0b12b7ae0606edfe3be Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 02:15:35 -0400 Subject: [PATCH 05/15] Cast cones against merged outlines when the model carries them A model may now list runtimeWalls: touching pieces with the same heights, merged offline into one outline each. Cones and floor shadows are cast against those outlines; the pieces stay the model for heights, standing and ids. Merging cuts a map's cone edges about threefold and closes the hairline cracks between pieces of one wall. The loader refuses a merge of pieces with different heights, a piece in two merges, or a piece left out; without runtimeWalls nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_height_visibility.dart | 89 +++++++++++++++++++++--- test/svg_height_visibility_test.dart | 42 +++++++++++ 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index f2704c38..564072f4 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -17,9 +17,16 @@ class SvgHeightVisibility { this.cellSize, this.ground, this.requiresPhysicalGround, - this.sightlineFloors) { - for (var wall = 0; wall < walls.length; wall++) { - for (final ring in walls[wall].rings) { + this.sightlineFloors, + [List? runtimeWalls]) + : runtimeWalls = runtimeWalls ?? + List.unmodifiable([ + for (var i = 0; i < walls.length; i++) + SvgRuntimeWall._(i, walls[i].rings, walls[i].evenOdd) + ]) { + for (final shape in this.runtimeWalls) { + final wall = shape.wall; + for (final ring in shape.rings) { final points = _runtimeRing(ring); for (var i = 0; i < points.length; i++) { final a = points[i], b = points[(i + 1) % points.length]; @@ -28,8 +35,8 @@ class SvgHeightVisibility { // wall thinner than the step is inside on neither side or both. final mid = (a + b) / 2, along = (b - a) / (b - a).distance; final left = Offset(along.dy, -along.dx) * 1e-6; - final inLeft = walls[wall].contains(mid + left), - inRight = walls[wall].contains(mid - left); + final inLeft = shape.contains(mid + left), + inRight = shape.contains(mid - left); final edge = _Edge(a, b, wall, interior: inLeft == inRight ? null @@ -164,6 +171,42 @@ class SvgHeightVisibility { } sightlineFloors.add(support); } + // Touching pieces with the same heights, merged offline into one outline + // each: what cones are cast against. The pieces stay the model. + List? runtimeWalls; + if (json['runtimeWalls'] != null) { + final index = {for (var i = 0; i < walls.length; i++) walls[i].id: i}; + final covered = List.filled(walls.length, false); + runtimeWalls = []; + for (final raw in _list(json['runtimeWalls'], 'runtimeWalls')) { + final row = _map(raw); + final members = [ + for (final id in _list(row['walls'], 'runtime wall members')) + index[id] ?? + (throw FormatException('Runtime wall names missing wall $id.')) + ]; + if (members.isEmpty) { + throw const FormatException('Runtime wall has no members.'); + } + final first = walls[members.first]; + for (final member in members) { + final wall = walls[member]; + if (covered[member] || + wall.floorElevationMeters != first.floorElevationMeters || + wall.unknownHeight != first.unknownHeight || + !_sameBands(wall.bands, first.bands)) { + throw FormatException( + 'Runtime wall merges ${wall.id} with different heights.'); + } + covered[member] = true; + } + runtimeWalls + .add(SvgRuntimeWall._(members.first, _rings(row), _evenOdd(row))); + } + if (covered.contains(false)) { + throw const FormatException('Runtime walls leave a wall out.'); + } + } return SvgHeightVisibility._( List.unmodifiable(walls), List.unmodifiable(supports), @@ -174,7 +217,16 @@ class SvgHeightVisibility { ? null : SvgGroundHeight.fromJson(_map(json['ground'])), physicalGround, - List.unmodifiable(sightlineFloors)); + List.unmodifiable(sightlineFloors), + runtimeWalls == null ? null : List.unmodifiable(runtimeWalls)); + } + + static bool _sameBands(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i].bottom != b[i].bottom || a[i].top != b[i].top) return false; + } + return true; } final List walls; @@ -184,9 +236,18 @@ class SvgHeightVisibility { final SvgGroundHeight? ground; final bool requiresPhysicalGround; final List sightlineFloors; + + /// The outlines cones are cast against: [walls] themselves, or touching + /// pieces with the same heights merged into one outline each. + final List runtimeWalls; + late final _floorOccluders = [ - for (final wall in walls) - SvgFloorOccluder(wall.rings, wall.evenOdd, [ + for (final shape in runtimeWalls) + SvgFloorOccluder( + shape.rings, shape.evenOdd, _absoluteBands(walls[shape.wall])) + ]; + + static List<(double, double)> _absoluteBands(SvgHeightWall wall) => [ if (wall.unknownHeight) (double.negativeInfinity, double.infinity) else @@ -197,8 +258,7 @@ class SvgHeightVisibility { : (wall.floorElevationMeters ?? 0) + band.bottom, (wall.floorElevationMeters ?? 0) + band.top ) - ]) - ]; + ]; final _edges = <_Edge>[]; _EdgeNode? _tree; SvgHeightNative? _native; @@ -1045,6 +1105,15 @@ class SvgHeightBand { bool contains(double height) => height >= bottom && height <= top; } +/// One outline cones are cast against, standing for [wall] and every piece +/// with the same heights merged into it. +class SvgRuntimeWall extends _Footprint { + SvgRuntimeWall._(this.wall, super.rings, super.evenOdd); + + /// The index in [SvgHeightVisibility.walls] whose heights it carries. + final int wall; +} + class SvgHeightWall extends _Footprint { SvgHeightWall._(this.id, super.rings, super.evenOdd, List bands, this.unknownHeight, this.floorElevationMeters) diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index eedda987..85c3687b 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -543,4 +543,46 @@ void main() { Offset.fromDirection(math.pi / 4 - 0.002, 50); expect((Path()..addPolygon(cone.polygon, true)).contains(beside), isTrue); }); + + group('runtime walls', () { + final pieces = [ + for (var y = -20.0; y < 20; y += 1) + wall('piece-$y', [rectangle(10, y, 11, y + 1)]) + ]; + Map merged({List? members, List? ring}) => + data(pieces) + ..['runtimeWalls'] = [ + { + 'walls': members ?? [for (final p in pieces) p['id'] as String], + 'rings': [ring ?? rectangle(10, -20, 11, 20)], + 'fillRule': 'nonzero', + } + ]; + SvgVisibilityCone cast(Map json) => + SvgHeightVisibility.fromJson(json).cone( + origin: Offset.zero, + directionRadians: 0, + range: 100, + apertureRadians: 1); + + test('a merged outline casts the cone its pieces cast', () { + expect(area(cast(merged()).polygon), + closeTo(area(cast(data(pieces)).polygon), 1e-9)); + }); + + test('a merge of pieces with different heights is refused', () { + final json = merged(); + (json['walls'] as List)[3]['bands'] = [ + [0, 1] + ]; + expect(() => SvgHeightVisibility.fromJson(json), throwsFormatException); + }); + + test('runtime walls must cover every wall', () { + expect( + () => SvgHeightVisibility.fromJson(merged( + members: [for (final p in pieces.skip(1)) p['id'] as String])), + throwsFormatException); + }); + }); } From 795b9c961ecceb3cadbac38400bb8e98280e2aae Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 09:31:06 -0400 Subject: [PATCH 06/15] Build floor shadows three times faster, without the faces that add nothing A Lotus or Icebox cone built 900-2,200 shadow faces per frame, each a rectangle clipped through lists and closures: 3 ms typical, 17 ms at p99 in a profile-mode drag. Faces are now clipped in flat buffers, a face whose wedge misses the floor is dropped before any clipping, and where a shadow runs on for ever only the edges facing the eye cast it: an edge facing away shades what the near side already shades. On 113 Lotus and Icebox cones the shadows cover exactly the same 22,600 sample points, with 46% fewer faces in a third of the time. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_floor_visibility.dart | 183 ++++++++++++++++++++---- 1 file changed, 154 insertions(+), 29 deletions(-) diff --git a/lib/view_cone/svg_floor_visibility.dart b/lib/view_cone/svg_floor_visibility.dart index c34c8ff5..d7e3f098 100644 --- a/lib/view_cone/svg_floor_visibility.dart +++ b/lib/view_cone/svg_floor_visibility.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:typed_data'; import 'dart:ui'; /// A painted footprint extruded through absolute source-height intervals. @@ -10,6 +11,47 @@ class SvgFloorOccluder { final bool evenOdd; final List<(double, double)> bands; final Rect bounds; + + /// Every edge as ax, ay, bx, by, inside: which side of a to b the + /// footprint lies on, 1 left, -1 right, 0 where a step either way cannot + /// tell (a sliver thinner than the step). + late final Float64List edges = () { + final out = []; + for (final ring in rings) { + for (var i = 0; i < ring.length; i++) { + final a = ring[i], b = ring[(i + 1) % ring.length]; + if (a == b) continue; + final mid = (a + b) / 2, along = (b - a) / (b - a).distance; + final left = Offset(along.dy, -along.dx) * 1e-6; + final inLeft = _inside(mid + left), inRight = _inside(mid - left); + out.addAll([ + a.dx, + a.dy, + b.dx, + b.dy, + inLeft == inRight + ? 0 + : inLeft + ? 1 + : -1 + ]); + } + } + return Float64List.fromList(out); + }(); + + bool _inside(Offset point) { + var winding = 0; + for (final ring in rings) { + for (var i = 0; i < ring.length; i++) { + final a = ring[i], b = ring[(i + 1) % ring.length]; + final side = _cross(b - a, point - a); + if (a.dy <= point.dy && b.dy > point.dy && side > 0) winding++; + if (a.dy > point.dy && b.dy <= point.dy && side < 0) winding--; + } + } + return evenOdd ? winding.isOdd : winding != 0; + } } /// One measured destination floor as a cone overlooks it. Built from plain @@ -107,6 +149,8 @@ SvgFloorShadows svgFloorShadows({ if (bounds.isEmpty) return SvgFloorShadows._(faces, caps); final influence = bounds.expandToInclude(Rect.fromPoints(origin, origin)); final delta = targetEye - observerEye; + final ox = origin.dx, oy = origin.dy; + final clip = _Clipper(bounds); for (final wall in walls) { if (!wall.bounds.overlaps(influence)) continue; for (final band in wall.bands) { @@ -133,31 +177,123 @@ SvgFloorShadows svgFloorShadows({ ].where((ring) => ring.length >= 3).toList(); if (rings.isNotEmpty) caps.add((rings, wall.evenOdd)); } - for (final ring in wall.rings) { - for (var i = 0; i < ring.length; i++) { - final a = ring[i], b = ring[(i + 1) % ring.length]; - final av = a - origin, bv = b - origin; - final cross = _cross(av, bv); - if (cross == 0) continue; - final sign = cross.sign; - final edge = b - a; - var face = _rectangle(bounds); - face = _clip(face, (p) => sign * _cross(av, p - origin)); - face = _clip(face, (p) => -sign * _cross(bv, p - origin)); - face = _clip( - face, (p) => -sign * _cross(edge, p - (origin + av * near))); - if (far != null) { - face = _clip( - face, (p) => sign * _cross(edge, p - (origin + av * far))); - } - if (face.length >= 3) faces.add(_counterClockwise(face)); + final e = wall.edges; + for (var k = 0; k < e.length; k += 5) { + final avx = e[k] - ox, avy = e[k + 1] - oy; + final bvx = e[k + 2] - ox, bvy = e[k + 3] - oy; + final cross = avx * bvy - avy * bvx; + if (cross == 0) continue; + final ex = bvx - avx, ey = bvy - avy; + // A shadow that runs on for ever is cast whole by the edges facing + // the eye; an edge facing away only shades what they already shade. + if (far == null && e[k + 4] != 0) { + final eyeSide = ex * -avy - ey * -avx; + if (eyeSide * e[k + 4] < 0) continue; + } + final sign = cross.sign; + clip.reset(); + // Between the rays through the edge's ends, past its near copy. + if (!clip.cut(sign * -avy, sign * avx, 0, ox, oy) || + !clip.cut(-sign * -bvy, -sign * bvx, 0, ox, oy) || + !clip.cut( + -sign * -ey, -sign * ex, 0, ox + avx * near, oy + avy * near)) { + continue; } + if (far != null && + !clip.cut( + sign * -ey, sign * ex, 0, ox + avx * far, oy + avy * far)) { + continue; + } + final face = clip.polygon(); + if (face.length >= 3) faces.add(face); } } } return SvgFloorShadows._(faces, caps); } +/// Sutherland-Hodgman clipping of a rectangle by half-planes, in flat +/// buffers: a face is clipped by three or four lines per edge, thousands of +/// times per cone, so no lists or closures per step. +class _Clipper { + _Clipper(this.rect); + + final Rect rect; + var _a = Float64List(32), _b = Float64List(32); + var _n = 0; + + void reset() { + _a[0] = rect.left; + _a[1] = rect.top; + _a[2] = rect.right; + _a[3] = rect.top; + _a[4] = rect.right; + _a[5] = rect.bottom; + _a[6] = rect.left; + _a[7] = rect.bottom; + _n = 4; + } + + /// Keeps the side where nx * (x - px) + ny * (y - py) >= c. False when + /// nothing is left. + bool cut(double nx, double ny, double c, double px, double py) { + if (_n == 0) return false; + var allIn = true, anyIn = false; + for (var i = 0; i < _n; i++) { + final d = nx * (_a[i * 2] - px) + ny * (_a[i * 2 + 1] - py) - c; + if (d >= 0) { + anyIn = true; + } else { + allIn = false; + } + } + if (allIn) return true; + if (!anyIn) { + _n = 0; + return false; + } + if (_b.length < (_n + 1) * 2) _b = Float64List((_n + 1) * 4); + var m = 0; + for (var i = 0; i < _n; i++) { + final j = (i + 1) % _n; + final ax = _a[i * 2], ay = _a[i * 2 + 1]; + final bx = _a[j * 2], by = _a[j * 2 + 1]; + final da = nx * (ax - px) + ny * (ay - py) - c; + final db = nx * (bx - px) + ny * (by - py) - c; + if (da >= 0) { + _b[m * 2] = ax; + _b[m * 2 + 1] = ay; + m++; + } + if ((da >= 0) != (db >= 0)) { + final t = da / (da - db); + _b[m * 2] = ax + (bx - ax) * t; + _b[m * 2 + 1] = ay + (by - ay) * t; + m++; + } + } + final swap = _a; + _a = _b; + _b = swap; + if (_a.length < _b.length) _a = Float64List(_b.length)..setAll(0, _a); + _n = m; + return _n >= 3; + } + + /// The clipped face, wound one way so faces unite under nonzero fill. + List polygon() { + var twiceArea = 0.0; + for (var i = 0; i < _n; i++) { + final j = (i + 1) % _n; + twiceArea += _a[i * 2] * _a[j * 2 + 1] - _a[j * 2] * _a[i * 2 + 1]; + } + final points = [ + for (var i = 0; i < _n; i++) Offset(_a[i * 2], _a[i * 2 + 1]) + ]; + return twiceArea < 0 ? points.reversed.toList() : points; + } +} + /// [floor] inside [sector], less the shadows the walls cast on a target /// standing on it. The boolean form of what [SvgFloorShadows] paints. Path visibleSvgFloor({ @@ -180,17 +316,6 @@ Path visibleSvgFloor({ .subtractFrom(visible); } -List _counterClockwise(List points) { - var twiceArea = 0.0; - for (var i = 0; i < points.length; i++) { - twiceArea += _cross(points[i], points[(i + 1) % points.length]); - } - return twiceArea < 0 ? points.reversed.toList() : points; -} - -List _rectangle(Rect r) => - [r.topLeft, r.topRight, r.bottomRight, r.bottomLeft]; - List _clipRect(List points, Rect r) { var result = _clip(points, (p) => p.dx - r.left); result = _clip(result, (p) => r.right - p.dx); From a29e175e046c498ff0ec147d8fabfd912fdd2099 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 09:48:18 -0400 Subject: [PATCH 07/15] Let runtime walls carry a piece's own edges under its heights Where a merged outline cannot cover a piece's raw ring (a bow tie or a sliver the union cleans away), the model lists that ring as extra edges under the piece's heights, so a cone stops where the pieces stopped it. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_height_visibility.dart | 9 +++++++++ test/svg_height_visibility_test.dart | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 564072f4..233cf655 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -180,6 +180,15 @@ class SvgHeightVisibility { runtimeWalls = []; for (final raw in _list(json['runtimeWalls'], 'runtimeWalls')) { final row = _map(raw); + // A piece's own edges where its outline does not cover them (a bow + // tie, a sliver): extra geometry under its heights, not a member. + if (row['heightsOf'] != null) { + final owner = index[row['heightsOf']] ?? + (throw FormatException( + 'Runtime edges name missing wall ${row['heightsOf']}.')); + runtimeWalls.add(SvgRuntimeWall._(owner, _rings(row), _evenOdd(row))); + continue; + } final members = [ for (final id in _list(row['walls'], 'runtime wall members')) index[id] ?? diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 85c3687b..c2c1a80d 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -578,6 +578,19 @@ void main() { expect(() => SvgHeightVisibility.fromJson(json), throwsFormatException); }); + test('a piece's own edges can ride along under its heights', () { + // The outline covers only half the wall; the piece's raw ring comes + // along as extra edges, so the cone still stops at the whole wall. + final json = merged(ring: rectangle(10, -20, 11, 0)) + ..['runtimeWalls'].add({ + 'heightsOf': pieces.last['id'], + 'rings': [rectangle(10, 0, 11, 20)], + 'fillRule': 'nonzero', + }); + expect(area(cast(json).polygon), + closeTo(area(cast(data(pieces)).polygon), 1e-9)); + }); + test('runtime walls must cover every wall', () { expect( () => SvgHeightVisibility.fromJson(merged( From 1b08ca8c66b3539187d53698c7d856b168a83165 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 09:48:58 -0400 Subject: [PATCH 08/15] Fix the runtime-edges test name Co-Authored-By: Claude Opus 5.5 (1M context) --- test/svg_height_visibility_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index c2c1a80d..15b18191 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -578,7 +578,7 @@ void main() { expect(() => SvgHeightVisibility.fromJson(json), throwsFormatException); }); - test('a piece's own edges can ride along under its heights', () { + test('a piece keeps its own edges under its heights', () { // The outline covers only half the wall; the piece's raw ring comes // along as extra edges, so the cone still stops at the whole wall. final json = merged(ring: rectangle(10, -20, 11, 0)) From 871de7bad594eab0855a72c993ed3a1a39021375 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 09:50:49 -0400 Subject: [PATCH 09/15] Type the runtime-walls test list for added entries Co-Authored-By: Claude Opus 5.5 (1M context) --- test/svg_height_visibility_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 15b18191..61389141 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -551,7 +551,7 @@ void main() { ]; Map merged({List? members, List? ring}) => data(pieces) - ..['runtimeWalls'] = [ + ..['runtimeWalls'] = >[ { 'walls': members ?? [for (final p in pieces) p['id'] as String], 'rings': [ring ?? rectangle(10, -20, 11, 20)], From ef19055eabedd7a8c1aa352cc0969627c01cf93a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 09:51:56 -0400 Subject: [PATCH 10/15] Keep the refused-merge test off the shared pieces Co-Authored-By: Claude Opus 5.5 (1M context) --- test/svg_height_visibility_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 61389141..43fde741 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math' as math; import 'dart:ui'; @@ -571,7 +572,8 @@ void main() { }); test('a merge of pieces with different heights is refused', () { - final json = merged(); + // A copy: the pieces are shared with the other tests. + final json = jsonDecode(jsonEncode(merged())) as Map; (json['walls'] as List)[3]['bands'] = [ [0, 1] ]; From 705d017370da0fe297237637fed68a12c651249a Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 12:11:33 -0400 Subject: [PATCH 11/15] Tell a wall's side once per run of its ring, unknown where edges meet A bow tie's crossing edges change side along their length, so the side a midpoint probe found let floor shadows cull both diagonals and leave the lobe behind them lit. Edges something crosses or meets inside their length are now unknown, which only costs a seam or a shadow face; along a run of a ring that nothing else comes near the side cannot change, so one probe answers for the run. Cone edges and floor shadows share the answer, computed once per runtime wall at load instead of per edge on the first floor frame (Lotus: about 20 ms against 360 ms in a debug test). Runtime edges carried under a piece's heights may no longer also name members, which the loader ignored. Co-Authored-By: Claude Opus 5.5 (1M context) --- lib/view_cone/svg_floor_visibility.dart | 180 +++++++++++++++++++---- lib/view_cone/svg_height_visibility.dart | 39 +++-- test/svg_floor_visibility_test.dart | 41 ++++++ test/svg_height_visibility_test.dart | 11 ++ 4 files changed, 227 insertions(+), 44 deletions(-) diff --git a/lib/view_cone/svg_floor_visibility.dart b/lib/view_cone/svg_floor_visibility.dart index d7e3f098..e62a18a9 100644 --- a/lib/view_cone/svg_floor_visibility.dart +++ b/lib/view_cone/svg_floor_visibility.dart @@ -4,54 +4,180 @@ import 'dart:ui'; /// A painted footprint extruded through absolute source-height intervals. class SvgFloorOccluder { - SvgFloorOccluder(this.rings, this.evenOdd, this.bands) - : bounds = _bounds(rings.expand((ring) => ring)); + SvgFloorOccluder(this.rings, this.evenOdd, this.bands, [Int8List? sides]) + : bounds = _bounds(rings.expand((ring) => ring)), + _sides = sides; final List> rings; final bool evenOdd; final List<(double, double)> bands; final Rect bounds; + final Int8List? _sides; + /// Every edge as ax, ay, bx, by, inside: which side of a to b the - /// footprint lies on, 1 left, -1 right, 0 where a step either way cannot - /// tell (a sliver thinner than the step). + /// footprint lies on, 1 left, -1 right, 0 unknown (see [svgEdgeSides]). late final Float64List edges = () { + final sides = _sides ?? svgEdgeSides(rings, evenOdd); final out = []; + var k = 0; for (final ring in rings) { for (var i = 0; i < ring.length; i++) { final a = ring[i], b = ring[(i + 1) % ring.length]; if (a == b) continue; - final mid = (a + b) / 2, along = (b - a) / (b - a).distance; - final left = Offset(along.dy, -along.dx) * 1e-6; - final inLeft = _inside(mid + left), inRight = _inside(mid - left); - out.addAll([ - a.dx, - a.dy, - b.dx, - b.dy, - inLeft == inRight - ? 0 - : inLeft - ? 1 - : -1 - ]); + out.addAll([a.dx, a.dy, b.dx, b.dy, sides[k++].toDouble()]); } } return Float64List.fromList(out); }(); +} - bool _inside(Offset point) { - var winding = 0; - for (final ring in rings) { - for (var i = 0; i < ring.length; i++) { - final a = ring[i], b = ring[(i + 1) % ring.length]; - final side = _cross(b - a, point - a); - if (a.dy <= point.dy && b.dy > point.dy && side > 0) winding++; - if (a.dy > point.dy && b.dy <= point.dy && side < 0) winding--; +/// Which side of each edge a footprint lies on, edges in ring order skipping +/// zero-length ones: 1 left, -1 right, 0 unknown. +/// +/// Unknown where the side can change along the edge or a step either way +/// cannot tell: the edge touches or crosses another edge (a bow tie, a ring +/// meeting another), or the footprint is thinner than the step there. An +/// unknown side only costs work, never blocking: no seam is skipped and no +/// shadow face is culled on it. Along a run of a ring that nothing else comes +/// near, the side cannot change, so one probe answers for the whole run. +Int8List svgEdgeSides(List> rings, bool evenOdd) { + const step = 1e-6, near = 2 * step; + final ax = [], ay = [], bx = [], by = []; + final ringOf = []; + for (var r = 0; r < rings.length; r++) { + final ring = rings[r]; + for (var i = 0; i < ring.length; i++) { + final a = ring[i], b = ring[(i + 1) % ring.length]; + if (a == b) continue; + ax.add(a.dx); + ay.add(a.dy); + bx.add(b.dx); + by.add(b.dy); + ringOf.add(r); + } + } + final n = ax.length; + final ringStart = List.filled(rings.length, 0); + for (var e = n - 1; e >= 0; e--) { + ringStart[ringOf[e]] = e; + } + + double toEdge(double px, double py, int e) { + final ex = bx[e] - ax[e], ey = by[e] - ay[e]; + final t = (((px - ax[e]) * ex + (py - ay[e]) * ey) / (ex * ex + ey * ey)) + .clamp(0.0, 1.0); + final dx = ax[e] + ex * t - px, dy = ay[e] + ey * t - py; + return math.sqrt(dx * dx + dy * dy); + } + + double side(double px, double py, int e) => + (bx[e] - ax[e]) * (py - ay[e]) - (by[e] - ay[e]) * (px - ax[e]); + // Properly, each through the other's inside. + bool cross(int e, int f) => + side(ax[f], ay[f], e) * side(bx[f], by[f], e) < 0 && + side(ax[e], ay[e], f) * side(bx[e], by[e], f) < 0; + bool next(int e, int f) => + ringOf[e] == ringOf[f] && bx[e] == ax[f] && by[e] == ay[f]; + int following(int e) => + e + 1 < n && ringOf[e + 1] == ringOf[e] ? e + 1 : ringStart[ringOf[e]]; + + // An edge something meets inside its length can change side there. Where + // something meets a ring only at a corner, each edge keeps its side, but + // the side can change at that corner. + final crowded = List.filled(n, false); + final cornerMet = List.filled(n, false); // at the edge's start + void meet(double px, double py, int e) { + if (toEdge(px, py, e) > near) return; + if ((Offset(px, py) - Offset(ax[e], ay[e])).distance <= near) { + cornerMet[e] = true; + } else if ((Offset(px, py) - Offset(bx[e], by[e])).distance <= near) { + cornerMet[following(e)] = true; + } else { + crowded[e] = true; + } + } + + final order = List.generate(n, (i) => i) + ..sort((e, f) => math.min(ax[e], bx[e]).compareTo(math.min(ax[f], bx[f]))); + final active = []; + for (final e in order) { + final left = math.min(ax[e], bx[e]); + active.removeWhere((f) => math.max(ax[f], bx[f]) + near < left); + final top = math.min(ay[e], by[e]), bottom = math.max(ay[e], by[e]); + for (final f in active) { + if (math.min(ay[f], by[f]) > bottom + near || + math.max(ay[f], by[f]) < top - near) { + continue; + } + if (next(e, f) || next(f, e)) { + // Consecutive edges share a point by construction; they crowd each + // other only when one folds back along the other. + final (first, second) = next(e, f) ? (e, f) : (f, e); + if (toEdge(ax[first], ay[first], second) <= near || + toEdge(bx[second], by[second], first) <= near) { + crowded[e] = crowded[f] = true; + } + continue; } + if (cross(e, f)) crowded[e] = crowded[f] = true; + meet(ax[f], ay[f], e); + meet(bx[f], by[f], e); + meet(ax[e], ay[e], f); + meet(bx[e], by[e], f); + } + active.add(e); + } + + bool inside(double px, double py) { + var winding = 0; + for (var e = 0; e < n; e++) { + final s = side(px, py, e); + if (ay[e] <= py && by[e] > py && s > 0) winding++; + if (ay[e] > py && by[e] <= py && s < 0) winding--; } return evenOdd ? winding.isOdd : winding != 0; } + + int probe(int e) { + final ex = bx[e] - ax[e], ey = by[e] - ay[e]; + final length = math.sqrt(ex * ex + ey * ey); + final lx = ey / length * step, ly = -ex / length * step; + final mx = (ax[e] + bx[e]) / 2, my = (ay[e] + by[e]) / 2; + final inLeft = inside(mx + lx, my + ly), inRight = inside(mx - lx, my - ly); + return inLeft == inRight + ? 0 + : inLeft + ? 1 + : -1; + } + + final sides = Int8List(n); + for (var start = 0, end = 0; start < n; start = end) { + while (end < n && ringOf[end] == ringOf[start]) { + end++; + } + final count = end - start; + bool breaks(int e) => + cornerMet[e] || crowded[e] || crowded[e == start ? end - 1 : e - 1]; + // Walk the ring from a break, so a run never wraps past its start. + var first = 0; + while (first < count && !breaks(start + first)) { + first++; + } + if (first == count) first = 0; + int? runSide; + for (var i = 0; i < count; i++) { + final e = start + (first + i) % count; + if (crowded[e]) { + runSide = null; + continue; + } + if (runSide == null || breaks(e)) runSide = probe(e); + sides[e] = runSide; + } + } + return sides; } /// One measured destination floor as a cone overlooks it. Built from plain @@ -335,8 +461,6 @@ List _clip(List points, double Function(Offset) distance) { return result; } -double _cross(Offset a, Offset b) => a.dx * b.dy - a.dy * b.dx; - Rect _bounds(Iterable points) { var left = double.infinity, top = double.infinity; var right = double.negativeInfinity, bottom = double.negativeInfinity; diff --git a/lib/view_cone/svg_height_visibility.dart b/lib/view_cone/svg_height_visibility.dart index 233cf655..24a81fa2 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -25,25 +25,19 @@ class SvgHeightVisibility { SvgRuntimeWall._(i, walls[i].rings, walls[i].evenOdd) ]) { for (final shape in this.runtimeWalls) { - final wall = shape.wall; - for (final ring in shape.rings) { - final points = _runtimeRing(ring); + final sides = shape.sides; + var k = 0; + for (final points in shape.edgeRings) { for (var i = 0; i < points.length; i++) { final a = points[i], b = points[(i + 1) % points.length]; if (a == b) continue; - // Which side the wall lies on, when a step either way tells: a - // wall thinner than the step is inside on neither side or both. - final mid = (a + b) / 2, along = (b - a) / (b - a).distance; - final left = Offset(along.dy, -along.dx) * 1e-6; - final inLeft = shape.contains(mid + left), - inRight = shape.contains(mid - left); - final edge = _Edge(a, b, wall, - interior: inLeft == inRight + final side = sides[k++]; + _edges.add(_Edge(a, b, shape.wall, + interior: side == 0 ? null - : inLeft + : side > 0 ? _Side.left - : _Side.right); - _edges.add(edge); + : _Side.right)); } } } @@ -183,6 +177,10 @@ class SvgHeightVisibility { // A piece's own edges where its outline does not cover them (a bow // tie, a sliver): extra geometry under its heights, not a member. if (row['heightsOf'] != null) { + if (_list(row['walls'] ?? const [], 'walls').isNotEmpty) { + throw FormatException( + 'Runtime edges of ${row['heightsOf']} also name members.'); + } final owner = index[row['heightsOf']] ?? (throw FormatException( 'Runtime edges name missing wall ${row['heightsOf']}.')); @@ -252,8 +250,8 @@ class SvgHeightVisibility { late final _floorOccluders = [ for (final shape in runtimeWalls) - SvgFloorOccluder( - shape.rings, shape.evenOdd, _absoluteBands(walls[shape.wall])) + SvgFloorOccluder(shape.edgeRings, shape.evenOdd, + _absoluteBands(walls[shape.wall]), shape.sides) ]; static List<(double, double)> _absoluteBands(SvgHeightWall wall) => [ @@ -1121,6 +1119,15 @@ class SvgRuntimeWall extends _Footprint { /// The index in [SvgHeightVisibility.walls] whose heights it carries. final int wall; + + /// [rings] without repeated points or points midway along straight + /// horizontal and vertical runs: the edges rays are cast against. + late final List> edgeRings = [ + for (final ring in rings) _runtimeRing(ring) + ]; + + /// The side of each edge of [edgeRings] the wall lies on. + late final Int8List sides = svgEdgeSides(edgeRings, evenOdd); } class SvgHeightWall extends _Footprint { diff --git a/test/svg_floor_visibility_test.dart b/test/svg_floor_visibility_test.dart index 36addaad..7beb7db1 100644 --- a/test/svg_floor_visibility_test.dart +++ b/test/svg_floor_visibility_test.dart @@ -59,4 +59,45 @@ void main() { expect(visible(1, 4.75, walls: [overhead]).contains(const Offset(20, 0)), isTrue); }); + + test('a bow tie still shades the floor behind its crossing', () { + // The diagonals cross, so neither keeps one side along its length: a + // face culled as facing away would leave the lower lobe see-through. + final bowTie = SvgFloorOccluder( + [ + const [Offset(0, 0), Offset(2.5, 2.5), Offset(0, 4), Offset(2, 0)] + ], + false, + [(0, 3)]); + final result = visibleSvgFloor( + floor: Path()..addRect(const Rect.fromLTRB(-2, 0, 0, 1)), + sector: Path()..addRect(const Rect.fromLTRB(-30, -30, 30, 30)), + origin: const Offset(3, 0.5), + observerEye: 2, + targetEye: 1, + walls: [bowTie]); + expect(result.contains(const Offset(-1, 0.5)), isFalse); + }); + + test('edge sides are known only where nothing crowds the edge', () { + final square = rectangle(const Rect.fromLTRB(0, 0, 1, 1)); + expect(svgEdgeSides([square], false), [-1, -1, -1, -1]); + expect(svgEdgeSides([square.reversed.toList()], false), [1, 1, 1, 1]); + // A hole is inside on the other side of its edges. + expect( + svgEdgeSides( + [rectangle(const Rect.fromLTRB(-1, -1, 2, 2)), square], true), + [-1, -1, -1, -1, 1, 1, 1, 1]); + // The crossing diagonals of a bow tie are unknown, its others are not. + expect( + svgEdgeSides([ + const [Offset(0, 0), Offset(2.5, 2.5), Offset(0, 4), Offset(2, 0)] + ], false), + [0, isNot(0), 0, isNot(0)]); + // Squares sharing an edge: along it the side is in on both. + expect( + svgEdgeSides( + [square, rectangle(const Rect.fromLTRB(1, 0, 2, 1))], false), + [-1, 0, -1, -1, -1, -1, -1, 0]); + }); } diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 43fde741..c79505d0 100644 --- a/test/svg_height_visibility_test.dart +++ b/test/svg_height_visibility_test.dart @@ -593,6 +593,17 @@ void main() { closeTo(area(cast(data(pieces)).polygon), 1e-9)); }); + test('edges carried under a piece heights name no members', () { + final json = merged() + ..['runtimeWalls'].add({ + 'heightsOf': pieces.first['id'], + 'walls': [pieces.last['id']], + 'rings': [rectangle(10, 0, 11, 20)], + 'fillRule': 'nonzero', + }); + expect(() => SvgHeightVisibility.fromJson(json), throwsFormatException); + }); + test('runtime walls must cover every wall', () { expect( () => SvgHeightVisibility.fromJson(merged( From e4c8d932d0aba5841d48bcf7f690df069de13c8e Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 14:13:20 -0400 Subject: [PATCH 12/15] Run a cone query's threads above normal priority on Windows The caller waits for every chunk of a query, so a pool thread the scheduler sets aside for a 15 ms time slice while holding one stalled the drag. On a desktop busy with other work (12 busy processes on 16 cores) a sweep of 16,000 Summit queries had p99 6.6 ms and a 163 ms worst case; with the calling thread raised for the query and the workers raised for good (they sleep when idle), p99 is 2.6 ms and the worst 5.5 ms. A quiet machine is unchanged (p99 1.9 ms). Co-Authored-By: Claude Opus 5.5 (1M context) --- native/height/icarus_svg_height.cpp | 34 ++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/native/height/icarus_svg_height.cpp b/native/height/icarus_svg_height.cpp index a5efb827..c8637eac 100644 --- a/native/height/icarus_svg_height.cpp +++ b/native/height/icarus_svg_height.cpp @@ -19,6 +19,13 @@ #include #include +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif + static_assert(sizeof(ISHResult) == 72); static_assert(offsetof(ISHResult, points) == 8); static_assert(offsetof(ISHResult, queryMicros) == 64); @@ -219,10 +226,34 @@ struct Crossing { Point point; uint32_t first, second; }; // that run's count, fails the bounds test, and touches nothing; a claim // within range keeps the run alive until the chunk is done, so the callback // and the remaining counter it then reads belong to that run. +// A query is a few milliseconds of work split across threads, and the +// caller waits for every chunk. A thread the scheduler sets aside for a +// time slice (15 ms on Windows) holding one chunk stalls the whole query, so +// the threads doing a query run above normal priority while they do it. +struct Boost { +#ifdef _WIN32 + Boost() : thread(GetCurrentThread()), previous(GetThreadPriority(thread)) { + if (previous < THREAD_PRIORITY_ABOVE_NORMAL) + SetThreadPriority(thread, THREAD_PRIORITY_ABOVE_NORMAL); + } + ~Boost() { + if (previous < THREAD_PRIORITY_ABOVE_NORMAL) + SetThreadPriority(thread, previous); + } + HANDLE thread; + int previous; +#endif +}; + struct Pool { explicit Pool(unsigned workers) { for (unsigned i = 0; i < workers; ++i) - threads.emplace_back([this] { loop(); }); + threads.emplace_back([this] { +#ifdef _WIN32 + SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); +#endif + loop(); + }); } ~Pool() { { @@ -601,6 +632,7 @@ int32_t ish_query(void *opaque, double originX, double originY, out->status = ISH_BUSY; return ISH_BUSY; } + Boost boost; const auto started = Clock::now(); try { const double values[] = {originX, originY, directionRadians, range, From 830a96562af7c64f81637bb9662fa7961ef69f4f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 14:32:09 -0400 Subject: [PATCH 13/15] Cast no side rays at a vertex the wall runs straight across Every vertex in reach cast three rays: at it and just beside it on each side, so that a wall turning away there lets the ray past it find what is behind. Where the vertex's two active edges leave to either side of the ray, the wall runs across it and the rays beside it only meet those two edges. Curved walls flattened into centimetre edges are made of such vertices. Cones keep their exact area (to 1e-9 against the Dart query on 1,200 Pearl, Summit and Lotus defense cones) with half the points; the Pearl defense sweep's p99 goes from 6.5 ms to 5.1 ms. Co-Authored-By: Claude Opus 5.5 (1M context) --- native/height/icarus_svg_height.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/native/height/icarus_svg_height.cpp b/native/height/icarus_svg_height.cpp index c8637eac..b62bee52 100644 --- a/native/height/icarus_svg_height.cpp +++ b/native/height/icarus_svg_height.cpp @@ -467,6 +467,25 @@ bool seam(const Handle &handle, uint32_t vertex, const uint8_t *active) { std::abs(cross(away[0], away[1])) <= 1e-12 * lengths; } +// Whether the wall runs straight across the ray at a vertex: 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(const Handle &handle, uint32_t vertex, Point delta, + const uint8_t *active) { + std::array sides{}; + size_t count = 0; + const Point at = handle.vertexPoints[vertex]; + for (uint32_t id : handle.vertexEdges[vertex]) { + const Edge &edge = handle.edges[id]; + if (!active[edge.wall]) continue; + if (count == sides.size()) return false; + sides[count++] = cross(delta, (edge.aVertex == vertex ? edge.b : edge.a) - at); + } + return count == 2 && sides[0] * sides[1] < 0; +} + Hit castRay(const Handle &handle, Point origin, Point direction, double range, const uint8_t *active, Counters &counters) { Hit result; @@ -734,8 +753,9 @@ int32_t ish_query(void *opaque, double originX, double originY, std::vector &angles, &vertex, &outAngles, &outVertex; ~Store() { outAngles = std::move(angles); outVertex = std::move(vertex); } } store{localAngles, localVertex, handle.chunkAngles[chunk], handle.chunkVertexAngles[chunk]}; - auto emit = [&](double angle, bool vertex) { + auto emit = [&](double angle, bool vertex, bool beside = true) { for (double event : {angle - cornerOffset, angle, angle + cornerOffset}) { + if (event != angle && !beside) continue; if (event >= -half && event <= half) { localAngles.push_back(event); if (vertex && event == angle) localVertex.push_back(event); @@ -819,7 +839,8 @@ int32_t ish_query(void *opaque, double originX, double originY, continue; } } - emit(angle, true); + emit(angle, true, + !passThrough(handle, endpointVertices[endpoint], delta, active)); } } }); From 167353eb2fab246e99a92b98983af251599b0a0f Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sat, 3 Oct 2026 14:33:55 -0400 Subject: [PATCH 14/15] Pin the rays past a curved wall's silhouette Co-Authored-By: Claude Opus 5.5 (1M context) --- test/svg_height_native_test.dart | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/svg_height_native_test.dart b/test/svg_height_native_test.dart index 42963469..d9f7e71a 100644 --- a/test/svg_height_native_test.dart +++ b/test/svg_height_native_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; +import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/view_cone/svg_height_native.dart'; @@ -32,6 +33,45 @@ void main() { native.close(); } }, skip: library == null ? 'Set ICARUS_SVG_NATIVE_LIBRARY.' : false); + test('a curved wall still lets rays past its silhouette', () { + // A 64-gon in front of a far wall. Its vertices are all corners a wall + // runs straight across, except the two at its silhouette, where the + // rays beside the vertex find the far wall. + final edges = []; + for (var k = 0; k < 64; k++) { + final a = k * 2 * math.pi / 64, b = (k + 1) * 2 * math.pi / 64; + edges.addAll([ + 20 + 5 * math.cos(a), 5 * math.sin(a), // + 20 + 5 * math.cos(b), 5 * math.sin(b), 0 + ]); + } + edges.addAll([60, -50, 60, 50, 1]); + final native = SvgHeightNative.open( + wallIds: ['curve', 'far'], + edgeRecords: Float64List.fromList(edges), + libraryPath: library); + try { + final cone = native.query( + origin: Offset.zero, + directionRadians: 0.05, + range: 100, + apertureRadians: 1, + activeWalls: [true, true]); + final path = Path() + ..addPolygon([ + for (var i = 0; i < cone.xy.length; i += 2) + Offset(cone.xy[i], cone.xy[i + 1]) + ], true); + Offset at(double degrees, double distance) => + Offset.fromDirection(degrees * math.pi / 180, distance); + // Just past the silhouette (14.47 degrees), between two arc rays. + expect(path.contains(at(14.54, 59)), isTrue); + expect(path.contains(at(10, 12)), isTrue); + expect(path.contains(at(10, 30)), isFalse); + } finally { + native.close(); + } + }, skip: library == null ? 'Set ICARUS_SVG_NATIVE_LIBRARY.' : false); test('native cone includes a long wall crossing the range circle', () { final native = SvgHeightNative.open( wallIds: ['wall'], From 5c2fb7822f4e6f3e7d707140a0d893db714c5836 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Sun, 4 Oct 2026 12:12:45 -0400 Subject: [PATCH 15/15] Place a nudged cone where the walls are, not where the agent's cone starts An agent on a wall's ink is cast from just outside it. The painter put the map in place by that nudged origin, at the agent, which shifted the whole cone and its floor clip by the nudge: on Ascent's B wall about 4 px, so every edge sat off the drawn walls. The map is now placed by where the agent really stands; the cone lands where it was cast, a hair off the apex. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../utilities/svg_height_view_cone.dart | 14 ++-- test/svg_height_app_integration_test.dart | 69 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 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 213459ec..394aebe7 100644 --- a/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart +++ b/lib/widgets/draggable_widgets/utilities/svg_height_view_cone.dart @@ -171,12 +171,14 @@ class _SvgHeightViewConeState extends State { ..[5] = cosine * sy ..[10] = 1 ..[15] = 1; - transform[12] = apex.dx - - transform[0] * sourceOrigin.dx - - transform[4] * sourceOrigin.dy; - transform[13] = apex.dy - - transform[1] * sourceOrigin.dx - - transform[5] * sourceOrigin.dy; + // The agent stands at the apex, so the map is placed by where it really + // is. The cone was cast from the nudged origin and lands there, a hair + // off the apex; placing it by the nudged origin instead would shift every + // edge off the drawn walls by the nudge. + transform[12] = + apex.dx - transform[0] * rawOrigin.dx - transform[4] * rawOrigin.dy; + transform[13] = + apex.dy - transform[1] * rawOrigin.dx - transform[5] * rawOrigin.dy; // During a drag only the translation changes frame to frame. Keep the // rotated, scaled receiver path and translate it on the canvas, so the // clip path object stays the same and the raster cache can keep it. diff --git a/test/svg_height_app_integration_test.dart b/test/svg_height_app_integration_test.dart index de9729f5..50a20221 100644 --- a/test/svg_height_app_integration_test.dart +++ b/test/svg_height_app_integration_test.dart @@ -291,6 +291,75 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + testWidgets('a cone nudged out of wall ink still stops on the drawn walls', + (tester) async { + // An agent standing on a wall's ink is cast from just outside it. The + // cone must still be drawn where the walls are, not shifted by that + // nudge. + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + Map wall(String id, double top, double bottom) => { + 'id': id, + 'rings': [ + [100, top, 300, top, 300, bottom, 100, bottom] + ], + 'bands': [ + [0, 10] + ], + 'unknownHeight': false, + }; + final model = SvgHeightVisibility.fromJson( + _fixture(walls: [wall('ledge', 199, 203.5), wall('far', 170, 180)])); + final runtime = SplitSvgHeightRuntime(model, model); + const raw = Offset(200, 201); + final nudged = model.standablePointNear(raw)!; + final scale = CoordinateSystem.instance.worldHeightToScreen( + SvgHeightMapTransform.forMap(MapValue.split).scale); + expect((raw - nudged).distance * scale, greaterThan(3), + reason: 'the nudge must be visible for this test to mean anything'); + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 400, + height: 300, + child: SvgHeightViewCone( + runtime: runtime, + canonicalOrigin: + SplitSvgMapTransform.sideWorldFromSource(raw, isAttack: true), + rotation: 0, + range: 100, + angle: math.pi / 4, + isAttack: true, + )))); + final painter = tester + .widgetList(find.byType(CustomPaint)) + .map((p) => p.painter) + .whereType() + .single; + const size = Size(400, 300); + final bytes = (await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), size); + final picture = recorder.endRecording(); + final image = await picture.toImage(400, 300); + final data = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))! + .buffer + .asUint8List(); + image.dispose(); + picture.dispose(); + return data; + }))!; + // Up the apex column from where the cone was cast, the lit area ends at + // the far wall's near face. + final x = painter.apex.dx.round(); + var top = (painter.apex.dy - (raw.dy - nudged.dy) * scale).round() - 2; + while (top > 0 && bytes[((top - 1) * 400 + x) * 4 + 3] > 0) { + top--; + } + final face = painter.apex.dy - (raw.dy - 180) * scale; + expect(top, closeTo(face, 1.5)); + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('mounted anonymous cones own separate cache entries', (tester) async { CoordinateSystem(playAreaSize: const Size(1920, 1080));