Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify AreaMeta and move vision blocking algorithm into a dedicated class #4577

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading