Skip to content

Commit b1a5a35

Browse files
committed
Factor visibility calculation out of FogUtil
A new class `VisibilityProblem` is responsible for collecting blocking segments and generating a visibility polygon from them. No meaningful changes have been made to the algorithm itself, though it does support some new conveniences such as progressively building up the problem set before solving.
1 parent 16dcd41 commit b1a5a35

File tree

2 files changed

+237
-189
lines changed

2 files changed

+237
-189
lines changed

src/main/java/net/rptools/maptool/client/ui/zone/FogUtil.java

Lines changed: 9 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -18,25 +18,20 @@
1818
import java.awt.Rectangle;
1919
import java.awt.geom.Area;
2020
import java.util.ArrayList;
21-
import java.util.Collections;
22-
import java.util.Comparator;
2321
import java.util.EnumMap;
24-
import java.util.HashMap;
2522
import java.util.HashSet;
26-
import java.util.IdentityHashMap;
2723
import java.util.List;
2824
import java.util.Map;
2925
import java.util.Set;
3026
import java.util.function.Consumer;
3127
import javax.annotation.Nonnull;
32-
import javax.annotation.Nullable;
3328
import net.rptools.lib.CodeTimer;
3429
import net.rptools.lib.GeometryUtil;
3530
import net.rptools.maptool.client.AppUtil;
3631
import net.rptools.maptool.client.MapTool;
3732
import net.rptools.maptool.client.ui.zone.renderer.ZoneRenderer;
3833
import net.rptools.maptool.client.ui.zone.vbl.AreaTree;
39-
import net.rptools.maptool.client.ui.zone.vbl.VisibilitySweepEndpoint;
34+
import net.rptools.maptool.client.ui.zone.vbl.VisibilityProblem;
4035
import net.rptools.maptool.client.ui.zone.vbl.VisionBlockingAccumulator;
4136
import net.rptools.maptool.model.AbstractPoint;
4237
import net.rptools.maptool.model.CellPoint;
@@ -51,18 +46,11 @@
5146
import net.rptools.maptool.model.player.Player.Role;
5247
import org.apache.logging.log4j.LogManager;
5348
import org.apache.logging.log4j.Logger;
54-
import org.locationtech.jts.algorithm.Orientation;
5549
import org.locationtech.jts.awt.ShapeWriter;
5650
import org.locationtech.jts.geom.Coordinate;
5751
import org.locationtech.jts.geom.Geometry;
5852
import org.locationtech.jts.geom.GeometryFactory;
59-
import org.locationtech.jts.geom.LineSegment;
60-
import org.locationtech.jts.geom.LineString;
61-
import org.locationtech.jts.geom.Polygon;
62-
import org.locationtech.jts.geom.prep.PreparedGeometry;
6353
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
64-
import org.locationtech.jts.geom.util.LineStringExtracter;
65-
import org.locationtech.jts.operation.union.UnaryUnionOp;
6654

