Skip to content

Commit

Permalink
Merge pull request #4577 from kwvanderlinde/refactor/4506-visibility-…
Browse files Browse the repository at this point in the history
…cleanup

Simplify AreaMeta and move vision blocking algorithm into a dedicated class
  • Loading branch information
cwisniew authored Dec 23, 2023
2 parents 5b424c3 + b1a5a35 commit f4a63e5
Show file tree
Hide file tree
Showing 10 changed files with 565 additions and 786 deletions.
14 changes: 12 additions & 2 deletions src/main/java/net/rptools/lib/GeometryUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.PrecisionModel;
import org.locationtech.jts.noding.NodableSegmentString;
import org.locationtech.jts.noding.NodedSegmentString;
Expand Down Expand Up @@ -68,7 +69,7 @@ public static GeometryFactory getGeometryFactory() {
return geometryFactory;
}

public static Geometry toJts(Area area) {
private static Polygonizer toPolygonizer(Area area) {
final var pathIterator = area.getPathIterator(null);
final var polygonizer = new Polygonizer(true);
final var coords = (List<Coordinate[]>) ShapeReader.toCoordinates(pathIterator);
Expand All @@ -78,6 +79,7 @@ public static Geometry toJts(Area area) {
for (var string : coords) {
strings.add(new NodedSegmentString(string, null));
}

final var noder = new SnapRoundingNoder(precisionModel);
noder.computeNodes(strings);
final Collection<? extends SegmentString> nodedStrings = noder.getNodedSubstrings();
Expand All @@ -99,6 +101,14 @@ public static Geometry toJts(Area area) {
invalidRings);
}

return polygonizer.getGeometry();
return polygonizer;
}

public static Geometry toJts(Area area) {
return toPolygonizer(area).getGeometry();
}

public static Collection<Polygon> toJtsPolygons(Area area) {
return toPolygonizer(area).getPolygons();
}
}
220 changes: 21 additions & 199 deletions src/main/java/net/rptools/maptool/client/ui/zone/FogUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,20 @@
import java.awt.Rectangle;
import java.awt.geom.Area;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import net.rptools.lib.CodeTimer;
import net.rptools.lib.GeometryUtil;
import net.rptools.maptool.client.AppUtil;
import net.rptools.maptool.client.MapTool;
import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer;
import net.rptools.maptool.client.ui.zone.vbl.AreaTree;
import net.rptools.maptool.client.ui.zone.vbl.VisibilitySweepEndpoint;
import net.rptools.maptool.client.ui.zone.vbl.VisibilityProblem;
import net.rptools.maptool.client.ui.zone.vbl.VisionBlockingAccumulator;
import net.rptools.maptool.model.AbstractPoint;
import net.rptools.maptool.model.CellPoint;
Expand All @@ -51,18 +46,11 @@
import net.rptools.maptool.model.player.Player.Role;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.locationtech.jts.algorithm.Orientation;
import org.locationtech.jts.awt.ShapeWriter;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineSegment;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.Polygon;
import org.locationtech.jts.geom.prep.PreparedGeometry;
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
import org.locationtech.jts.geom.util.LineStringExtracter;
import org.locationtech.jts.operation.union.UnaryUnionOp;

public class FogUtil {
private static final Logger log = LogManager.getLogger(FogUtil.class);
Expand All @@ -73,13 +61,13 @@ public class FogUtil {
*
* @param origin the vision origin.
* @param vision the lightSourceArea.
* @param topology the VBL topology.
* @param wallVbl the VBL topology.
* @return the visible area.
*/
public static @Nonnull Area calculateVisibility(
Point origin,
Area vision,
AreaTree topology,
AreaTree wallVbl,
AreaTree hillVbl,
AreaTree pitVbl,
AreaTree coverVbl) {
Expand All @@ -94,26 +82,31 @@ public class FogUtil {
* cannot handle. These cases do not exist within a single type of topology, but can arise when
* we combine them.
*/

var topologies = new EnumMap<Zone.TopologyType, AreaTree>(Zone.TopologyType.class);
topologies.put(Zone.TopologyType.WALL_VBL, wallVbl);
topologies.put(Zone.TopologyType.HILL_VBL, hillVbl);
topologies.put(Zone.TopologyType.PIT_VBL, pitVbl);
topologies.put(Zone.TopologyType.COVER_VBL, coverVbl);

List<Geometry> visibleAreas = new ArrayList<>();
final List<Function<VisionBlockingAccumulator, Boolean>> topologyConsumers = new ArrayList<>();
topologyConsumers.add(acc -> acc.addWallBlocking(topology));
topologyConsumers.add(acc -> acc.addHillBlocking(hillVbl));
topologyConsumers.add(acc -> acc.addPitBlocking(pitVbl));
topologyConsumers.add(acc -> acc.addCoverBlocking(coverVbl));
for (final var consumer : topologyConsumers) {
for (final var topology : topologies.entrySet()) {
final var solver =
new VisibilityProblem(
geometryFactory, new Coordinate(origin.getX(), origin.getY()), visionGeometry);
final var accumulator =
new VisionBlockingAccumulator(geometryFactory, origin, visionGeometry);
final var isVisionCompletelyBlocked = consumer.apply(accumulator);
final var isVisionCompletelyBlocked = accumulator.add(topology.getKey(), topology.getValue());
if (!isVisionCompletelyBlocked) {
// Vision has been completely blocked by this topology. Short circuit.
return new Area();
}

final var visibleArea =
calculateVisibleArea(
new Coordinate(origin.getX(), origin.getY()),
accumulator.getVisionBlockingSegments(),
visionGeometry);
for (var string : accumulator.getVisionBlockingSegments()) {
solver.add(string);
}

final var visibleArea = solver.solve();
if (visibleArea != null) {
visibleAreas.add(visibleArea);
}
Expand All @@ -134,177 +127,6 @@ public class FogUtil {
return vision;
}

private record NearestWallResult(LineSegment wall, Coordinate point, double distance) {}

private static NearestWallResult findNearestOpenWall(
Set<LineSegment> openWalls, LineSegment ray) {
assert !openWalls.isEmpty();

@Nullable LineSegment currentNearest = null;
@Nullable Coordinate currentNearestPoint = null;
double nearestDistance = Double.MAX_VALUE;
for (final var openWall : openWalls) {
final var intersection = ray.lineIntersection(openWall);
if (intersection == null) {
continue;
}

final var distance = ray.p0.distance(intersection);
if (distance < nearestDistance) {
currentNearest = openWall;
currentNearestPoint = intersection;
nearestDistance = distance;
}
}

assert currentNearest != null;
return new NearestWallResult(currentNearest, currentNearestPoint, nearestDistance);
}

/**
* Builds a list of endpoints for the sweep algorithm to consume.
*
* <p>The endpoints will be unique (i.e., no coordinate is represented more than once) and in a
* consistent orientation (i.e., counterclockwise around the origin). In addition, all endpoints
* will have their starting and ending walls filled according to which walls are incident to the
* corresponding point.
*
* @param origin The center of vision, by which orientation can be determined.
* @param visionBlockingSegments The "walls" that are able to block vision. All points in these
* walls will be present in the returned list.
* @return A list of all endpoints in counterclockwise order.
*/
private static List<VisibilitySweepEndpoint> getSweepEndpoints(
Coordinate origin, List<LineString> visionBlockingSegments) {
final Map<Coordinate, VisibilitySweepEndpoint> endpointsByPosition = new HashMap<>();
for (final var segment : visionBlockingSegments) {
VisibilitySweepEndpoint current = null;
for (final var coordinate : segment.getCoordinates()) {
final var previous = current;
current =
endpointsByPosition.computeIfAbsent(
coordinate, c -> new VisibilitySweepEndpoint(c, origin));
if (previous == null) {
// We just started this segment; still need a second point.
continue;
}

final var isForwards =
Orientation.COUNTERCLOCKWISE
== Orientation.index(origin, previous.getPoint(), current.getPoint());
// Make sure the wall always goes in the counterclockwise direction.
final LineSegment wall =
isForwards
? new LineSegment(previous.getPoint(), coordinate)
: new LineSegment(coordinate, previous.getPoint());
if (isForwards) {
previous.startsWall(wall);
current.endsWall(wall);
} else {
previous.endsWall(wall);
current.startsWall(wall);
}
}
}
final List<VisibilitySweepEndpoint> endpoints = new ArrayList<>(endpointsByPosition.values());

endpoints.sort(
Comparator.comparingDouble(VisibilitySweepEndpoint::getPseudoangle)
.thenComparing(VisibilitySweepEndpoint::getDistance));

return endpoints;
}

private static @Nullable Geometry calculateVisibleArea(
Coordinate origin, List<LineString> visionBlockingSegments, PreparedGeometry visionGeometry) {
if (visionBlockingSegments.isEmpty()) {
// No topology, apparently.
return null;
}

/*
* Unioning all the line segments has the nice effect of noding any intersections between line
* segments. Without this, it may not be valid.
* Note: if the geometry were only composed of one topology, it would certainly be valid due to
* its "flat" nature. But even in that case, it is more robust to due the union in case this
* flatness assumption ever changes.
*/
final var allWallGeometry = new UnaryUnionOp(visionBlockingSegments).union();
// Replace the original geometry with the well-defined geometry.
visionBlockingSegments = new ArrayList<>();
LineStringExtracter.getLines(allWallGeometry, visionBlockingSegments);

/*
* The algorithm requires walls in every direction. The easiest way to accomplish this is to add
* the boundary of the bounding box.
*/
final var envelope = allWallGeometry.getEnvelopeInternal();
envelope.expandToInclude(visionGeometry.getGeometry().getEnvelopeInternal());
// Exact expansion distance doesn't matter, we just don't want the boundary walls to overlap
// endpoints from real walls.
envelope.expandBy(1.0);
// Because we definitely have geometry, the envelope will always be a non-trivial rectangle.
visionBlockingSegments.add(((Polygon) geometryFactory.toGeometry(envelope)).getExteriorRing());

// Now that we have valid geometry and a bounding box, we can continue with the sweep.

final var endpoints = getSweepEndpoints(origin, visionBlockingSegments);
Set<LineSegment> openWalls = Collections.newSetFromMap(new IdentityHashMap<>());

// This initial sweep just makes sure we have the correct open set to start.
for (final var endpoint : endpoints) {
openWalls.addAll(endpoint.getStartsWalls());
openWalls.removeAll(endpoint.getEndsWalls());
}

// Now for the real sweep. Make sure to process the first point once more at the end to ensure
// the sweep covers the full 360 degrees.
endpoints.add(endpoints.get(0));
List<Coordinate> visionPoints = new ArrayList<>();
for (final var endpoint : endpoints) {
assert !openWalls.isEmpty();

final var ray = new LineSegment(origin, endpoint.getPoint());
final var nearestWallResult = findNearestOpenWall(openWalls, ray);

openWalls.addAll(endpoint.getStartsWalls());
openWalls.removeAll(endpoint.getEndsWalls());

// Find a new nearest wall.
final var newNearestWallResult = findNearestOpenWall(openWalls, ray);

if (newNearestWallResult.wall != nearestWallResult.wall) {
// Implies we have changed which wall we are at. Need to figure out projections.

if (openWalls.contains(nearestWallResult.wall())) {
// The previous nearest wall is still open. I.e., we didn't fall of its end but
// encountered a new closer wall. So we project the current point to the previous
// nearest wall, then step to the current point.
visionPoints.add(nearestWallResult.point());
visionPoints.add(endpoint.getPoint());
} else {
// The previous nearest wall is now closed. I.e., we "fell off" it and therefore have
// encountered a different wall. So we step from the current point (which is on the
// previous wall) to the projection on the new wall.
visionPoints.add(endpoint.getPoint());
// Special case: if the two walls are adjacent, they share the current point. We don't
// need to add the point twice, so just skip in that case.
if (!endpoint.getStartsWalls().contains(newNearestWallResult.wall())) {
visionPoints.add(newNearestWallResult.point());
}
}
}
}
if (visionPoints.size() < 3) {
// This shouldn't happen, but just in case.
log.warn("Sweep produced too few points: {}", visionPoints);
return null;
}
visionPoints.add(visionPoints.get(0)); // Ensure a closed loop.

return geometryFactory.createPolygon(visionPoints.toArray(Coordinate[]::new));
}

/**
* Expose visible area and previous path of all tokens in the token set. Server and clients are
* updated.
Expand Down

This file was deleted.

Loading

0 comments on commit f4a63e5

Please sign in to comment.