diff --git a/lib/view_cone/svg_floor_visibility.dart b/lib/view_cone/svg_floor_visibility.dart index d3a60491b..e62a18a97 100644 --- a/lib/view_cone/svg_floor_visibility.dart +++ b/lib/view_cone/svg_floor_visibility.dart @@ -1,34 +1,282 @@ import 'dart:math' as math; +import 'dart:typed_data'; 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 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; + out.addAll([a.dx, a.dy, b.dx, b.dy, sides[k++].toDouble()]); + } + } + return Float64List.fromList(out); + }(); } -/// 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, +/// 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 +/// geometry, so a worker isolate can make one; paths are made when painted. +class SvgFloorLayer { + SvgFloorLayer(this.rings, this.evenOdd, this.shadows); + + /// The floor's footprint, in source coordinates. + 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, +/// 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; + 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) { @@ -47,55 +295,152 @@ 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); + 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)); + } + 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; } - if (!cap.getBounds().isEmpty) { - visible = Path.combine(PathOperation.difference, visible, cap); + 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; } - } - 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) { - // 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 (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; } - return visible; } -List _rectangle(Rect r) => - [r.topLeft, r.topRight, r.bottomRight, r.bottomLeft]; +/// [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 _clipRect(List points, Rect r) { var result = _clip(points, (p) => p.dx - r.left); @@ -116,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_native_io.dart b/lib/view_cone/svg_height_native_io.dart index 2e403bb27..a41647fde 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,18 @@ class SvgHeightNative implements Finalizable { static const int _maximumPointCount = 1 << 22; + /// [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? interiorSides, String? libraryPath, }) { final edgeCount = edgeRecords.length ~/ 5; if (edgeRecords.length % 5 != 0 || + (interiorSides != null && interiorSides.length != edgeCount) || wallIds.length > 1 << 20 || edgeCount > 1 << 19) { throw ArgumentError('SVG native geometry exceeds its bounded capacity.'); @@ -153,6 +160,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 +183,18 @@ class SvgHeightNative implements Finalizable { close(handle); throw StateError('SVG native context has no query buffers.'); } + if (interiorSides != null && interiorSides.isNotEmpty) { + final sides = calloc(interiorSides.length); + try { + 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.'); + } + } finally { + calloc.free(sides); + } + } return SvgHeightNative._( ids, handle, mask, result, query, readError, close, finalizer); } finally { @@ -187,13 +208,17 @@ class SvgHeightNative implements Finalizable { static SvgHeightNative? tryOpen({ required List wallIds, required Float64List edgeRecords, + Uint8List? interiorSides, 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, + interiorSides: interiorSides, + 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 eb871db6c..eefadca03 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? interiorSides, String? libraryPath}) => throw UnsupportedError('Native acceleration is unavailable on web.'); static SvgHeightNative? tryOpen( {required List wallIds, required Float64List edgeRecords, + 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 9af435da9..24a81fa22 100644 --- a/lib/view_cone/svg_height_visibility.dart +++ b/lib/view_cone/svg_height_visibility.dart @@ -17,15 +17,27 @@ 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) { - final points = _runtimeRing(ring); + 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 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; - final edge = _Edge(a, b, wall); - _edges.add(edge); + final side = sides[k++]; + _edges.add(_Edge(a, b, shape.wall, + interior: side == 0 + ? null + : side > 0 + ? _Side.left + : _Side.right)); } } } @@ -153,6 +165,55 @@ 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); + // 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']}.')); + runtimeWalls.add(SvgRuntimeWall._(owner, _rings(row), _evenOdd(row))); + continue; + } + 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), @@ -163,7 +224,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; @@ -173,12 +243,18 @@ class SvgHeightVisibility { final SvgGroundHeight? ground; final bool requiresPhysicalGround; final List sightlineFloors; - late final _floorPaths = [ - for (final floor in sightlineFloors) _footprintPath(floor) - ]; + + /// 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.edgeRings, shape.evenOdd, + _absoluteBands(walls[shape.wall]), shape.sides) + ]; + + static List<(double, double)> _absoluteBands(SvgHeightWall wall) => [ if (wall.unknownHeight) (double.negativeInfinity, double.infinity) else @@ -189,8 +265,7 @@ class SvgHeightVisibility { : (wall.floorElevationMeters ?? 0) + band.bottom, (wall.floorElevationMeters ?? 0) + band.top ) - ]) - ]; + ]; final _edges = <_Edge>[]; _EdgeNode? _tree; SvgHeightNative? _native; @@ -210,6 +285,50 @@ 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]) 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.interior != second.interior) || + (reversed && first.interior == second.interior)) { + 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; @@ -244,6 +363,14 @@ class SvgHeightVisibility { if (walls.any((wall) => wall.unknownHeight)) return false; _native = SvgHeightNative.tryOpen( wallIds: [for (final wall in walls) wall.id], + 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, @@ -613,49 +740,39 @@ 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 floor = sightlineFloors[i]; + final bounds = floor.bounds.intersect(reach); + if (bounds.width <= 0 || bounds.height <= 0) continue; + floors.add(SvgFloorLayer( + floor.rings, + floor.evenOdd, + 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 +783,8 @@ class SvgHeightVisibility { candidateMicros: stats.candidateMicros, nativeMicros: stats.nativeMicros), eyeElevationMeters: eye, - visibilityPath: visibility); + floors: floors, + sector: sector); } SvgVisibilityCone _horizontalCone({ @@ -811,7 +929,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; @@ -994,6 +1112,24 @@ 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; + + /// [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 { SvgHeightWall._(this.id, super.rings, super.evenOdd, List bands, this.unknownHeight, this.floorElevationMeters) @@ -1064,7 +1200,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 +1210,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 +1222,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. @@ -1118,14 +1278,36 @@ 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); +/// 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; + // 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, add); + canvas.clipPath(sector); + canvas.drawPath(layer.floor, fill); + layer.shadows.erase(canvas); + canvas.restore(); } - return path; + canvas.restore(); } class SvgVisibilityStats { @@ -1186,10 +1368,19 @@ class _Footprint { } } +enum _Side { left, right } + class _Edge { - const _Edge(this.a, this.b, this.wall); + _Edge(this.a, this.b, this.wall, {required this.interior}) + : _inverseLength = 1 / (b - a).distance; final Offset a, b; final int wall; + + /// 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) { final edge = b - a, relative = a - origin; final determinant = _cross(direction, edge); @@ -1202,13 +1393,15 @@ 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; + // 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 >= -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 02430248a..394aebe76 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. @@ -193,13 +195,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 +231,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 +240,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 +260,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 +274,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 +333,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 78ad3b1f5..b62bee52e 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); @@ -62,6 +69,10 @@ struct Edge { Point a, b; uint32_t wall; uint32_t aVertex = 0, bVertex = 0; + // See the Dart _Edge: corner roundoff admitted along the wall. + double inverseLength = 0; + // 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 { @@ -84,9 +95,9 @@ 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; + const double slack = (1e-13 + rayDistance * 1e-10) * inverseLength; if (rayDistance >= 0 && rayDistance <= range && - along >= -endpointRoundoff && along <= 1 + endpointRoundoff) { + along >= -slack && along <= 1 + slack) { distance = rayDistance; return true; } @@ -215,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() { { @@ -316,6 +351,10 @@ 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; + bool interiorSides = false; Pool pool{poolWorkers()}; std::mutex mutex; std::string error; @@ -336,6 +375,16 @@ struct Handle { }; edge.aVertex = id(edge.a); edge.bVertex = id(edge.b); + const Point delta = edge.b - edge.a; + edge.inverseLength = 1 / 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 +423,69 @@ 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) { + if (!handle.interiorSides) return false; + 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]]; + // 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 (first.interior > 1 || second.interior > 1) continue; + if ((same && first.interior != second.interior) || + (reversed && first.interior == second.interior)) + 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; +} + +// 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; @@ -502,6 +614,20 @@ void *ish_open(const double *records, uint32_t edgeCount, uint32_t wallCount, } } +int32_t ish_set_interior_sides(void *opaque, const uint8_t *sides, + uint32_t edgeCount) { + 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) + 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; +} + uint8_t *ish_active_wall_buffer(void *opaque) { return opaque ? static_cast(opaque)->activeScratch.data() : nullptr; } @@ -525,6 +651,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, @@ -626,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); @@ -681,7 +809,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); @@ -709,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)); } } }); diff --git a/native/height/icarus_svg_height.h b/native/height/icarus_svg_height.h index a0982ba6b..36097ef1e 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); +// 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, uint32_t errorCapacity); ISH_API int32_t ish_close(void *handle); diff --git a/test/svg_floor_visibility_test.dart b/test/svg_floor_visibility_test.dart index 36addaadd..7beb7db1d 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_app_integration_test.dart b/test/svg_height_app_integration_test.dart index de9729f59..50a202218 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)); diff --git a/test/svg_height_native_test.dart b/test/svg_height_native_test.dart index 429634692..d9f7e71a0 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'], diff --git a/test/svg_height_visibility_test.dart b/test/svg_height_visibility_test.dart index 7904d2631..c79505d0c 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'; @@ -467,4 +468,147 @@ 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('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)]) + ], 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); + }); + + 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', () { + // 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] + ]; + expect(() => SvgHeightVisibility.fromJson(json), throwsFormatException); + }); + + 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)) + ..['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('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( + members: [for (final p in pieces.skip(1)) p['id'] as String])), + throwsFormatException); + }); + }); }