6755
public class FogUtil {
6856
private static final Logger log = LogManager.getLogger(FogUtil.class);
@@ -103,6 +91,9 @@ public class FogUtil {
10391

10492
List<Geometry> visibleAreas = new ArrayList<>();
10593
for (final var topology : topologies.entrySet()) {
94+
final var solver =
95+
new VisibilityProblem(
96+
geometryFactory, new Coordinate(origin.getX(), origin.getY()), visionGeometry);
10697
final var accumulator =
10798
new VisionBlockingAccumulator(geometryFactory, origin, visionGeometry);
10899
final var isVisionCompletelyBlocked = accumulator.add(topology.getKey(), topology.getValue());
@@ -111,11 +102,11 @@ public class FogUtil {
111102
return new Area();
112103
}
113104

114-
final var visibleArea =
115-
calculateVisibleArea(
116-
new Coordinate(origin.getX(), origin.getY()),
117-
accumulator.getVisionBlockingSegments(),
118-
visionGeometry);
105+
for (var string : accumulator.getVisionBlockingSegments()) {
106+
solver.add(string);
107+
}
108+
109+
final var visibleArea = solver.solve();
119110
if (visibleArea != null) {
120111
visibleAreas.add(visibleArea);
121112
}
@@ -136,177 +127,6 @@ public class FogUtil {
136127
return vision;
137128
}
138129

139-
private record NearestWallResult(LineSegment wall, Coordinate point, double distance) {}
140-
141-
private static NearestWallResult findNearestOpenWall(
142-
Set<LineSegment> openWalls, LineSegment ray) {
143-
assert !openWalls.isEmpty();
144-
145-
@Nullable LineSegment currentNearest = null;
146-
@Nullable Coordinate currentNearestPoint = null;
147-
double nearestDistance = Double.MAX_VALUE;
148-
for (final var openWall : openWalls) {
149-
final var intersection = ray.lineIntersection(openWall);
150-
if (intersection == null) {
151-
continue;
152-
}
153-
154-
final var distance = ray.p0.distance(intersection);
155-
if (distance < nearestDistance) {
156-
currentNearest = openWall;
157-
currentNearestPoint = intersection;
158-
nearestDistance = distance;
159-
}
160-
}
161-
162-
assert currentNearest != null;
163-
return new NearestWallResult(currentNearest, currentNearestPoint, nearestDistance);
164-
}
165-
166-
/**
167-
* Builds a list of endpoints for the sweep algorithm to consume.
168-
*
169-
* <p>The endpoints will be unique (i.e., no coordinate is represented more than once) and in a
170-
* consistent orientation (i.e., counterclockwise around the origin). In addition, all endpoints
171-
* will have their starting and ending walls filled according to which walls are incident to the
172-
* corresponding point.
173-
*
174-
* @param origin The center of vision, by which orientation can be determined.
175-
* @param visionBlockingSegments The "walls" that are able to block vision. All points in these
176-
* walls will be present in the returned list.
177-
* @return A list of all endpoints in counterclockwise order.
178-
*/
179-
private static List<VisibilitySweepEndpoint> getSweepEndpoints(
180-
Coordinate origin, List<LineString> visionBlockingSegments) {
181-
final Map<Coordinate, VisibilitySweepEndpoint> endpointsByPosition = new HashMap<>();
182-
for (final var segment : visionBlockingSegments) {
183-
VisibilitySweepEndpoint current = null;
184-
for (final var coordinate : segment.getCoordinates()) {
185-
final var previous = current;
186-
current =
187-
endpointsByPosition.computeIfAbsent(
188-
coordinate, c -> new VisibilitySweepEndpoint(c, origin));
189-
if (previous == null) {
190-
// We just started this segment; still need a second point.
191-
continue;
192-
}
193-
194-
final var isForwards =
195-
Orientation.COUNTERCLOCKWISE
196-
== Orientation.index(origin, previous.getPoint(), current.getPoint());
197-
// Make sure the wall always goes in the counterclockwise direction.
198-
final LineSegment wall =
199-
isForwards
200-
? new LineSegment(previous.getPoint(), coordinate)
201-
: new LineSegment(coordinate, previous.getPoint());
202-
if (isForwards) {
203-
previous.startsWall(wall);
204-
current.endsWall(wall);
205-
} else {
206-
previous.endsWall(wall);
207-
current.startsWall(wall);
208-
}
209-
}
210-
}
211-
final List<VisibilitySweepEndpoint> endpoints = new ArrayList<>(endpointsByPosition.values());
212-
213-
endpoints.sort(
214-
Comparator.comparingDouble(VisibilitySweepEndpoint::getPseudoangle)
215-
.thenComparing(VisibilitySweepEndpoint::getDistance));
216-
217-
return endpoints;
218-
}
219-
220-
private static @Nullable Geometry calculateVisibleArea(
221-
Coordinate origin, List<LineString> visionBlockingSegments, PreparedGeometry visionGeometry) {
222-
if (visionBlockingSegments.isEmpty()) {
223-
// No topology, apparently.
224-
return null;
225-
}
226-
227-
/*
228-
* Unioning all the line segments has the nice effect of noding any intersections between line
229-
* segments. Without this, it may not be valid.
230-
* Note: if the geometry were only composed of one topology, it would certainly be valid due to
231-
* its "flat" nature. But even in that case, it is more robust to due the union in case this
232-
* flatness assumption ever changes.
233-
*/
234-
final var allWallGeometry = new UnaryUnionOp(visionBlockingSegments).union();
235-
// Replace the original geometry with the well-defined geometry.
236-
visionBlockingSegments = new ArrayList<>();
237-
LineStringExtracter.getLines(allWallGeometry, visionBlockingSegments);
238-
239-
/*
240-
* The algorithm requires walls in every direction. The easiest way to accomplish this is to add
241-
* the boundary of the bounding box.
242-
*/
243-
final var envelope = allWallGeometry.getEnvelopeInternal();
244-
envelope.expandToInclude(visionGeometry.getGeometry().getEnvelopeInternal());
245-
// Exact expansion distance doesn't matter, we just don't want the boundary walls to overlap
246-
// endpoints from real walls.
247-
envelope.expandBy(1.0);
248-
// Because we definitely have geometry, the envelope will always be a non-trivial rectangle.
249-
visionBlockingSegments.add(((Polygon) geometryFactory.toGeometry(envelope)).getExteriorRing());
250-
251-
// Now that we have valid geometry and a bounding box, we can continue with the sweep.
252-
253-
final var endpoints = getSweepEndpoints(origin, visionBlockingSegments);
254-
Set<LineSegment> openWalls = Collections.newSetFromMap(new IdentityHashMap<>());
255-
256-
// This initial sweep just makes sure we have the correct open set to start.
257-
for (final var endpoint : endpoints) {
258-
openWalls.addAll(endpoint.getStartsWalls());
259-
openWalls.removeAll(endpoint.getEndsWalls());
260-
}
261-
262-
// Now for the real sweep. Make sure to process the first point once more at the end to ensure
263-
// the sweep covers the full 360 degrees.
264-
endpoints.add(endpoints.get(0));
265-
List<Coordinate> visionPoints = new ArrayList<>();
266-
for (final var endpoint : endpoints) {
267-
assert !openWalls.isEmpty();
268-
269-
final var ray = new LineSegment(origin, endpoint.getPoint());
270-
final var nearestWallResult = findNearestOpenWall(openWalls, ray);
271-
272-
openWalls.addAll(endpoint.getStartsWalls());
273-
openWalls.removeAll(endpoint.getEndsWalls());
274-
275-
// Find a new nearest wall.
276-
final var newNearestWallResult = findNearestOpenWall(openWalls, ray);
277-
278-
if (newNearestWallResult.wall != nearestWallResult.wall) {
279-
// Implies we have changed which wall we are at. Need to figure out projections.
280-
281-
if (openWalls.contains(nearestWallResult.wall())) {
282-
// The previous nearest wall is still open. I.e., we didn't fall of its end but
283-
// encountered a new closer wall. So we project the current point to the previous
284-
// nearest wall, then step to the current point.
285-
visionPoints.add(nearestWallResult.point());
286-
visionPoints.add(endpoint.getPoint());
287-
} else {
288-
// The previous nearest wall is now closed. I.e., we "fell off" it and therefore have
289-
// encountered a different wall. So we step from the current point (which is on the
290-
// previous wall) to the projection on the new wall.
291-
visionPoints.add(endpoint.getPoint());
292-
// Special case: if the two walls are adjacent, they share the current point. We don't
293-
// need to add the point twice, so just skip in that case.
294-
if (!endpoint.getStartsWalls().contains(newNearestWallResult.wall())) {
295-
visionPoints.add(newNearestWallResult.point());
296-
}
297-
}
298-
}
299-
}
300-
if (visionPoints.size() < 3) {
301-
// This shouldn't happen, but just in case.
302-
log.warn("Sweep produced too few points: {}", visionPoints);
303-
return null;
304-
}
305-
visionPoints.add(visionPoints.get(0)); // Ensure a closed loop.
306-
307-
return geometryFactory.createPolygon(visionPoints.toArray(Coordinate[]::new));
308-
}
309-
310130
/**
311131
* Expose visible area and previous path of all tokens in the token set. Server and clients are
312132
* updated.

0 commit comments

Comments
 (0)